## Copyright © 2026 Olaf Kolkman ## SPDX-License-Identifier: GPL-3.0-or-later from uuid import uuid4 from fastapi import APIRouter, Depends, Header, HTTPException, Request from pydantic import BaseModel from backend.app.api.dependencies import get_current_user from backend.app.database import get_connection, init_db from backend.app.core.config import settings from backend.app.services.auth_service import authenticate_user, find_user from backend.app.services.email_service import send_password_reset_email, smtp_configured from backend.app.services.email_verification import verify_email from backend.app.services.password_reset import create_reset_token, reset_password from backend.app.services.token_service import issue_token, revoke_token, rotate_refresh_token, validate_token from backend.app.services.otp_service import verify_code from backend.app.services.secret_store import decrypt_secret from backend.app.services.email_addresses import verify_user_email_address from backend.app.services.login_throttle import check_login_allowed, clear_login_failures, record_login_failure router = APIRouter() init_db() class LoginRequest(BaseModel): email: str password: str otp: str | None = None device_id: str | None = None class RefreshTokenRequest(BaseModel): refresh_token: str device_id: str | None = None class PasswordResetRequest(BaseModel): token: str password: str @router.post('/login') def login(payload: LoginRequest, request: Request): email = payload.email.strip() ip_address = request.client.host if request.client else 'unknown' retry_after = check_login_allowed(ip_address, email) if retry_after is not None: raise HTTPException(status_code=429, detail='Too many failed login attempts. Try again later.', headers={'Retry-After': str(retry_after)}) user = authenticate_user(email, payload.password) if user is None: record_login_failure(ip_address, email) reset_user = find_user(email) if reset_user and reset_user['email_verified'] and smtp_configured(): try: token = create_reset_token(reset_user['id']) reset_url = f'{settings.public_url}/reset-password?token={token}' send_password_reset_email(reset_user['email'], reset_user['username'], reset_url) except Exception: pass raise HTTPException(status_code=401, detail='Invalid username or password') if not user['email_verified']: raise HTTPException(status_code=403, detail='Email address is not verified') if user['otp_enabled'] and not verify_code(decrypt_secret(user['otp_secret']), payload.otp): record_login_failure(ip_address, email) raise HTTPException(status_code=401, detail='One-time password required or invalid') clear_login_failures(ip_address, email) token_data = issue_token(user['id'], user['username'], payload.device_id) return { 'access_token': token_data['access_token'], 'token_type': 'bearer', 'expires_at': token_data['expires_at'], 'refresh_token': token_data['refresh_token'], 'device_id': token_data['device_id'], 'user': {'id': user['id'], 'username': user['username'], 'email': user['email'], 'otp_enabled': bool(user['otp_enabled'])} } @router.post('/refresh') def refresh_token_endpoint(payload: RefreshTokenRequest): rotated = rotate_refresh_token(payload.refresh_token, payload.device_id) if rotated is None: raise HTTPException(status_code=401, detail='Refresh token is invalid, expired, or bound to another device') return { 'access_token': rotated['access_token'], 'token_type': 'bearer', 'expires_at': rotated['expires_at'], 'refresh_token': rotated['refresh_token'], 'device_id': rotated['device_id'], 'user': {'id': rotated['user_id'], 'username': rotated['username']}, } @router.get('/verify-email') def verify_email_address(token: str): if not verify_email(token): raise HTTPException(status_code=400, detail='Verification link is invalid or expired') return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'} @router.get('/verify-additional-email') def verify_additional_email_address(token: str): if not verify_user_email_address(token): raise HTTPException(status_code=400, detail='Email verification link is invalid or expired') return {'status': 'verified', 'message': 'Email address verified. You can now sign in.'} @router.post('/reset-password') def reset_password_endpoint(payload: PasswordResetRequest): if len(payload.password) < 8: raise HTTPException(status_code=422, detail='Password must contain at least 8 characters') if not reset_password(payload.token, payload.password): raise HTTPException(status_code=400, detail='Password reset link is invalid or expired') return {'status': 'password_reset', 'message': 'Password reset. You can now sign in.'} @router.post('/logout') def logout(authorization: str | None = Header(default=None)): if not authorization or not authorization.startswith('Bearer '): raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') token = authorization.replace('Bearer ', '', 1).strip() if not token: raise HTTPException(status_code=401, detail='Missing or invalid Authorization header') revoked = revoke_token(token) if not revoked: raise HTTPException(status_code=404, detail='Token not found or already revoked') return {'status': 'logged_out'} @router.get('/me') def current_user(user: dict = Depends(get_current_user)): return { 'id': user['id'], 'username': user['username'], 'email': user['email'], 'is_admin': bool(user['is_admin']), }