from typing import Annotated, Literal from fastapi import APIRouter, Header, Request, Response from pydantic import BaseModel, ConfigDict, EmailStr, Field from app.config import get_settings from app.dependencies import CurrentSession, Db from app.errors import forbidden from app.identity import service from app.identity.mailer import send_token_email from app.ratelimit import allow from app.security import constant_time_equals router = APIRouter(tags=["identity"]) class InviteBody(BaseModel): email: EmailStr class AcceptInviteBody(BaseModel): model_config = ConfigDict(populate_by_name=True) token: str password: str = Field(min_length=12, max_length=256) display_name: str | None = Field(default=None, alias="fullName", max_length=120) locale: Literal["ru", "en"] = "ru" timezone: str = "Europe/Moscow" class LoginBody(BaseModel): email: EmailStr password: str class ResetRequestBody(BaseModel): email: EmailStr class ResetBody(BaseModel): token: str password: str = Field(min_length=12, max_length=256) def _set_auth_cookies(response: Response, *, token: str, csrf: str) -> None: settings = get_settings() max_age = settings.session_ttl_days * 86400 response.set_cookie( settings.session_cookie_name, token, httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=max_age, path="/", ) response.set_cookie( settings.csrf_cookie_name, csrf, httponly=False, secure=settings.cookie_secure, samesite="lax", max_age=max_age, path="/", ) @router.post("/admin/invitations", status_code=201) def create_invite( body: InviteBody, db: Db, admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None, ) -> dict: expected = get_settings().admin_bootstrap_token if not expected or not admin_token or not constant_time_equals(admin_token, expected): raise forbidden("IDENTITY_ADMIN_TOKEN_INVALID", "Admin authorization required") invitation, token = service.create_invitation(db, email=str(body.email)) db.commit() send_token_email(recipient=invitation.email, token=token, kind="invite") return {"id": invitation.id, "token": token, "expires_at": invitation.expires_at} @router.post("/auth/invitations/accept", status_code=201) def accept_invite(body: AcceptInviteBody, db: Db, response: Response) -> dict: user = service.accept_invitation(db, **body.model_dump(by_alias=False)) session, token = service.create_session(db, user=user) _set_auth_cookies(response, token=token, csrf=session.csrf_token) return { "id": user.id, "email": user.email, "fullName": user.display_name or "", "locale": user.locale, "timezone": user.timezone, } @router.post("/auth/login") def login(body: LoginBody, db: Db, response: Response, request: Request) -> dict: client = request.client.host if request.client else "unknown" if not allow(f"login:{client}:{str(body.email).casefold()}", limit=10, window_seconds=900): from app.errors import AppError raise AppError("Too many login attempts", code="RATE_LIMITED", status_code=429) user = service.authenticate(db, email=str(body.email), password=body.password) session, token = service.create_session(db, user=user) _set_auth_cookies(response, token=token, csrf=session.csrf_token) return { "id": user.id, "email": user.email, "fullName": user.display_name or "", "locale": user.locale, "timezone": user.timezone, } @router.post("/auth/password-reset", status_code=202) def request_password_reset(body: ResetRequestBody, db: Db, request: Request) -> dict: client = request.client.host if request.client else "unknown" if not allow(f"password-reset:{client}", limit=5, window_seconds=900): from app.errors import AppError raise AppError("Too many reset attempts", code="RATE_LIMITED", status_code=429) token = service.create_password_reset(db, email=str(body.email)) if token: send_token_email(recipient=str(body.email), token=token, kind="reset") result: dict[str, object] = {"accepted": True} if get_settings().environment == "development" and token: result["token"] = token return result @router.post("/auth/password-reset/confirm") def confirm_password_reset(body: ResetBody, db: Db, response: Response) -> dict: user = service.reset_password(db, token=body.token, password=body.password) session, token = service.create_session(db, user=user) _set_auth_cookies(response, token=token, csrf=session.csrf_token) return { "id": user.id, "email": user.email, "fullName": user.display_name or "", "locale": user.locale, "timezone": user.timezone, } @router.get("/auth/me") def me(auth: CurrentSession) -> dict: user, _ = auth return { "id": user.id, "email": user.email, "fullName": user.display_name or "", "locale": user.locale, "timezone": user.timezone, } @router.post("/auth/logout", status_code=204) def logout(auth: CurrentSession, db: Db, response: Response) -> None: _, session = auth service.revoke_session(db, session=session) settings = get_settings() response.delete_cookie(settings.session_cookie_name, path="/") response.delete_cookie(settings.csrf_cookie_name, path="/")