Files
finance/backend/app/identity/router.py
T
k1nq d582f15681 feat: add tests for import polling, login page, money utilities, and query keys
- Implemented tests for import polling functionality, ensuring correct behavior for active and terminal import statuses.
- Created tests for the login page, validating user interactions and error handling.
- Added unit tests for money utility functions, covering parsing, formatting, and summation of monetary values.
- Developed tests for query keys, ensuring proper scoping and invalidation roots.

chore: setup testing environment and configuration

- Added setup file for testing with Vitest and Testing Library.
- Configured TypeScript and Vite for the frontend project.
- Created Docker and Nginx configurations for local development and production environments.
- Established a PostgreSQL initialization script for setting up application roles.

docs: add README and environment configuration for infra

- Included README for the infra directory, detailing local development setup and service descriptions.
- Created an example environment configuration file for local development with placeholders for sensitive information.
2026-09-14 09:26:33 +05:00

165 lines
5.4 KiB
Python

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="/")