from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Response, Request, Cookie, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.orm import Session from datetime import datetime, timedelta, timezone import random import time from user_agents import parse import ulid from app.core.database.db_session import get_db from app.core.config.Config import settings from app.core.Token import create_access_token from app.utils.Hash_util import hash_password, verify_password from app.repositories.user_repository import user_repository from app.repositories.session_repository import session_repository from app.models.UserSessionModel import UserSession from app.models.UserModel import User from app.models.AuditLogModel import AuditLog from app.schemas.Auth import LoginSchema, TokenResponseSchema from app.core.validators.password_validator import validate_password_complexity router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"]) def get_client_ip(request: Request) -> str: # Read forwarded headers or default to client host x_forwarded_for = request.headers.get("x-forwarded-for") if x_forwarded_for: return x_forwarded_for.split(",")[0].strip() return request.client.host if request.client else "127.0.0.1" def record_failed_login_audit(db: Session, request: Request, email: str, user: Optional[User] = None, reason: str = "Invalid credentials"): try: req_id = getattr(request.state, "trace_id", None) or str(ulid.ULID()) audit = AuditLog( audit_id=str(ulid.ULID()), request_id=req_id, user_id=user.user_id if user else None, entity_type="auth", entity_id=email, action="failed_login", old_value=None, new_value={"reason": reason, "email": email}, ip_address=get_client_ip(request), user_agent=(request.headers.get("user-agent") or "")[:255], ) db.add(audit) db.commit() except Exception: db.rollback() @router.post("/login", response_model=TokenResponseSchema) def login( data: LoginSchema, response: Response, request: Request, db: Session = Depends(get_db) ): user = user_repository.get_by_email(db, data.email) # 1. Lockout & Verification checks if not user: record_failed_login_audit(db, request, data.email, None, "User not found") raise HTTPException(status_code=401, detail="Invalid credentials") if user.is_locked and user.deleted_at is None: record_failed_login_audit(db, request, data.email, user, "Account locked") raise HTTPException(status_code=403, detail="Account locked. Please reset password to unlock.") if not verify_password(data.password, user.password_hash): user.failed_login_attempts += 1 is_locked_now = user.failed_login_attempts >= settings.MAX_FAILED_LOGIN if is_locked_now: user.is_locked = True db.commit() record_failed_login_audit( db, request, data.email, user, "Account locked due to too many failed attempts" if is_locked_now else "Invalid credentials" ) if is_locked_now: raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.") raise HTTPException(status_code=401, detail="Invalid credentials") # 2. Reset failed attempts count user.failed_login_attempts = 0 user.is_locked = False user.last_login = datetime.now(timezone.utc) db.commit() # 3. Generate tokens access_token = create_access_token(user.user_id, user.email, user.role.role_name) raw_refresh = str(ulid.ULID()) # 4. Extract device metadata ua_string = request.headers.get("user-agent", "") ua = parse(ua_string) device_type = "Desktop" if ua.is_mobile: device_type = "Mobile" elif ua.is_tablet: device_type = "Tablet" os_name = f"{ua.os.family} {ua.os.version_string}".strip() browser_name = f"{ua.browser.family} {ua.browser.version_string}".strip() # 5. Save session session_id = str(ulid.ULID()) session_entry = UserSession( session_id=session_id, user_id=user.user_id, device_name=ua.device.family or "Unknown", device_type=device_type, browser=browser_name, operating_system=os_name, ip_address=get_client_ip(request), latitude=data.latitude, longitude=data.longitude, location_name=data.location_name, device_fingerprint=data.device_fingerprint, refresh_token=raw_refresh, access_token_id=session_id, # Match token references expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), is_active=True ) db.add(session_entry) db.commit() # 6. Set response cookie and header response.set_cookie( "refresh_token", raw_refresh, httponly=True, secure=True, samesite="none" ) response.headers["X-User-Email"] = user.email return { "access_token": access_token, "refresh_token": raw_refresh, "token_type": "bearer" } @router.post("/refresh", response_model=TokenResponseSchema) def refresh( request: Request, response: Response, refresh_token: Optional[str] = Cookie(None), db: Session = Depends(get_db) ): if not refresh_token: auth_header = request.headers.get("Authorization") if auth_header and auth_header.startswith("Bearer "): refresh_token = auth_header.split(" ")[1] if not refresh_token: refresh_token = request.headers.get("X-Refresh-Token") if not refresh_token: raise HTTPException(status_code=401, detail="Missing refresh token") session = session_repository.get_by_refresh_token(db, refresh_token) if not session or session.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc): if session: session.is_active = False db.commit() raise HTTPException(status_code=401, detail="Invalid or expired refresh token") user = user_repository.get_by_id(db, session.user_id) if not user or not user.is_active or user.is_locked: raise HTTPException(status_code=403, detail="User account is inactive or locked") # Refresh Token Rotation (RTR): invalidate old token, issue new one new_access = create_access_token(user.user_id, user.email, user.role.role_name) new_refresh = str(ulid.ULID()) # Deactivate old session session.is_active = False db.commit() # Create new session entry carrying over device metadata new_session_id = str(ulid.ULID()) new_session = UserSession( session_id=new_session_id, user_id=user.user_id, device_name=session.device_name, device_type=session.device_type, browser=session.browser, operating_system=session.operating_system, ip_address=session.ip_address, latitude=session.latitude, longitude=session.longitude, location_name=session.location_name, device_fingerprint=session.device_fingerprint, refresh_token=new_refresh, access_token_id=new_session_id, expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), is_active=True ) db.add(new_session) db.commit() response.set_cookie( "refresh_token", new_refresh, httponly=True, secure=True, samesite="none" ) return { "access_token": new_access, "refresh_token": new_refresh, "token_type": "bearer" } @router.post("/logout") def logout( response: Response, refresh_token: str = Cookie(None), db: Session = Depends(get_db) ): if refresh_token: session = session_repository.get_by_refresh_token(db, refresh_token) if session: session.is_active = False db.commit() response.delete_cookie("refresh_token", httponly=True, secure=True, samesite="lax") return {"detail": "Logged out successfully"} @router.post("/token", response_model=TokenResponseSchema) def oauth2_token( response: Response, request: Request, form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db) ): user = user_repository.get_by_email(db, form_data.username) if not user: record_failed_login_audit(db, request, form_data.username, None, "User not found") raise HTTPException(status_code=401, detail="Invalid credentials") if user.is_locked and user.deleted_at is None: record_failed_login_audit(db, request, form_data.username, user, "Account locked") raise HTTPException(status_code=403, detail="Account locked. Please reset password to unlock.") if not verify_password(form_data.password, user.password_hash): user.failed_login_attempts += 1 is_locked_now = user.failed_login_attempts >= settings.MAX_FAILED_LOGIN if is_locked_now: user.is_locked = True db.commit() record_failed_login_audit( db, request, form_data.username, user, "Account locked due to too many failed attempts" if is_locked_now else "Invalid credentials" ) if is_locked_now: raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.") raise HTTPException(status_code=401, detail="Invalid credentials") user.failed_login_attempts = 0 user.is_locked = False user.last_login = datetime.now(timezone.utc) db.commit() access_token = create_access_token(user.user_id, user.email, user.role.role_name) raw_refresh = str(ulid.ULID()) ua_string = request.headers.get("user-agent", "") ua = parse(ua_string) device_type = "Desktop" if ua.is_mobile: device_type = "Mobile" elif ua.is_tablet: device_type = "Tablet" os_name = f"{ua.os.family} {ua.os.version_string}".strip() browser_name = f"{ua.browser.family} {ua.browser.version_string}".strip() session_id = str(ulid.ULID()) session_entry = UserSession( session_id=session_id, user_id=user.user_id, device_name=ua.device.family or "Unknown", device_type=device_type, browser=browser_name, operating_system=os_name, ip_address=get_client_ip(request), refresh_token=raw_refresh, access_token_id=session_id, expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS), is_active=True ) db.add(session_entry) db.commit() response.set_cookie( "refresh_token", raw_refresh, httponly=True, secure=True, samesite="lax" ) response.headers["X-User-Email"] = user.email return { "access_token": access_token, "refresh_token": raw_refresh, "token_type": "bearer" }