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.
189 lines
6.6 KiB
Python
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
|