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.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: str) -> 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="viewer", is_active=True, email_verified=False, ) db.add(user) await db.commit() try: code = otp.issue_code(payload.email, "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, "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 == "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, "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="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": "reset_password", "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, "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"}