import uuid from datetime import datetime, timedelta, timezone from typing import Optional from fastapi import APIRouter, Cookie, Depends, HTTPException, Response from app.api.deps import get_current_user, get_token_repository, get_user_repository from app.config import settings from app.core.database.token_repository import RefreshTokenRepository from app.core.database.user_repository import UserRepository from app.core.dto.user import UserDTO from app.core.security import ( DUMMY_PASSWORD_HASH, create_access_token, hash_token, new_refresh_token, verify_password, ) from app.schemas.auth import LoginRequest, RefreshRequest, TokenResponse router = APIRouter() REFRESH_COOKIE = "refresh_token" # The cookie is sent only to the auth endpoints COOKIE_PATH = "/api/v1/auth" def _invalid_credentials() -> HTTPException: return HTTPException(status_code=401, detail="Invalid email or password") def _set_refresh_cookie(response: Response, token: str) -> None: response.set_cookie( REFRESH_COOKIE, token, max_age=settings.REFRESH_TOKEN_TTL_DAYS * 24 * 3600, path=COOKIE_PATH, httponly=True, secure=settings.COOKIE_SECURE, samesite="strict", ) async def _issue_tokens( user: UserDTO, family_id: str, web: bool, response: Response, tokens: RefreshTokenRepository, ) -> TokenResponse: refresh_token = new_refresh_token() await tokens.create( user_id=user.id, family_id=family_id, token_hash=hash_token(refresh_token), expires_at=datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_TTL_DAYS), ) if web: _set_refresh_cookie(response, refresh_token) return TokenResponse( access_token=create_access_token(user.id, user.role.value), expires_in=settings.ACCESS_TOKEN_TTL_MINUTES * 60, refresh_token=None if web else refresh_token, ) @router.post("/login/", response_model=TokenResponse) async def login( payload: LoginRequest, response: Response, users: UserRepository = Depends(get_user_repository), tokens: RefreshTokenRepository = Depends(get_token_repository), ): found = await users.get_credentials(payload.email.strip().lower()) # Check the password even for an unknown email: the response time must not tell them apart user, password_hash = found if found else (None, DUMMY_PASSWORD_HASH) if not verify_password(payload.password, password_hash) or user is None or not user.is_active: raise _invalid_credentials() await users.update(user.id, {"last_login": datetime.now(timezone.utc)}) return await _issue_tokens(user, uuid.uuid4().hex, payload.client == "web", response, tokens) @router.post("/refresh/", response_model=TokenResponse) async def refresh( response: Response, payload: Optional[RefreshRequest] = None, cookie_token: Optional[str] = Cookie(default=None, alias=REFRESH_COOKIE), users: UserRepository = Depends(get_user_repository), tokens: RefreshTokenRepository = Depends(get_token_repository), ): """Exchange a refresh token for a new pair. The old refresh token stops working.""" body_token = payload.refresh_token if payload else None token = body_token or cookie_token web = body_token is None claim = await tokens.claim(hash_token(token)) if token else None user = await users.get(claim.user_id) if claim else None if claim is None or user is None or not user.is_active: if claim is not None: await tokens.revoke_all_of_user(claim.user_id) response.delete_cookie(REFRESH_COOKIE, path=COOKIE_PATH) raise HTTPException(status_code=401, detail="Invalid refresh token") return await _issue_tokens(user, claim.family_id, web, response, tokens) @router.post("/logout/") async def logout( response: Response, payload: Optional[RefreshRequest] = None, cookie_token: Optional[str] = Cookie(default=None, alias=REFRESH_COOKIE), tokens: RefreshTokenRepository = Depends(get_token_repository), ): """Revoke the refresh token of this login. The access token expires by itself in minutes.""" token = (payload.refresh_token if payload else None) or cookie_token if token: await tokens.revoke_family_of(hash_token(token)) response.delete_cookie(REFRESH_COOKIE, path=COOKIE_PATH) return {"status": "ok"} @router.get("/me/", response_model=UserDTO) async def me(user: UserDTO = Depends(get_current_user)): return user