d582f15681
- 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.
165 lines
5.4 KiB
Python
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="/")
|