from fastapi import APIRouter, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import text from app.core.config import settings from app.core.enums import OtpPurpose, UserRole from app.core.logging import get_logger from app.core.security import ( create_access_token, create_refresh_token, get_current_user, get_password_hash, verify_password, ) from app.db.session import get_db from app.models.user import User from app.schemas.token import Token from app.schemas.user import ( ForgotPasswordRequest, ResendCodeRequest, ResetPasswordRequest, UserRegister, UserResponse, VerifyEmailRequest, ) from app.services import otp from app.services.email import ( EmailError, EmailNotConfiguredError, send_verification_email, ) logger = get_logger(__name__) router = APIRouter() def _token_response(user: User) -> dict: access_token = create_access_token(data={"sub": user.id}) refresh = create_refresh_token(data={"sub": user.id}) expires_in = ( settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0 else None ) return { "access_token": access_token, "token_type": "bearer", "expires_in": expires_in, "refresh_token": refresh, "user": { "id": user.id, "username": user.username, "role": user.role, "gatekeeper_groups": user.gatekeeper_groups or [], }, } async def _load_user_by_email(db: AsyncSession, email: str) -> User | None: result = await db.execute( text( "SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified " "FROM users WHERE email = :email" ), {"email": email}, ) row = result.fetchone() if row is None: return None user = User() user.id = row[0] user.username = row[1] user.email = row[2] user.password_hash = row[3] user.role = row[4] user.is_active = row[5] user.gatekeeper_groups = row[6] or [] user.email_verified = bool(row[7]) return user @router.post("/login", response_model=Token) async def login( form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db), ): result = await db.execute( text( "SELECT id, username, email, password_hash, role, is_active, gatekeeper_groups, email_verified " "FROM users WHERE username = :username" ), {"username": form_data.username}, ) row = result.fetchone() if row is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials", ) user = User() user.id = row[0] user.username = row[1] user.email = row[2] user.password_hash = row[3] user.role = row[4] user.is_active = row[5] user.gatekeeper_groups = row[6] or [] user.email_verified = bool(row[7]) if not verify_password(form_data.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials", ) if not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User is inactive", ) if not user.email_verified: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"code": "EMAIL_NOT_VERIFIED", "email": user.email}, ) return _token_response(user) @router.post("/refresh", response_model=Token) async def refresh_token( current_user: User = Depends(get_current_user), ): access_token = create_access_token(data={"sub": current_user.id}) expires_in = None if settings.ACCESS_TOKEN_EXPIRE_MINUTES > 0: expires_in = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 return { "access_token": access_token, "token_type": "bearer", "expires_in": expires_in, "user": { "id": current_user.id, "username": current_user.username, "role": current_user.role, "gatekeeper_groups": current_user.gatekeeper_groups or [], }, } @router.post("/logout") async def logout(): return {"message": "Successfully logged out"} @router.get("/me", response_model=UserResponse) async def get_me(current_user: User = Depends(get_current_user)): return { "id": current_user.id, "username": current_user.username, "email": current_user.email, "role": current_user.role, "gatekeeper_groups": current_user.gatekeeper_groups or [], "is_active": current_user.is_active, "email_verified": getattr(current_user, "email_verified", True), "created_at": current_user.created_at, } async def _send_code_or_raise(db: AsyncSession, email: str, code: str, purpose: OtpPurpose) -> None: try: await send_verification_email(db, to=email, code=code, purpose=purpose) except EmailNotConfiguredError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={"code": exc.code, "message": str(exc)}, ) from exc except EmailError as exc: logger.warning_event( "SMTP send failed", event="auth.email.send_failed", context={"email": email, "purpose": purpose, "error": str(exc)}, ) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail={"code": exc.code, "message": str(exc)}, ) from exc @router.post("/register", status_code=status.HTTP_201_CREATED) async def register(payload: UserRegister, db: AsyncSession = Depends(get_db)): existing = await db.execute( text("SELECT id, email_verified FROM users WHERE username = :u OR email = :e"), {"u": payload.username, "e": payload.email}, ) row = existing.fetchone() if row is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"code": "USER_ALREADY_EXISTS", "message": "Username or email already in use"}, ) user = User( username=payload.username, email=payload.email, password_hash=get_password_hash(payload.password), role=UserRole.VIEWER.value, is_active=True, email_verified=False, ) db.add(user) await db.commit() try: code = otp.issue_code(payload.email, OtpPurpose.REGISTER) except otp.OtpResendRateLimited as exc: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds}, ) from exc await _send_code_or_raise(db, payload.email, code, OtpPurpose.REGISTER) return {"status": "pending_verification", "email": payload.email} @router.post("/verify-email", response_model=Token) async def verify_email(payload: VerifyEmailRequest, db: AsyncSession = Depends(get_db)): user = await _load_user_by_email(db, payload.email) if user is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"code": "USER_NOT_FOUND"}, ) try: otp.verify_code(payload.email, "register", payload.code) except otp.OtpExpired as exc: raise HTTPException( status_code=status.HTTP_410_GONE, detail={"code": exc.code, "message": str(exc)}, ) from exc except otp.OtpAttemptsExceeded as exc: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail={"code": exc.code, "message": str(exc)}, ) from exc except otp.OtpInvalid as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"code": exc.code, "message": str(exc)}, ) from exc await db.execute( text("UPDATE users SET email_verified = TRUE WHERE id = :id"), {"id": user.id}, ) await db.commit() user.email_verified = True return _token_response(user) @router.post("/resend-code") async def resend_code(payload: ResendCodeRequest, db: AsyncSession = Depends(get_db)): user = await _load_user_by_email(db, payload.email) if user is None: # Avoid email enumeration; pretend success. return {"status": "ok"} if payload.purpose is OtpPurpose.REGISTER and user.email_verified: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"code": "ALREADY_VERIFIED"}, ) try: code = otp.issue_code(payload.email, payload.purpose) except otp.OtpResendRateLimited as exc: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail={"code": exc.code, "retry_after_seconds": exc.retry_after_seconds}, ) from exc await _send_code_or_raise(db, payload.email, code, payload.purpose) return {"status": "ok"} @router.post("/forgot-password") async def forgot_password(payload: ForgotPasswordRequest, db: AsyncSession = Depends(get_db)): user = await _load_user_by_email(db, payload.email) if user is None: # Don't leak whether an email is registered. return {"status": "ok"} try: code = otp.issue_code(payload.email, OtpPurpose.RESET_PASSWORD) except otp.OtpResendRateLimited: # Silently accept; the user can retry after the cooldown. return {"status": "ok"} try: await send_verification_email( db, to=payload.email, code=code, purpose=OtpPurpose.RESET_PASSWORD, ) except EmailNotConfiguredError as exc: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail={"code": exc.code, "message": str(exc)}, ) from exc except EmailError as exc: logger.warning_event( "SMTP send failed", event="auth.email.send_failed", context={ "email": payload.email, "purpose": OtpPurpose.RESET_PASSWORD.value, "error": str(exc), }, ) return {"status": "ok"} @router.post("/reset-password") async def reset_password(payload: ResetPasswordRequest, db: AsyncSession = Depends(get_db)): user = await _load_user_by_email(db, payload.email) if user is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"code": "OTP_INVALID"}, ) try: otp.verify_code(payload.email, OtpPurpose.RESET_PASSWORD, payload.code) except otp.OtpExpired as exc: raise HTTPException( status_code=status.HTTP_410_GONE, detail={"code": exc.code, "message": str(exc)}, ) from exc except otp.OtpAttemptsExceeded as exc: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail={"code": exc.code, "message": str(exc)}, ) from exc except otp.OtpInvalid as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"code": exc.code, "message": str(exc)}, ) from exc await db.execute( text("UPDATE users SET password_hash = :p, email_verified = TRUE WHERE id = :id"), {"p": get_password_hash(payload.new_password), "id": user.id}, ) await db.commit() return {"status": "ok"}