from fastapi import APIRouter, Depends, HTTPException, status, Request, Response from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import false, select from pydantic import BaseModel, EmailStr, Field from datetime import datetime, timedelta from typing import Optional, List import uuid import logging from config.config import settings from database import get_session from database import User, RefreshToken, AuditEventType from security.auth import ( hash_password, verify_password, validate_password_strength, generate_verification_token, generate_reset_token, ) from security.cookies import CookieManager, set_auth_cookies from security.fingerprint import DeviceFingerprint, get_client_ip from security.rate_limiter import RateLimiter from services.token_service import TokenService from services.audit_service import AuditService from services.email_service import AuthEmailService from core.dependencies import get_current_user logger = logging.getLogger(__name__) router = APIRouter(prefix="/auth", tags=["Authentication"]) class RegisterRequest(BaseModel): email: EmailStr password: str = Field(..., min_length=8, max_length=128) nom: str = Field(..., min_length=2, max_length=100) prenom: str = Field(..., min_length=2, max_length=100) class LoginRequest(BaseModel): email: EmailStr password: str = Field(..., min_length=1, max_length=128) class TokenResponse(BaseModel): message: str user: dict expires_in: int class ForgotPasswordRequest(BaseModel): email: EmailStr class ResetPasswordRequest(BaseModel): token: str new_password: str = Field(..., min_length=8, max_length=128) class VerifyEmailRequest(BaseModel): token: str class ResendVerificationRequest(BaseModel): email: EmailStr class ChangePasswordRequest(BaseModel): current_password: str new_password: str = Field(..., min_length=8, max_length=128) class SessionResponse(BaseModel): id: str device_info: Optional[str] ip_address: Optional[str] created_at: str last_used_at: Optional[str] @router.post("/register", status_code=status.HTTP_201_CREATED) async def register( data: RegisterRequest, request: Request, session: AsyncSession = Depends(get_session), ): ip = get_client_ip(request) allowed, error_msg = await RateLimiter.check_registration_rate_limit(ip) if not allowed: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg ) result = await session.execute(select(User).where(User.email == data.email.lower())) if result.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Cet email est deja utilise" ) is_valid, error_msg = validate_password_strength(data.password) if not is_valid: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) verification_token = generate_verification_token() new_user = User( id=str(uuid.uuid4()), email=data.email.lower(), hashed_password=hash_password(data.password), nom=data.nom, prenom=data.prenom, is_verified=False, verification_token=verification_token, verification_token_expires=datetime.now() + timedelta(hours=24), created_at=datetime.now(), ) session.add(new_user) await session.commit() base_url = str(request.base_url).rstrip("/") AuthEmailService.send_verification_email(data.email, verification_token, base_url) logger.info(f"Nouvel utilisateur inscrit: {data.email}") return { "success": True, "message": "Inscription reussie. Consultez votre email pour verifier votre compte.", "user_id": new_user.id, } @router.post("/login") async def login( data: LoginRequest, request: Request, response: Response, session: AsyncSession = Depends(get_session), ): ip = get_client_ip(request) user_agent = request.headers.get("User-Agent", "") fingerprint_hash = DeviceFingerprint.generate_hash(request) allowed, error_msg, _ = await RateLimiter.check_login_rate_limit( data.email.lower(), ip ) if not allowed: await AuditService.log_login_failed(session, request, data.email, "rate_limit") raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg ) result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() if not user or not verify_password(data.password, user.hashed_password): await RateLimiter.record_login_attempt(data.email.lower(), ip, success=False) await AuditService.record_login_attempt( session, request, data.email, False, "invalid_credentials" ) if user: user.failed_login_attempts = (user.failed_login_attempts or 0) + 1 if user.failed_login_attempts >= settings.account_lockout_threshold: user.locked_until = datetime.now() + timedelta( minutes=settings.account_lockout_duration_minutes ) await AuditService.log_account_locked( session, request, user.id, "too_many_failed_attempts" ) await session.commit() raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Compte verrouille. Reessayez dans {settings.account_lockout_duration_minutes} minutes.", ) await session.commit() raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Email ou mot de passe incorrect", ) if not user.is_active: await AuditService.log_login_failed( session, request, data.email, "account_disabled", user.id ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Compte desactive" ) if not user.is_verified: await AuditService.log_login_failed( session, request, data.email, "email_not_verified", user.id ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Email non verifie. Consultez votre boite de reception.", ) if user.locked_until and user.locked_until > datetime.now(): await AuditService.log_login_failed( session, request, data.email, "account_locked", user.id ) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Compte temporairement verrouille", ) user.failed_login_attempts = 0 user.locked_until = None user.last_login = datetime.now() user.last_login_ip = ip ( access_token, refresh_token, csrf_token, session_id, ) = await TokenService.create_token_pair( session=session, user=user, fingerprint_hash=fingerprint_hash, device_info=user_agent, ip_address=ip, ) await session.commit() await RateLimiter.record_login_attempt(data.email.lower(), ip, success=True) await AuditService.log_login_success(session, request, user.id, user.email) set_auth_cookies(response, access_token, refresh_token, csrf_token) logger.info(f"Connexion reussie: {user.email} depuis {ip}") return TokenResponse( message="Connexion reussie", user={ "id": user.id, "email": user.email, "nom": user.nom, "prenom": user.prenom, "role": user.role, }, expires_in=settings.access_token_expire_minutes * 60, ) @router.post("/refresh") async def refresh_tokens( request: Request, response: Response, session: AsyncSession = Depends(get_session) ): refresh_token = CookieManager.get_refresh_token(request) if not refresh_token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token manquant" ) ip = get_client_ip(request) user_agent = request.headers.get("User-Agent", "") fingerprint_hash = DeviceFingerprint.generate_hash(request) result = await TokenService.refresh_tokens( session=session, refresh_token=refresh_token, fingerprint_hash=fingerprint_hash, device_info=user_agent, ip_address=ip, ) if not result: CookieManager.clear_all_auth_cookies(response) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token invalide ou expire", ) new_access, new_refresh, new_csrf, session_id = result await session.commit() set_auth_cookies(response, new_access, new_refresh, new_csrf) logger.debug("Tokens rafraichis avec succes") return { "message": "Tokens rafraichis", "expires_in": settings.access_token_expire_minutes * 60, } @router.post("/logout") async def logout( request: Request, response: Response, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): refresh_token = CookieManager.get_refresh_token(request) if refresh_token: await TokenService.revoke_token( session=session, refresh_token=refresh_token, reason="user_logout" ) await AuditService.log_logout(session, request, user.id) await session.commit() CookieManager.clear_all_auth_cookies(response) logger.info(f"Deconnexion: {user.email}") return {"success": True, "message": "Deconnexion reussie"} @router.post("/logout-all") async def logout_all_sessions( request: Request, response: Response, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): count = await TokenService.revoke_all_user_tokens( session=session, user_id=user.id, reason="user_logout_all" ) await AuditService.log_event( session=session, event_type=AuditEventType.SESSION_TERMINATED, request=request, user_id=user.id, description=f"Toutes les sessions terminees ({count} tokens revoques)", ) await session.commit() CookieManager.clear_all_auth_cookies(response) logger.info(f"Toutes les sessions terminees pour {user.email}: {count} tokens") return {"success": True, "message": f"{count} session(s) terminee(s)"} @router.get("/verify-email") async def verify_email_get(token: str, session: AsyncSession = Depends(get_session)): result = await session.execute(select(User).where(User.verification_token == token)) user = result.scalar_one_or_none() if not user: return { "success": False, "message": "Token de verification invalide ou deja utilise.", } if ( user.verification_token_expires and user.verification_token_expires < datetime.now() ): return { "success": False, "message": "Token expire. Demandez un nouveau lien de verification.", "expired": True, } user.is_verified = True user.verification_token = None user.verification_token_expires = None await session.commit() logger.info(f"Email verifie: {user.email}") return { "success": True, "message": "Email verifie avec succes. Vous pouvez maintenant vous connecter.", } @router.post("/verify-email") async def verify_email_post( data: VerifyEmailRequest, request: Request, session: AsyncSession = Depends(get_session), ): result = await session.execute( select(User).where(User.verification_token == data.token) ) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Token de verification invalide", ) if ( user.verification_token_expires and user.verification_token_expires < datetime.now() ): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Token expire. Demandez un nouveau lien de verification.", ) user.is_verified = True user.verification_token = None user.verification_token_expires = None await AuditService.log_event( session=session, event_type=AuditEventType.EMAIL_VERIFICATION, request=request, user_id=user.id, description="Email verifie avec succes", ) await session.commit() logger.info(f"Email verifie: {user.email}") return {"success": True, "message": "Email verifie avec succes."} @router.post("/resend-verification") async def resend_verification( data: ResendVerificationRequest, request: Request, session: AsyncSession = Depends(get_session), ): result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() if not user: return { "success": True, "message": "Si cet email existe, un nouveau lien a ete envoye.", } if user.is_verified: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Ce compte est deja verifie" ) verification_token = generate_verification_token() user.verification_token = verification_token user.verification_token_expires = datetime.now() + timedelta(hours=24) await session.commit() base_url = str(request.base_url).rstrip("/") AuthEmailService.send_verification_email(user.email, verification_token, base_url) return {"success": True, "message": "Un nouveau lien de verification a ete envoye."} @router.post("/forgot-password") async def forgot_password( data: ForgotPasswordRequest, request: Request, session: AsyncSession = Depends(get_session), ): ip = get_client_ip(request) allowed, error_msg = await RateLimiter.check_password_reset_rate_limit( data.email.lower(), ip ) if not allowed: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=error_msg ) result = await session.execute(select(User).where(User.email == data.email.lower())) user = result.scalar_one_or_none() await AuditService.log_password_reset_request( session, request, data.email, user.id if user else None ) if not user: return { "success": True, "message": "Si cet email existe, un lien de reinitialisation a ete envoye.", } reset_token = generate_reset_token() user.reset_token = reset_token user.reset_token_expires = datetime.now() + timedelta(hours=1) await session.commit() frontend_url = settings.frontend_url or str(request.base_url).rstrip("/") AuthEmailService.send_password_reset_email(user.email, reset_token, frontend_url) logger.info(f"Reset password demande: {user.email}") return { "success": True, "message": "Si cet email existe, un lien de reinitialisation a ete envoye.", } @router.post("/reset-password") async def reset_password( data: ResetPasswordRequest, request: Request, response: Response, session: AsyncSession = Depends(get_session), ): result = await session.execute(select(User).where(User.reset_token == data.token)) user = result.scalar_one_or_none() if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Token de reinitialisation invalide", ) if user.reset_token_expires and user.reset_token_expires < datetime.now(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Token expire. Demandez un nouveau lien.", ) is_valid, error_msg = validate_password_strength(data.new_password) if not is_valid: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) user.hashed_password = hash_password(data.new_password) user.reset_token = None user.reset_token_expires = None user.failed_login_attempts = 0 user.locked_until = None user.password_changed_at = datetime.now() await TokenService.revoke_all_user_tokens( session=session, user_id=user.id, reason="password_reset" ) await AuditService.log_password_change(session, request, user.id, "reset") await session.commit() CookieManager.clear_all_auth_cookies(response) AuthEmailService.send_password_changed_notification(user.email) logger.info(f"Mot de passe reinitialise: {user.email}") return { "success": True, "message": "Mot de passe reinitialise. Vous pouvez maintenant vous connecter.", } @router.post("/change-password") async def change_password( data: ChangePasswordRequest, request: Request, response: Response, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): if not verify_password(data.current_password, user.hashed_password): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Mot de passe actuel incorrect", ) is_valid, error_msg = validate_password_strength(data.new_password) if not is_valid: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_msg) user.hashed_password = hash_password(data.new_password) user.password_changed_at = datetime.now() await TokenService.revoke_all_user_tokens( session=session, user_id=user.id, reason="password_change" ) await AuditService.log_password_change(session, request, user.id, "user_initiated") await session.commit() CookieManager.clear_all_auth_cookies(response) AuthEmailService.send_password_changed_notification(user.email) logger.info(f"Mot de passe change: {user.email}") return { "success": True, "message": "Mot de passe modifie. Veuillez vous reconnecter.", } @router.get("/me") async def get_current_user_info(user: User = Depends(get_current_user)): return { "id": user.id, "email": user.email, "nom": user.nom, "prenom": user.prenom, "role": user.role, "is_verified": user.is_verified, "created_at": user.created_at.isoformat() if user.created_at else None, "last_login": user.last_login.isoformat() if user.last_login else None, } @router.get("/sessions", response_model=List[SessionResponse]) async def get_active_sessions( session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user) ): sessions = await TokenService.get_user_active_sessions(session, user.id) return [SessionResponse(**s) for s in sessions] @router.delete("/sessions/{session_id}") async def revoke_session( session_id: str, request: Request, session: AsyncSession = Depends(get_session), user: User = Depends(get_current_user), ): result = await session.execute( select(RefreshToken).where( RefreshToken.id == session_id, RefreshToken.user_id == user.id, RefreshToken.is_revoked.is_(false()), ) ) token_record = result.scalar_one_or_none() if not token_record: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Session introuvable" ) token_record.is_revoked = True token_record.revoked_at = datetime.now() token_record.revoked_reason = "user_revoked" await AuditService.log_event( session=session, event_type=AuditEventType.SESSION_TERMINATED, request=request, user_id=user.id, description=f"Session {session_id[:8]}... revoquee", ) await session.commit() return {"success": True, "message": "Session revoquee"} @router.get("/csrf-token") async def get_csrf_token( request: Request, response: Response, user: User = Depends(get_current_user) ): from security.auth import generate_session_id, create_csrf_token session_id = getattr(request.state, "session_id", None) if not session_id: session_id = generate_session_id() csrf_token = create_csrf_token(session_id) CookieManager.set_csrf_token(response, csrf_token) return {"csrf_token": csrf_token}