This commit is contained in:
Iliyan Angelov
2025-11-29 01:21:11 +02:00
parent cf97df9aeb
commit fb16d7ae34
2856 changed files with 5558 additions and 248 deletions

View File

@@ -7,7 +7,7 @@ import uuid
import os
from ..config.database import get_db
from ..services.auth_service import auth_service
from ..schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, ForgotPasswordRequest, ResetPasswordRequest, AuthResponse, TokenResponse, MessageResponse, MFAInitResponse, EnableMFARequest, VerifyMFARequest, MFAStatusResponse
from ..schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, ForgotPasswordRequest, ResetPasswordRequest, AuthResponse, TokenResponse, MessageResponse, MFAInitResponse, EnableMFARequest, VerifyMFARequest, MFAStatusResponse, UpdateProfileRequest
from ..middleware.auth import get_current_user
from ..models.user import User
from ..services.audit_service import audit_service
@@ -22,16 +22,15 @@ AUTH_RATE_LIMIT = "5/minute" # 5 attempts per minute per IP
PASSWORD_RESET_LIMIT = "3/hour" # 3 password reset requests per hour per IP
LOGIN_RATE_LIMIT = "10/minute" # 10 login attempts per minute per IP
# Initialize limiter - will be set from app state
limiter = None
def get_limiter(request: Request) -> Limiter:
"""Get limiter instance from app state."""
return request.app.state.limiter if hasattr(request.app.state, 'limiter') else None
def apply_rate_limit(func, limit_value: str):
"""Helper to apply rate limiting decorator if limiter is available."""
def decorator(*args, **kwargs):
# This will be applied at runtime when route is called
return func(*args, **kwargs)
return decorator
global limiter
if hasattr(request.app.state, 'limiter'):
limiter = request.app.state.limiter
return limiter
def get_base_url(request: Request) -> str:
return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:8000')}'
@@ -52,6 +51,7 @@ async def register(
response: Response,
db: Session=Depends(get_db)
):
# Rate limiting is handled by middleware, but we can add additional checks here if needed
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
@@ -59,14 +59,28 @@ async def register(
try:
result = await auth_service.register(db=db, name=register_request.name, email=register_request.email, password=register_request.password, phone=register_request.phone)
from ..config.settings import settings
max_age = 7 * 24 * 60 * 60 # 7 days for registration
# Use secure cookies in production (HTTPS required)
# Set access token in httpOnly cookie for security
# Use 'lax' in development for cross-origin support, 'strict' in production
samesite_value = 'strict' if settings.is_production else 'lax'
response.set_cookie(
key='accessToken',
value=result['token'],
httponly=True,
secure=settings.is_production, # Secure flag enabled in production
samesite=samesite_value,
max_age=max_age,
path='/'
)
# Set refresh token in httpOnly cookie
response.set_cookie(
key='refreshToken',
value=result['refreshToken'],
httponly=True,
secure=settings.is_production, # Secure flag enabled in production
samesite='strict',
max_age=7 * 24 * 60 * 60,
samesite=samesite_value,
max_age=max_age,
path='/'
)
@@ -83,7 +97,8 @@ async def register(
status='success'
)
return {'status': 'success', 'message': 'Registration successful', 'data': {'token': result['token'], 'user': result['user']}}
# Return user data but NOT the token (it's in httpOnly cookie now)
return {'status': 'success', 'message': 'Registration successful', 'data': {'user': result['user']}}
except ValueError as e:
error_message = str(e)
# Log failed registration attempt
@@ -132,12 +147,25 @@ async def login(
from ..config.settings import settings
max_age = 7 * 24 * 60 * 60 if login_request.rememberMe else 1 * 24 * 60 * 60
# Use secure cookies in production (HTTPS required)
# Set access token in httpOnly cookie for security
# Use 'lax' in development for cross-origin support, 'strict' in production
samesite_value = 'strict' if settings.is_production else 'lax'
response.set_cookie(
key='accessToken',
value=result['token'],
httponly=True,
secure=settings.is_production, # Secure flag enabled in production
samesite=samesite_value,
max_age=max_age,
path='/'
)
# Set refresh token in httpOnly cookie
response.set_cookie(
key='refreshToken',
value=result['refreshToken'],
httponly=True,
secure=settings.is_production, # Secure flag enabled in production
samesite='strict',
samesite=samesite_value,
max_age=max_age,
path='/'
)
@@ -155,7 +183,8 @@ async def login(
status='success'
)
return {'status': 'success', 'data': {'token': result['token'], 'user': result['user']}}
# Return user data but NOT the token (it's in httpOnly cookie now)
return {'status': 'success', 'data': {'user': result['user']}}
except ValueError as e:
error_message = str(e)
status_code = status.HTTP_401_UNAUTHORIZED if 'Invalid email or password' in error_message or 'Invalid MFA token' in error_message else status.HTTP_400_BAD_REQUEST
@@ -176,12 +205,32 @@ async def login(
return JSONResponse(status_code=status_code, content={'status': 'error', 'message': error_message})
@router.post('/refresh-token', response_model=TokenResponse)
async def refresh_token(refreshToken: str=Cookie(None), db: Session=Depends(get_db)):
async def refresh_token(
request: Request,
response: Response,
refreshToken: str=Cookie(None),
db: Session=Depends(get_db)
):
if not refreshToken:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Refresh token not found')
try:
result = await auth_service.refresh_access_token(db, refreshToken)
return result
from ..config.settings import settings
# Set new access token in httpOnly cookie
# Use 'lax' in development for cross-origin support, 'strict' in production
samesite_value = 'strict' if settings.is_production else 'lax'
max_age = 7 * 24 * 60 * 60 # 7 days
response.set_cookie(
key='accessToken',
value=result['token'],
httponly=True,
secure=settings.is_production,
samesite=samesite_value,
max_age=max_age,
path='/'
)
# Return user data but NOT the token (it's in httpOnly cookie now)
return {'status': 'success', 'data': {'user': result.get('user')}}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
@@ -199,7 +248,13 @@ async def logout(
if refreshToken:
await auth_service.logout(db, refreshToken)
response.delete_cookie(key='refreshToken', path='/')
# Delete both access and refresh token cookies
from ..config.settings import settings
# Use 'lax' in development for cross-origin support, 'strict' in production
samesite_value = 'strict' if settings.is_production else 'lax'
response.delete_cookie(key='refreshToken', path='/', secure=settings.is_production, samesite=samesite_value)
response.delete_cookie(key='accessToken', path='/', secure=settings.is_production, samesite=samesite_value)
# Log logout
await audit_service.log_action(
@@ -227,9 +282,18 @@ async def get_profile(current_user: User=Depends(get_current_user), db: Session=
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.put('/profile')
async def update_profile(profile_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
async def update_profile(profile_data: UpdateProfileRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
user = await auth_service.update_profile(db=db, user_id=current_user.id, full_name=profile_data.get('full_name'), email=profile_data.get('email'), phone_number=profile_data.get('phone_number'), password=profile_data.get('password'), current_password=profile_data.get('currentPassword'), currency=profile_data.get('currency'))
user = await auth_service.update_profile(
db=db,
user_id=current_user.id,
full_name=profile_data.full_name,
email=profile_data.email,
phone_number=profile_data.phone_number,
password=profile_data.password,
current_password=profile_data.currentPassword,
currency=profile_data.currency
)
return {'status': 'success', 'message': 'Profile updated successfully', 'data': {'user': user}}
except ValueError as e:
error_message = str(e)