123 lines
4.4 KiB
Python
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
|