""" Authentication API router for Golf game V2. Provides endpoints for user registration, login, password management, and session handling. """ import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Header, Request from pydantic import BaseModel, EmailStr from models.user import User from services.auth_service import AuthService logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/auth", tags=["auth"]) # ============================================================================= # Request/Response Models # ============================================================================= class RegisterRequest(BaseModel): """Registration request.""" username: str password: str email: Optional[str] = None class LoginRequest(BaseModel): """Login request.""" username: str password: str class VerifyEmailRequest(BaseModel): """Email verification request.""" token: str class ResendVerificationRequest(BaseModel): """Resend verification email request.""" email: str class ForgotPasswordRequest(BaseModel): """Forgot password request.""" email: str class ResetPasswordRequest(BaseModel): """Password reset request.""" token: str new_password: str class ChangePasswordRequest(BaseModel): """Change password request.""" current_password: str new_password: str class UpdatePreferencesRequest(BaseModel): """Update preferences request.""" preferences: dict class ConvertGuestRequest(BaseModel): """Convert guest to user request.""" guest_id: str username: str password: str email: Optional[str] = None class UserResponse(BaseModel): """User response (public fields only).""" id: str username: str email: Optional[str] role: str email_verified: bool preferences: dict created_at: str last_login: Optional[str] class AuthResponse(BaseModel): """Authentication response with token.""" user: UserResponse token: str expires_at: str class SessionResponse(BaseModel): """Session response.""" id: str device_info: dict ip_address: Optional[str] created_at: str last_used_at: str # ============================================================================= # Dependencies # ============================================================================= # These will be set by main.py during startup _auth_service: Optional[AuthService] = None def set_auth_service(service: AuthService) -> None: """Set the auth service instance (called from main.py).""" global _auth_service _auth_service = service def get_auth_service_dep() -> AuthService: """Dependency to get auth service.""" if _auth_service is None: raise HTTPException(status_code=503, detail="Auth service not initialized") return _auth_service async def get_current_user_v2( authorization: Optional[str] = Header(None), auth_service: AuthService = Depends(get_auth_service_dep), ) -> Optional[User]: """Get current user from Authorization header (optional).""" if not authorization: return None parts = authorization.split() if len(parts) != 2 or parts[0].lower() != "bearer": return None token = parts[1] return await auth_service.get_user_from_token(token) async def require_user_v2( user: Optional[User] = Depends(get_current_user_v2), ) -> User: """Require authenticated user.""" if not user: raise HTTPException(status_code=401, detail="Not authenticated") if not user.is_active: raise HTTPException(status_code=403, detail="Account disabled") return user async def require_admin_v2( user: User = Depends(require_user_v2), ) -> User: """Require admin user.""" if not user.is_admin(): raise HTTPException(status_code=403, detail="Admin access required") return user def get_client_ip(request: Request) -> Optional[str]: """Extract client IP from request.""" forwarded = request.headers.get("x-forwarded-for") if forwarded: return forwarded.split(",")[0].strip() if request.client: return request.client.host return None def get_device_info(request: Request) -> dict: """Extract device info from request headers.""" return { "user_agent": request.headers.get("user-agent", ""), } def get_token_from_header(authorization: Optional[str] = Header(None)) -> Optional[str]: """Extract token from Authorization header.""" if not authorization: return None parts = authorization.split() if len(parts) != 2 or parts[0].lower() != "bearer": return None return parts[1] # ============================================================================= # Registration Endpoints # ============================================================================= @router.post("/register", response_model=AuthResponse) async def register( request_body: RegisterRequest, request: Request, auth_service: AuthService = Depends(get_auth_service_dep), ): """Register a new user account.""" result = await auth_service.register( username=request_body.username, password=request_body.password, email=request_body.email, ) if not result.success: raise HTTPException(status_code=400, detail=result.error) if result.requires_verification: # Return user info but note they need to verify return { "user": _user_to_response(result.user), "token": "", "expires_at": "", "message": "Please check your email to verify your account", } # Auto-login after registration login_result = await auth_service.login( username=request_body.username, password=request_body.password, device_info=get_device_info(request), ip_address=get_client_ip(request), ) if not login_result.success: raise HTTPException(status_code=500, detail="Registration succeeded but login failed") return { "user": _user_to_response(login_result.user), "token": login_result.token, "expires_at": login_result.expires_at.isoformat(), } @router.post("/verify-email") async def verify_email( request_body: VerifyEmailRequest, auth_service: AuthService = Depends(get_auth_service_dep), ): """Verify email address with token.""" result = await auth_service.verify_email(request_body.token) if not result.success: raise HTTPException(status_code=400, detail=result.error) return {"status": "ok", "message": "Email verified successfully"} @router.post("/resend-verification") async def resend_verification( request_body: ResendVerificationRequest, auth_service: AuthService = Depends(get_auth_service_dep), ): """Resend verification email.""" await auth_service.resend_verification(request_body.email) # Always return success to prevent email enumeration return {"status": "ok", "message": "If the email exists, a verification link has been sent"} # ============================================================================= # Login/Logout Endpoints # ============================================================================= @router.post("/login", response_model=AuthResponse) async def login( request_body: LoginRequest, request: Request, auth_service: AuthService = Depends(get_auth_service_dep), ): """Login with username/email and password.""" result = await auth_service.login( username=request_body.username, password=request_body.password, device_info=get_device_info(request), ip_address=get_client_ip(request), ) if not result.success: raise HTTPException(status_code=401, detail=result.error) return { "user": _user_to_response(result.user), "token": result.token, "expires_at": result.expires_at.isoformat(), } @router.post("/logout") async def logout( token: Optional[str] = Depends(get_token_from_header), auth_service: AuthService = Depends(get_auth_service_dep), ): """Logout current session.""" if token: await auth_service.logout(token) return {"status": "ok"} @router.post("/logout-all") async def logout_all( user: User = Depends(require_user_v2), token: Optional[str] = Depends(get_token_from_header), auth_service: AuthService = Depends(get_auth_service_dep), ): """Logout all sessions except current.""" count = await auth_service.logout_all(user.id, except_token=token) return {"status": "ok", "sessions_revoked": count} # ============================================================================= # Password Management Endpoints # ============================================================================= @router.post("/forgot-password") async def forgot_password( request_body: ForgotPasswordRequest, auth_service: AuthService = Depends(get_auth_service_dep), ): """Request password reset email.""" await auth_service.forgot_password(request_body.email) # Always return success to prevent email enumeration return {"status": "ok", "message": "If the email exists, a reset link has been sent"} @router.post("/reset-password") async def reset_password( request_body: ResetPasswordRequest, auth_service: AuthService = Depends(get_auth_service_dep), ): """Reset password with token.""" result = await auth_service.reset_password( token=request_body.token, new_password=request_body.new_password, ) if not result.success: raise HTTPException(status_code=400, detail=result.error) return {"status": "ok", "message": "Password reset successfully"} @router.put("/password") async def change_password( request_body: ChangePasswordRequest, user: User = Depends(require_user_v2), token: Optional[str] = Depends(get_token_from_header), auth_service: AuthService = Depends(get_auth_service_dep), ): """Change password for current user.""" result = await auth_service.change_password( user_id=user.id, current_password=request_body.current_password, new_password=request_body.new_password, current_token=token, ) if not result.success: raise HTTPException(status_code=400, detail=result.error) return {"status": "ok", "message": "Password changed successfully"} # ============================================================================= # User Profile Endpoints # ============================================================================= @router.get("/me") async def get_me(user: User = Depends(require_user_v2)): """Get current user info.""" return {"user": _user_to_response(user)} @router.put("/me/preferences") async def update_preferences( request_body: UpdatePreferencesRequest, user: User = Depends(require_user_v2), auth_service: AuthService = Depends(get_auth_service_dep), ): """Update user preferences.""" updated = await auth_service.update_preferences(user.id, request_body.preferences) if not updated: raise HTTPException(status_code=500, detail="Failed to update preferences") return {"user": _user_to_response(updated)} # ============================================================================= # Session Management Endpoints # ============================================================================= @router.get("/sessions") async def get_sessions( user: User = Depends(require_user_v2), auth_service: AuthService = Depends(get_auth_service_dep), ): """Get all active sessions for current user.""" sessions = await auth_service.get_sessions(user.id) return { "sessions": [ { "id": s.id, "device_info": s.device_info, "ip_address": s.ip_address, "created_at": s.created_at.isoformat() if s.created_at else None, "last_used_at": s.last_used_at.isoformat() if s.last_used_at else None, } for s in sessions ] } @router.delete("/sessions/{session_id}") async def revoke_session( session_id: str, user: User = Depends(require_user_v2), auth_service: AuthService = Depends(get_auth_service_dep), ): """Revoke a specific session.""" success = await auth_service.revoke_session(user.id, session_id) if not success: raise HTTPException(status_code=404, detail="Session not found") return {"status": "ok"} # ============================================================================= # Guest Conversion Endpoint # ============================================================================= @router.post("/convert-guest", response_model=AuthResponse) async def convert_guest( request_body: ConvertGuestRequest, request: Request, auth_service: AuthService = Depends(get_auth_service_dep), ): """Convert guest session to full user account.""" result = await auth_service.convert_guest( guest_id=request_body.guest_id, username=request_body.username, password=request_body.password, email=request_body.email, ) if not result.success: raise HTTPException(status_code=400, detail=result.error) # Auto-login after conversion login_result = await auth_service.login( username=request_body.username, password=request_body.password, device_info=get_device_info(request), ip_address=get_client_ip(request), ) if not login_result.success: raise HTTPException(status_code=500, detail="Conversion succeeded but login failed") return { "user": _user_to_response(login_result.user), "token": login_result.token, "expires_at": login_result.expires_at.isoformat(), } # ============================================================================= # Account Deletion Endpoint # ============================================================================= @router.delete("/me") async def delete_account( user: User = Depends(require_user_v2), auth_service: AuthService = Depends(get_auth_service_dep), ): """Delete (soft delete) current user account.""" success = await auth_service.delete_account(user.id) if not success: raise HTTPException(status_code=500, detail="Failed to delete account") return {"status": "ok", "message": "Account deleted"} # ============================================================================= # Helpers # ============================================================================= def _user_to_response(user: User) -> dict: """Convert User to response dict (public fields only).""" return { "id": user.id, "username": user.username, "email": user.email, "role": user.role.value, "email_verified": user.email_verified, "preferences": user.preferences, "created_at": user.created_at.isoformat() if user.created_at else None, "last_login": user.last_login.isoformat() if user.last_login else None, }