f1tness_parser/backend/app/api/v1/auth.py

123 lines
4.4 KiB
Python

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