Files
finance/backend/app/identity/service.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

189 lines
6.6 KiB
Python

import uuid
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy.orm import Session as DbSession
from app.audit import service as audit
from app.config import get_settings
from app.errors import bad_request, conflict, unauthorized
from app.identity.models import Invitation, PasswordReset, Session, User
from app.security import hash_password, new_token, token_digest, verify_password
from app.spaces.service import create_space
def normalize_email(email: str) -> str:
return email.strip().casefold()
def validate_profile(locale: str, timezone: str) -> None:
if locale not in {"ru", "en"}:
raise bad_request("IDENTITY_LOCALE_INVALID", "Locale must be ru or en")
try:
ZoneInfo(timezone)
except ZoneInfoNotFoundError as exc:
raise bad_request("IDENTITY_TIMEZONE_INVALID", "Unknown timezone") from exc
def create_invitation(
db: DbSession,
*,
email: str,
invited_by_user_id: uuid.UUID | None = None,
space_id: uuid.UUID | None = None,
) -> tuple[Invitation, str]:
normalized = normalize_email(email)
if db.query(User.id).filter(User.email == normalized).first() is not None:
raise conflict("IDENTITY_EMAIL_EXISTS", "A user with this email already exists")
token = new_token()
invitation = Invitation(
email=normalized,
token_hash=token_digest(token),
invited_by_user_id=invited_by_user_id,
space_id=space_id,
role="owner" if space_id is None else "member",
expires_at=datetime.now(UTC) + timedelta(days=get_settings().invite_ttl_days),
)
db.add(invitation)
db.flush()
audit.record(
db,
event_type="identity.invitation_created",
user_id=invited_by_user_id,
detail={"invitation_id": str(invitation.id)},
)
return invitation, token
def accept_invitation(
db: DbSession,
*,
token: str,
password: str,
display_name: str | None,
locale: str,
timezone: str,
) -> User:
validate_profile(locale, timezone)
if len(password) < 12:
raise bad_request("IDENTITY_PASSWORD_WEAK", "Password must contain at least 12 characters")
invitation = (
db.query(Invitation)
.filter(Invitation.token_hash == token_digest(token))
.with_for_update()
.one_or_none()
)
now = datetime.now(UTC)
if invitation is None or invitation.accepted_at is not None or invitation.expires_at <= now:
raise unauthorized("IDENTITY_INVITATION_INVALID", "Invitation is invalid or expired")
if db.query(User.id).filter(User.email == invitation.email).first() is not None:
raise conflict("IDENTITY_EMAIL_EXISTS", "A user with this email already exists")
user = User(
email=invitation.email,
password_hash=hash_password(password),
display_name=display_name,
locale=locale,
timezone=timezone,
)
db.add(user)
db.flush()
if invitation.space_id is None:
space_name = f"{display_name or invitation.email} finance"
space = create_space(db, user=user, name=space_name)
invitation.space_id = space.id
else:
from app.spaces.models import Membership
db.add(Membership(space_id=invitation.space_id, user_id=user.id, role=invitation.role))
invitation.accepted_at = now
invitation.accepted_user_id = user.id
audit.record(db, event_type="identity.invitation_accepted", user_id=user.id)
return user
def authenticate(db: DbSession, *, email: str, password: str) -> User:
user = (
db.query(User)
.filter(User.email == normalize_email(email), User.status == "active")
.one_or_none()
)
if user is None or not verify_password(user.password_hash, password):
raise unauthorized("IDENTITY_CREDENTIALS_INVALID", "Invalid email or password")
return user
def create_session(db: DbSession, *, user: User) -> tuple[Session, str]:
token = new_token()
now = datetime.now(UTC)
session = Session(
user_id=user.id,
token_hash=token_digest(token),
csrf_token=new_token(),
expires_at=now + timedelta(days=get_settings().session_ttl_days),
last_seen_at=now,
)
db.add(session)
db.flush()
audit.record(db, event_type="identity.session_created", user_id=user.id)
return session, token
def get_active_session(db: DbSession, *, token: str) -> Session:
session = db.query(Session).filter(Session.token_hash == token_digest(token)).one_or_none()
now = datetime.now(UTC)
if session is None or session.revoked_at is not None or session.expires_at <= now:
raise unauthorized("IDENTITY_SESSION_INVALID", "Authentication required")
session.last_seen_at = now
return session
def revoke_session(db: DbSession, *, session: Session, reason: str = "logout") -> None:
if session.revoked_at is None:
session.revoked_at = datetime.now(UTC)
session.revoked_reason = reason
audit.record(db, event_type="identity.session_revoked", user_id=session.user_id)
def create_password_reset(db: DbSession, *, email: str) -> str | None:
user = (
db.query(User)
.filter(User.email == normalize_email(email), User.status == "active")
.one_or_none()
)
if user is None:
return None
token = new_token()
db.add(
PasswordReset(
user_id=user.id,
token_hash=token_digest(token),
expires_at=datetime.now(UTC) + timedelta(hours=get_settings().reset_ttl_hours),
)
)
audit.record(db, event_type="identity.password_reset_requested", user_id=user.id)
return token
def reset_password(db: DbSession, *, token: str, password: str) -> User:
if len(password) < 12:
raise bad_request("IDENTITY_PASSWORD_WEAK", "Password must contain at least 12 characters")
reset = (
db.query(PasswordReset)
.filter(PasswordReset.token_hash == token_digest(token))
.with_for_update()
.one_or_none()
)
now = datetime.now(UTC)
if reset is None or reset.used_at is not None or reset.expires_at <= now:
raise unauthorized("IDENTITY_RESET_INVALID", "Password reset is invalid or expired")
user = db.get(User, reset.user_id)
if user is None:
raise unauthorized("IDENTITY_RESET_INVALID", "Password reset is invalid or expired")
user.password_hash = hash_password(password)
reset.used_at = now
db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update(
{Session.revoked_at: now, Session.revoked_reason: "password_reset"}
)
audit.record(db, event_type="identity.password_reset_completed", user_id=user.id)
return user