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.
@@ -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,10 @@
1
+ from sqlmodel import MetaData, SQLModel
2
+
3
+ from .mixins import TimestampMixin
4
+
5
+ metadata = MetaData()
6
+
7
+
8
+ class BaseModel(TimestampMixin, SQLModel):
9
+ __abstract__ = True
10
+ metadata = metadata
@@ -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")