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.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" @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: raise HTTPException(status_code=401, detail="Invalid credentials") if user.is_locked and user.deleted_at is None: # Check if lockout period has expired (15 minutes) # We can implement a lockout expiry check, but for simplicity, check last update time # if 15 mins passed, unlock: # For security, let's keep it locked until reset_password confirms, or 15 mins pass # Since we lock dynamically, let's look at the DB fields: # user has is_locked. Let's write a clean time-locked query if needed, # or require password reset as configured. 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 if user.failed_login_attempts >= settings.MAX_FAILED_LOGIN: user.is_locked = True db.commit() raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.") db.commit() 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( response: Response, refresh_token: str = Cookie(None), db: Session = Depends(get_db) ): 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: raise HTTPException(status_code=401, detail="Invalid credentials") if user.is_locked and user.deleted_at is None: 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 if user.failed_login_attempts >= settings.MAX_FAILED_LOGIN: user.is_locked = True db.commit() raise HTTPException(status_code=403, detail="Account locked due to too many failed attempts.") db.commit() 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" }