This commit is contained in:
Iliyan Angelov
2025-11-28 02:40:05 +02:00
parent 627959f52b
commit 312f85530c
246 changed files with 23535 additions and 3428 deletions

View File

@@ -10,8 +10,29 @@ from ..services.auth_service import auth_service
from ..schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, ForgotPasswordRequest, ResetPasswordRequest, AuthResponse, TokenResponse, MessageResponse, MFAInitResponse, EnableMFARequest, VerifyMFARequest, MFAStatusResponse
from ..middleware.auth import get_current_user
from ..models.user import User
from ..services.audit_service import audit_service
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
router = APIRouter(prefix='/auth', tags=['auth'])
# Stricter rate limits for authentication endpoints
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
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
def get_base_url(request: Request) -> str:
return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:8000')}'
@@ -25,27 +46,133 @@ def normalize_image_url(image_url: str, base_url: str) -> str:
return f'{base_url}/{image_url}'
@router.post('/register', status_code=status.HTTP_201_CREATED)
async def register(request: RegisterRequest, response: Response, db: Session=Depends(get_db)):
async def register(
request: Request,
register_request: RegisterRequest,
response: Response,
db: Session=Depends(get_db)
):
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)
try:
result = await auth_service.register(db=db, name=request.name, email=request.email, password=request.password, phone=request.phone)
response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=7 * 24 * 60 * 60, path='/')
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
# Use secure cookies in production (HTTPS required)
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,
path='/'
)
# Log successful registration
await audit_service.log_action(
db=db,
action='user_registered',
resource_type='user',
user_id=result['user']['id'],
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': register_request.email, 'name': register_request.name},
status='success'
)
return {'status': 'success', 'message': 'Registration successful', 'data': {'token': result['token'], 'user': result['user']}}
except ValueError as e:
error_message = str(e)
# Log failed registration attempt
await audit_service.log_action(
db=db,
action='user_registration_failed',
resource_type='user',
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': register_request.email, 'name': register_request.name},
status='failed',
error_message=error_message
)
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': error_message})
@router.post('/login')
async def login(request: LoginRequest, response: Response, db: Session=Depends(get_db)):
async def login(
request: Request,
login_request: LoginRequest,
response: Response,
db: Session=Depends(get_db)
):
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)
try:
result = await auth_service.login(db=db, email=request.email, password=request.password, remember_me=request.rememberMe or False, mfa_token=request.mfaToken)
result = await auth_service.login(db=db, email=login_request.email, password=login_request.password, remember_me=login_request.rememberMe or False, mfa_token=login_request.mfaToken)
if result.get('requires_mfa'):
# Log MFA required
user = db.query(User).filter(User.email == login_request.email.lower().strip()).first()
if user:
await audit_service.log_action(
db=db,
action='login_mfa_required',
resource_type='authentication',
user_id=user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': login_request.email},
status='success'
)
return {'status': 'success', 'requires_mfa': True, 'user_id': result['user_id']}
max_age = 7 * 24 * 60 * 60 if request.rememberMe else 1 * 24 * 60 * 60
response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=max_age, path='/')
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)
response.set_cookie(
key='refreshToken',
value=result['refreshToken'],
httponly=True,
secure=settings.is_production, # Secure flag enabled in production
samesite='strict',
max_age=max_age,
path='/'
)
# Log successful login
await audit_service.log_action(
db=db,
action='login_success',
resource_type='authentication',
user_id=result['user']['id'],
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': login_request.email, 'remember_me': login_request.rememberMe},
status='success'
)
return {'status': 'success', 'data': {'token': result['token'], '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
# Log failed login attempt
await audit_service.log_action(
db=db,
action='login_failed',
resource_type='authentication',
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': login_request.email},
status='failed',
error_message=error_message
)
return JSONResponse(status_code=status_code, content={'status': 'error', 'message': error_message})
@router.post('/refresh-token', response_model=TokenResponse)
@@ -59,10 +186,34 @@ async def refresh_token(refreshToken: str=Cookie(None), db: Session=Depends(get_
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
@router.post('/logout', response_model=MessageResponse)
async def logout(response: Response, refreshToken: str=Cookie(None), db: Session=Depends(get_db)):
async def logout(
request: Request,
response: Response,
refreshToken: str=Cookie(None),
current_user: User=Depends(get_current_user),
db: Session=Depends(get_db)
):
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)
if refreshToken:
await auth_service.logout(db, refreshToken)
response.delete_cookie(key='refreshToken', path='/')
# Log logout
await audit_service.log_action(
db=db,
action='logout',
resource_type='authentication',
user_id=current_user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': current_user.email},
status='success'
)
return {'status': 'success', 'message': 'Logout successful'}
@router.get('/profile')
@@ -164,11 +315,12 @@ async def regenerate_backup_codes(current_user: User=Depends(get_current_user),
@router.post('/avatar/upload')
async def upload_avatar(request: Request, image: UploadFile=File(...), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
if not image.content_type or not image.content_type.startswith('image/'):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File must be an image')
content = await image.read()
if len(content) > 2 * 1024 * 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Avatar file size must be less than 2MB')
# Use comprehensive file validation (magic bytes + size)
from ..utils.file_validation import validate_uploaded_image
max_avatar_size = 2 * 1024 * 1024 # 2MB for avatars
# Validate file completely (MIME type, size, magic bytes, integrity)
content = await validate_uploaded_image(image, max_avatar_size)
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'avatars'
upload_dir.mkdir(parents=True, exist_ok=True)
if current_user.avatar: