oauth2fast-fastapi 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oauth2fast_fastapi/__init__.py +21 -0
- oauth2fast_fastapi/__version__.py +1 -0
- oauth2fast_fastapi/database.py +12 -0
- oauth2fast_fastapi/dependencies.py +90 -0
- oauth2fast_fastapi/mail/__init__.py +1 -0
- oauth2fast_fastapi/mail/connection.py +22 -0
- oauth2fast_fastapi/mail/service.py +39 -0
- oauth2fast_fastapi/mail/templates/verification.html +231 -0
- oauth2fast_fastapi/mail/templates/welcome.html +12 -0
- oauth2fast_fastapi/models/__init__.py +1 -0
- oauth2fast_fastapi/models/bases.py +10 -0
- oauth2fast_fastapi/models/mixins.py +32 -0
- oauth2fast_fastapi/models/user_model.py +21 -0
- oauth2fast_fastapi/routers/__init__.py +0 -0
- oauth2fast_fastapi/routers/base_router.py +61 -0
- oauth2fast_fastapi/routers/users_router.py +249 -0
- oauth2fast_fastapi/schemas/__init__.py +0 -0
- oauth2fast_fastapi/schemas/mixins.py +8 -0
- oauth2fast_fastapi/schemas/token_schema.py +14 -0
- oauth2fast_fastapi/schemas/user_schema.py +17 -0
- oauth2fast_fastapi/schemas/verification_schema.py +20 -0
- oauth2fast_fastapi/settings.py +71 -0
- oauth2fast_fastapi/utils/__init__.py +0 -0
- oauth2fast_fastapi/utils/password_utils.py +33 -0
- oauth2fast_fastapi/utils/token_utils.py +55 -0
- oauth2fast_fastapi/utils/verification_utils.py +53 -0
- oauth2fast_fastapi-0.1.1.dist-info/METADATA +390 -0
- oauth2fast_fastapi-0.1.1.dist-info/RECORD +31 -0
- oauth2fast_fastapi-0.1.1.dist-info/WHEEL +5 -0
- oauth2fast_fastapi-0.1.1.dist-info/licenses/LICENSE +21 -0
- oauth2fast_fastapi-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OAuth2Fast-FastAPI - Fast and secure OAuth2 authentication for FastAPI
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .__version__ import __version__
|
|
6
|
+
from .database import engine
|
|
7
|
+
from .dependencies import get_auth_session, get_current_user, get_current_verified_user
|
|
8
|
+
from .models.user_model import User
|
|
9
|
+
from .routers.base_router import router
|
|
10
|
+
from .settings import settings
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"__version__",
|
|
14
|
+
"engine",
|
|
15
|
+
"get_auth_session",
|
|
16
|
+
"get_current_user",
|
|
17
|
+
"get_current_verified_user",
|
|
18
|
+
"User",
|
|
19
|
+
"router",
|
|
20
|
+
"settings",
|
|
21
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
2
|
+
|
|
3
|
+
from .settings import settings
|
|
4
|
+
|
|
5
|
+
DB_URL = f"postgresql+asyncpg://{settings.auth_db.username}:{settings.auth_db.password.get_secret_value()}@{settings.auth_db.hostname}:{settings.auth_db.port}/{settings.auth_db.name}"
|
|
6
|
+
engine = create_async_engine(DB_URL, echo=False)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Sesion asíncrona
|
|
10
|
+
async_session = async_sessionmaker(
|
|
11
|
+
bind=engine, class_=AsyncSession, expire_on_commit=False
|
|
12
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from fastapi import Depends, HTTPException, status
|
|
2
|
+
from fastapi.security import OAuth2PasswordBearer
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from sqlmodel import select
|
|
5
|
+
|
|
6
|
+
from .database import async_session
|
|
7
|
+
from .models.user_model import User
|
|
8
|
+
from .schemas.token_schema import TokenData
|
|
9
|
+
from .settings import settings
|
|
10
|
+
from .utils.token_utils import verify_token
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Dependency de Database para usar en endpoints
|
|
14
|
+
async def get_auth_session():
|
|
15
|
+
async with async_session() as session:
|
|
16
|
+
yield session
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Dependency de Auth Base
|
|
20
|
+
oauth2_dependency = OAuth2PasswordBearer(
|
|
21
|
+
tokenUrl=f"{settings.auth_url_prefix.get_secret_value()}/token"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def get_current_user(
|
|
26
|
+
token: str = Depends(oauth2_dependency),
|
|
27
|
+
session: AsyncSession = Depends(get_auth_session),
|
|
28
|
+
) -> User:
|
|
29
|
+
"""
|
|
30
|
+
Dependency to get the current authenticated user from JWT token.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
token: JWT token from Authorization header
|
|
34
|
+
session: Database session
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Authenticated User object
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
HTTPException: If token is invalid or user not found
|
|
41
|
+
"""
|
|
42
|
+
credentials_exception = HTTPException(
|
|
43
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
44
|
+
detail="Could not validate credentials",
|
|
45
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Verify and decode token
|
|
49
|
+
payload = verify_token(token)
|
|
50
|
+
if payload is None:
|
|
51
|
+
raise credentials_exception
|
|
52
|
+
|
|
53
|
+
# Extract email from token
|
|
54
|
+
email: str | None = payload.get("sub")
|
|
55
|
+
if email is None:
|
|
56
|
+
raise credentials_exception
|
|
57
|
+
|
|
58
|
+
token_data = TokenData(email=email)
|
|
59
|
+
|
|
60
|
+
# Get user from database
|
|
61
|
+
result = await session.execute(select(User).where(User.email == token_data.email))
|
|
62
|
+
user = result.scalar_one_or_none()
|
|
63
|
+
|
|
64
|
+
if user is None:
|
|
65
|
+
raise credentials_exception
|
|
66
|
+
|
|
67
|
+
return user
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def get_current_verified_user(
|
|
71
|
+
current_user: User = Depends(get_current_user),
|
|
72
|
+
) -> User:
|
|
73
|
+
"""
|
|
74
|
+
Dependency to get the current authenticated and verified user.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
current_user: Current authenticated user
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Verified User object
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
HTTPException: If user is not verified
|
|
84
|
+
"""
|
|
85
|
+
if not current_user.is_verified:
|
|
86
|
+
raise HTTPException(
|
|
87
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
88
|
+
detail="Email not verified",
|
|
89
|
+
)
|
|
90
|
+
return current_user
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .service import send_verification_email as send_verification_email
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from fastapi_mail import ConnectionConfig
|
|
4
|
+
|
|
5
|
+
from ..settings import settings
|
|
6
|
+
|
|
7
|
+
BASE_DIR = Path(__file__).resolve().parent
|
|
8
|
+
TEMPLATES_PATH = BASE_DIR / "templates"
|
|
9
|
+
|
|
10
|
+
config = ConnectionConfig(
|
|
11
|
+
MAIL_USERNAME=settings.auth_mail_server.username,
|
|
12
|
+
MAIL_PASSWORD=settings.auth_mail_server.password,
|
|
13
|
+
MAIL_FROM=settings.auth_mail_server.from_direction,
|
|
14
|
+
MAIL_PORT=settings.auth_mail_server.port,
|
|
15
|
+
MAIL_SERVER=settings.auth_mail_server.server,
|
|
16
|
+
MAIL_FROM_NAME=settings.auth_mail_server.from_name,
|
|
17
|
+
MAIL_STARTTLS=False,
|
|
18
|
+
MAIL_SSL_TLS=True,
|
|
19
|
+
USE_CREDENTIALS=True,
|
|
20
|
+
VALIDATE_CERTS=True,
|
|
21
|
+
TEMPLATE_FOLDER=TEMPLATES_PATH,
|
|
22
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from fastapi import HTTPException, status
|
|
2
|
+
from fastapi_mail import FastMail, MessageSchema, MessageType
|
|
3
|
+
|
|
4
|
+
from ..settings import settings
|
|
5
|
+
from .connection import config
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def send_verification_email(email: str, verification_url: str) -> None:
|
|
9
|
+
"""
|
|
10
|
+
Send verification email to user.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
email: User's email address
|
|
14
|
+
verification_url: Complete URL for email verification
|
|
15
|
+
|
|
16
|
+
Raises:
|
|
17
|
+
HTTPException: If email sending fails
|
|
18
|
+
"""
|
|
19
|
+
try:
|
|
20
|
+
message = MessageSchema(
|
|
21
|
+
subject=f"Verifica tu cuenta - {settings.project_name}",
|
|
22
|
+
recipients=[email],
|
|
23
|
+
template_body={
|
|
24
|
+
"email": email,
|
|
25
|
+
"project_name": settings.project_name,
|
|
26
|
+
"verification_url": verification_url,
|
|
27
|
+
"support_email": settings.auth_mail_server.from_direction,
|
|
28
|
+
},
|
|
29
|
+
subtype=MessageType.html,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
fm = FastMail(config)
|
|
33
|
+
await fm.send_message(message, template_name="verification.html")
|
|
34
|
+
|
|
35
|
+
except Exception as e:
|
|
36
|
+
raise HTTPException(
|
|
37
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
38
|
+
detail=f"Error sending verification email: {str(e)}",
|
|
39
|
+
) from e
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="es">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
7
|
+
<title>Verifica tu cuenta - {{project_name}}</title>
|
|
8
|
+
<style>
|
|
9
|
+
* {
|
|
10
|
+
margin: 0;
|
|
11
|
+
padding: 0;
|
|
12
|
+
box-sizing: border-box;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
body {
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
17
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
18
|
+
padding: 40px 20px;
|
|
19
|
+
line-height: 1.6;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.email-container {
|
|
23
|
+
max-width: 600px;
|
|
24
|
+
margin: 0 auto;
|
|
25
|
+
background: #ffffff;
|
|
26
|
+
border-radius: 16px;
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.header {
|
|
32
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
33
|
+
padding: 40px 30px;
|
|
34
|
+
text-align: center;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.header h1 {
|
|
38
|
+
color: #ffffff;
|
|
39
|
+
font-size: 28px;
|
|
40
|
+
font-weight: 700;
|
|
41
|
+
margin-bottom: 10px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.header p {
|
|
45
|
+
color: rgba(255, 255, 255, 0.9);
|
|
46
|
+
font-size: 16px;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.content {
|
|
50
|
+
padding: 40px 30px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.greeting {
|
|
54
|
+
font-size: 20px;
|
|
55
|
+
color: #2d3748;
|
|
56
|
+
margin-bottom: 20px;
|
|
57
|
+
font-weight: 600;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.message {
|
|
61
|
+
color: #4a5568;
|
|
62
|
+
font-size: 16px;
|
|
63
|
+
margin-bottom: 30px;
|
|
64
|
+
line-height: 1.8;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.verification-box {
|
|
68
|
+
background: linear-gradient(135deg, #f7fafc 0%, #edf2f7 100%);
|
|
69
|
+
border-radius: 12px;
|
|
70
|
+
padding: 30px;
|
|
71
|
+
text-align: center;
|
|
72
|
+
margin: 30px 0;
|
|
73
|
+
border: 2px solid #e2e8f0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.verification-box p {
|
|
77
|
+
color: #4a5568;
|
|
78
|
+
margin-bottom: 20px;
|
|
79
|
+
font-size: 15px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.verify-button {
|
|
83
|
+
display: inline-block;
|
|
84
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
85
|
+
color: #ffffff;
|
|
86
|
+
text-decoration: none;
|
|
87
|
+
padding: 16px 40px;
|
|
88
|
+
border-radius: 8px;
|
|
89
|
+
font-weight: 600;
|
|
90
|
+
font-size: 16px;
|
|
91
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
92
|
+
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.verify-button:hover {
|
|
96
|
+
transform: translateY(-2px);
|
|
97
|
+
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.divider {
|
|
101
|
+
height: 1px;
|
|
102
|
+
background: linear-gradient(to right, transparent, #cbd5e0, transparent);
|
|
103
|
+
margin: 30px 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.alternative-text {
|
|
107
|
+
color: #718096;
|
|
108
|
+
font-size: 14px;
|
|
109
|
+
margin-top: 20px;
|
|
110
|
+
line-height: 1.6;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.link-box {
|
|
114
|
+
background: #f7fafc;
|
|
115
|
+
border: 1px dashed #cbd5e0;
|
|
116
|
+
border-radius: 8px;
|
|
117
|
+
padding: 15px;
|
|
118
|
+
margin-top: 15px;
|
|
119
|
+
word-break: break-all;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.link-box a {
|
|
123
|
+
color: #667eea;
|
|
124
|
+
text-decoration: none;
|
|
125
|
+
font-size: 13px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.footer {
|
|
129
|
+
background: #f7fafc;
|
|
130
|
+
padding: 30px;
|
|
131
|
+
text-align: center;
|
|
132
|
+
color: #718096;
|
|
133
|
+
font-size: 14px;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.footer p {
|
|
137
|
+
margin-bottom: 10px;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.footer a {
|
|
141
|
+
color: #667eea;
|
|
142
|
+
text-decoration: none;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.security-note {
|
|
146
|
+
background: #fff5f5;
|
|
147
|
+
border-left: 4px solid #fc8181;
|
|
148
|
+
padding: 15px;
|
|
149
|
+
margin-top: 30px;
|
|
150
|
+
border-radius: 4px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.security-note p {
|
|
154
|
+
color: #742a2a;
|
|
155
|
+
font-size: 14px;
|
|
156
|
+
margin: 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@media only screen and (max-width: 600px) {
|
|
160
|
+
body {
|
|
161
|
+
padding: 20px 10px;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.header h1 {
|
|
165
|
+
font-size: 24px;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.content {
|
|
169
|
+
padding: 30px 20px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.verify-button {
|
|
173
|
+
padding: 14px 30px;
|
|
174
|
+
font-size: 15px;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
</style>
|
|
178
|
+
</head>
|
|
179
|
+
|
|
180
|
+
<body>
|
|
181
|
+
<div class="email-container">
|
|
182
|
+
<div class="header">
|
|
183
|
+
<h1>🎉 ¡Bienvenido a {{project_name}}!</h1>
|
|
184
|
+
<p>Estamos emocionados de tenerte con nosotros</p>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div class="content">
|
|
188
|
+
<p class="greeting">Hola {{email}},</p>
|
|
189
|
+
|
|
190
|
+
<p class="message">
|
|
191
|
+
Gracias por registrarte en <strong>{{project_name}}</strong>. Para completar tu registro y comenzar a
|
|
192
|
+
disfrutar de todos nuestros servicios, necesitamos verificar tu dirección de correo electrónico.
|
|
193
|
+
</p>
|
|
194
|
+
|
|
195
|
+
<div class="verification-box">
|
|
196
|
+
<p><strong>Verifica tu cuenta ahora</strong></p>
|
|
197
|
+
<a href="{{verification_url}}" class="verify-button">Verificar mi cuenta</a>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<p class="message">
|
|
201
|
+
Este enlace de verificación es válido por <strong>24 horas</strong>. Si no solicitaste esta cuenta,
|
|
202
|
+
puedes ignorar este correo de forma segura.
|
|
203
|
+
</p>
|
|
204
|
+
|
|
205
|
+
<div class="divider"></div>
|
|
206
|
+
|
|
207
|
+
<p class="alternative-text">
|
|
208
|
+
<strong>¿El botón no funciona?</strong><br>
|
|
209
|
+
Copia y pega el siguiente enlace en tu navegador:
|
|
210
|
+
</p>
|
|
211
|
+
<div class="link-box">
|
|
212
|
+
<a href="{{verification_url}}">{{verification_url}}</a>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div class="security-note">
|
|
216
|
+
<p>
|
|
217
|
+
<strong>⚠️ Nota de seguridad:</strong> Nunca compartas este enlace con nadie. Nuestro equipo nunca
|
|
218
|
+
te pedirá tu contraseña por correo electrónico.
|
|
219
|
+
</p>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div class="footer">
|
|
224
|
+
<p><strong>{{project_name}}</strong></p>
|
|
225
|
+
<p>Este es un correo automático, por favor no respondas a este mensaje.</p>
|
|
226
|
+
<p>Si necesitas ayuda, contáctanos en <a href="mailto:{{support_email}}">{{support_email}}</a></p>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</body>
|
|
230
|
+
|
|
231
|
+
</html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Bienvenido a ISO-Solautyc</title>
|
|
5
|
+
</head>
|
|
6
|
+
<body>
|
|
7
|
+
<h1>¡Hola, {{ name }}!</h1>
|
|
8
|
+
<p>Te damos la bienvenida a ISO-Solautyc. Gracias por registrarte.</p>
|
|
9
|
+
<p>Este es un correo de prueba enviado mediante FastAPI-Mail y pytest.</p>
|
|
10
|
+
<p>Saludos cordiales,<br>El equipo de ISO-Solautyc</p>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .bases import BaseModel as BaseModel
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from datetime import UTC, datetime
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import DateTime, func
|
|
4
|
+
from sqlmodel import Column
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TimestampMixin:
|
|
8
|
+
"""Mixin reutilizable para marcas de tiempo en UTC."""
|
|
9
|
+
|
|
10
|
+
created_at = Column(
|
|
11
|
+
DateTime(timezone=True),
|
|
12
|
+
nullable=False,
|
|
13
|
+
default=lambda: datetime.now(UTC),
|
|
14
|
+
)
|
|
15
|
+
updated_at = Column(
|
|
16
|
+
DateTime(timezone=True),
|
|
17
|
+
nullable=False,
|
|
18
|
+
default=lambda: datetime.now(UTC),
|
|
19
|
+
onupdate=func.now(),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# class Example(TimestampMixin, SQLModel, table=True):
|
|
24
|
+
# """Ejemplo de modelo con timestamps automáticos."""
|
|
25
|
+
# id: int | None = Field(default=None, primary_key=True)
|
|
26
|
+
# name: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Opcional: asegurar que updated_at se actualice también del lado de Python
|
|
30
|
+
# @event.listens_for(Example, "before_update", propagate=True)
|
|
31
|
+
# def receive_before_update(mapper, connection, target):
|
|
32
|
+
# target.updated_at = datetime.now(timezone.utc)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from sqlmodel import (
|
|
2
|
+
BigInteger,
|
|
3
|
+
Column,
|
|
4
|
+
Field,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
from .bases import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Database model for User
|
|
11
|
+
# This model represents a user registered in the database
|
|
12
|
+
class User(BaseModel, table=True):
|
|
13
|
+
__tablename__ = "users"
|
|
14
|
+
|
|
15
|
+
id: int = Field(
|
|
16
|
+
default=None, sa_column=Column(BigInteger, index=True, primary_key=True)
|
|
17
|
+
)
|
|
18
|
+
name: str = Field(index=True)
|
|
19
|
+
email: str = Field(index=True, unique=True)
|
|
20
|
+
password: str = Field()
|
|
21
|
+
is_verified: bool = Field(default=False)
|
|
File without changes
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from fastapi import APIRouter, Depends, HTTPException, status
|
|
2
|
+
from fastapi.security import OAuth2PasswordRequestForm
|
|
3
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
4
|
+
from sqlmodel import select
|
|
5
|
+
|
|
6
|
+
from ..dependencies import get_auth_session
|
|
7
|
+
from ..models.user_model import User
|
|
8
|
+
from ..schemas.token_schema import Token
|
|
9
|
+
from ..settings import settings
|
|
10
|
+
from ..utils.password_utils import verify_password
|
|
11
|
+
from ..utils.token_utils import create_access_token
|
|
12
|
+
from .users_router import router as users_router
|
|
13
|
+
|
|
14
|
+
# Ensure prefix starts with "/"
|
|
15
|
+
prefix = settings.auth_url_prefix.get_secret_value()
|
|
16
|
+
if not prefix.startswith("/"):
|
|
17
|
+
prefix = f"/{prefix}"
|
|
18
|
+
|
|
19
|
+
router = APIRouter(
|
|
20
|
+
prefix=prefix,
|
|
21
|
+
tags=[prefix.strip("/").capitalize()],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Include users router
|
|
25
|
+
router.include_router(users_router)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@router.post("/token", response_model=Token)
|
|
29
|
+
async def login(
|
|
30
|
+
form_data: OAuth2PasswordRequestForm = Depends(),
|
|
31
|
+
session: AsyncSession = Depends(get_auth_session),
|
|
32
|
+
) -> Token:
|
|
33
|
+
"""
|
|
34
|
+
OAuth2 compatible token login endpoint.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
form_data: OAuth2 form with username (email) and password
|
|
38
|
+
session: Database session
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Token with access_token and token_type
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
HTTPException: If credentials are invalid
|
|
45
|
+
"""
|
|
46
|
+
# Get user by email (username in OAuth2 form)
|
|
47
|
+
result = await session.execute(select(User).where(User.email == form_data.username))
|
|
48
|
+
user = result.scalar_one_or_none()
|
|
49
|
+
|
|
50
|
+
# Verify user exists and password is correct
|
|
51
|
+
if not user or not verify_password(form_data.password, user.password):
|
|
52
|
+
raise HTTPException(
|
|
53
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
54
|
+
detail="Incorrect email or password",
|
|
55
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Create access token
|
|
59
|
+
access_token = create_access_token(data={"sub": user.email})
|
|
60
|
+
|
|
61
|
+
return Token(access_token=access_token, token_type="bearer")
|