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