from __future__ import annotations from datetime import timedelta from uuid import uuid4 from app.core.config import settings from app.core.datetime_utils import ensure_utc, utc_now from app.core.email import send_template_email from app.core.jwt_denylist import bump_auth_epoch, revoke_access_token from app.core.security import ( create_access_token, generate_opaque_token, generate_refresh_token, hash_opaque_token, hash_password, hash_refresh_token, verify_password, ) from app.modules.auth import repository as auth_repository from app.modules.users import repository from app.modules.users.models import User def _token_expires_at(): return utc_now() + timedelta(hours=settings.auth_token_ttl_hours) def _send_verification_email(user: User, token: str) -> None: verify_url = f"{settings.frontend_url}/verify?token={token}" show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes token_line = f"TOKEN:{token}\n" if show_token else "" body = "Confirm your Compton account.\n\n" + f"Open: {verify_url}\n" + token_line send_template_email( to=user.email, template="verify_email", subject="Confirm your Compton account", body=body, ) def _send_password_reset_email(user: User, token: str) -> None: reset_url = f"{settings.frontend_url}/reset-password?token={token}" show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes token_line = f"TOKEN:{token}\n" if show_token else "" body = "Reset your Compton password.\n\n" + f"Open: {reset_url}\n" + token_line send_template_email( to=user.email, template="reset_password", subject="Reset your Compton password", body=body, ) def _issue_verification_token(user_id: str) -> str: token = generate_opaque_token() auth_repository.create_email_verification_token( user_id=user_id, token_hash=hash_opaque_token(token), expires_at=_token_expires_at(), ) return token def _issue_password_reset_token(user_id: str) -> str: token = generate_opaque_token() auth_repository.create_password_reset_token( user_id=user_id, token_hash=hash_opaque_token(token), expires_at=_token_expires_at(), ) return token def register(email: str, password: str) -> User: existing = repository.get_user_by_email(email) if existing: if existing.status == "pending": token = _issue_verification_token(existing.id) _send_verification_email(existing, token) return existing user = repository.create_user(email=email, password_hash=hash_password(password), status="pending") token = _issue_verification_token(user.id) _send_verification_email(user, token) return user def verify_email_token(token: str) -> User: token_hash = hash_opaque_token(token) token_row = auth_repository.get_email_verification_token(token_hash) if not token_row or token_row.used_at is not None: raise ValueError("INVALID_TOKEN") if ensure_utc(token_row.expires_at) < utc_now(): raise ValueError("TOKEN_EXPIRED") user = repository.get_user_by_id(token_row.user_id) if not user: raise ValueError("INVALID_TOKEN") user.status = "active" user.email_verified_at = utc_now() repository.update_user(user) auth_repository.mark_email_verification_token_used(token_hash) return user def resend_verification(email: str) -> None: user = repository.get_user_by_email(email) if not user or user.status != "pending": return token = _issue_verification_token(user.id) _send_verification_email(user, token) def forgot_password(email: str) -> None: user = repository.get_user_by_email(email) if not user or user.status == "blocked": return token = _issue_password_reset_token(user.id) _send_password_reset_email(user, token) def reset_password(token: str, new_password: str) -> None: token_hash = hash_opaque_token(token) token_row = auth_repository.get_password_reset_token(token_hash) if not token_row or token_row.used_at is not None: raise ValueError("INVALID_TOKEN") if ensure_utc(token_row.expires_at) < utc_now(): raise ValueError("TOKEN_EXPIRED") user = repository.get_user_by_id(token_row.user_id) if not user: raise ValueError("INVALID_TOKEN") user.password_hash = hash_password(new_password) repository.update_user(user) auth_repository.mark_password_reset_token_used(token_hash) bump_auth_epoch(user.id) revoke_user_refresh_family(user.id) def _is_locked(user: User) -> bool: locked_until = ensure_utc(user.locked_until) if locked_until and locked_until > utc_now(): return True if locked_until and locked_until <= utc_now(): user.failed_login_attempts = 0 user.locked_until = None repository.update_user(user) return False def _record_failed_login(user: User) -> None: locked_until = ensure_utc(user.locked_until) if locked_until and locked_until <= utc_now(): user.failed_login_attempts = 0 user.locked_until = None user.failed_login_attempts += 1 if user.failed_login_attempts >= settings.auth_lockout_attempts: user.locked_until = utc_now() + timedelta(minutes=settings.auth_lockout_minutes) repository.update_user(user) def _reset_login_attempts(user: User) -> None: user.failed_login_attempts = 0 user.locked_until = None repository.update_user(user) def login(email: str, password: str) -> tuple[str, str, User]: user = repository.get_user_by_email(email) if user and _is_locked(user): raise PermissionError("ACCOUNT_TEMPORARILY_LOCKED") if not user or not verify_password(password, user.password_hash): if user: _record_failed_login(user) raise ValueError("INVALID_CREDENTIALS") if user.status == "pending": raise PermissionError("EMAIL_NOT_VERIFIED") if user.status == "blocked": raise PermissionError("ACCOUNT_BLOCKED") _reset_login_attempts(user) access_token = create_access_token(user.id, user.role, user.is_superuser) refresh_token = issue_refresh_token(user.id) return access_token, refresh_token, user def issue_refresh_token(user_id: str, family_id: str | None = None) -> str: token = generate_refresh_token() token_hash = hash_refresh_token(token) family = family_id or str(uuid4()) auth_repository.create_refresh_token( user_id=user_id, token_hash=token_hash, family_id=family, expires_at=utc_now() + timedelta(days=settings.jwt_refresh_ttl_days), ) return token def refresh(refresh_token: str) -> tuple[str, str, User]: token_hash = hash_refresh_token(refresh_token) token_row = auth_repository.get_refresh_token(token_hash) if not token_row: raise PermissionError("INVALID_REFRESH") user = repository.get_user_by_id(token_row.user_id) if not user: raise PermissionError("INVALID_REFRESH") if user.status == "pending": raise PermissionError("EMAIL_NOT_VERIFIED") if user.status == "blocked": raise PermissionError("ACCOUNT_BLOCKED") if token_row.revoked_at is not None: auth_repository.revoke_family_tokens(token_row.family_id) raise PermissionError("INVALID_REFRESH") if ensure_utc(token_row.expires_at) < utc_now(): raise PermissionError("EXPIRED_REFRESH") auth_repository.revoke_refresh_token(token_hash) new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id) access = create_access_token(user.id, user.role, user.is_superuser) return access, new_refresh, user def revoke_refresh_token(refresh_token: str) -> None: token_hash = hash_refresh_token(refresh_token) token_row = auth_repository.revoke_refresh_token(token_hash) if token_row: auth_repository.revoke_family_tokens(token_row.family_id) def logout(refresh_token: str | None, access_token: str | None) -> None: if access_token: revoke_access_token(access_token) if refresh_token: revoke_refresh_token(refresh_token) def revoke_user_refresh_family(user_id: str) -> None: auth_repository.revoke_user_families(user_id)