Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
506
server/routers/auth.py
Normal file
506
server/routers/auth.py
Normal file
@@ -0,0 +1,506 @@
|
||||
"""
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user