"""Registration/login/refresh/logout/verify-email/password-reset business logic. Kept independent of FastAPI so it's reachable from tests and (later) from Celery tasks or an admin script without pulling in the HTTP layer. """ from __future__ import annotations import uuid from datetime import UTC, datetime, timedelta from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import Settings from app.core.errors import ( AuthenticationError, ConflictError, NotFoundError, ThrottledError, ValidationAppError, ) from app.core.security import ( InvalidTokenError, TokenType, create_access_token, create_refresh_token, decode_token, generate_email_code, hash_email_code, hash_password, hash_token_identifier, verify_password, ) from app.db.base import ensure_aware_utc from app.models.enums import EmailCodePurpose, SecurityEventType, ThrottleAction from app.models.user import LOCAL_DEV_USER_EMAIL, LOCAL_DEV_USER_ID, User from app.models.user_security_event import UserSecurityEvent from app.repositories.email_code_repository import EmailCodeRepository from app.repositories.password_history_repository import PasswordHistoryRepository from app.repositories.refresh_token_repository import RefreshTokenRepository from app.repositories.user_known_ip_repository import UserKnownIpRepository from app.repositories.user_repository import UserRepository from app.repositories.user_security_event_repository import UserSecurityEventRepository from app.schemas.auth import ( ChangePasswordRequest, ConfirmPasswordResetRequest, LoginRequest, RegisterRequest, RequestPasswordResetRequest, ResendVerificationRequest, TokenResponse, VerifyEmailRequest, ) from app.services import ip_throttle_service, security_email_service EMAIL_CODE_VALID_HOURS = 36 # The local-dev bypass (AUTH_MODE=local + loopback, see # app/auth/dependencies.py) has no real login step - get_or_create_local_user # runs on every authenticated request. Logging a login_success event on every # single request would flood Account activity, so a fresh one is only # recorded once per this cooldown window, treated as a proxy for "a new # session" rather than every request within one. LOCAL_DEV_LOGIN_LOG_COOLDOWN_MINUTES = 30 async def get_or_create_local_user(db: AsyncSession, client_ip: str) -> User: repo = UserRepository(db) user = await repo.get_by_id(LOCAL_DEV_USER_ID) if user is None: user = await repo.create( email=LOCAL_DEV_USER_EMAIL, password_hash=None, display_name="Local Developer", timezone="America/New_York", user_id=LOCAL_DEV_USER_ID, is_admin=True, email_verified=True, ) await db.commit() event_repo = UserSecurityEventRepository(db) last_login = await event_repo.most_recent_of_type(user.id, SecurityEventType.LOGIN_SUCCESS) cutoff = datetime.now(UTC) - timedelta(minutes=LOCAL_DEV_LOGIN_LOG_COOLDOWN_MINUTES) if last_login is None or ensure_aware_utc(last_login.created_at) < cutoff: await event_repo.create( user_id=user.id, event_type=SecurityEventType.LOGIN_SUCCESS, ip_address=client_ip ) await UserKnownIpRepository(db).record_login(user.id, client_ip, datetime.now(UTC)) await db.commit() return user async def _issue_and_send_email_code( db: AsyncSession, settings: Settings, user: User, purpose: EmailCodePurpose ) -> None: code = generate_email_code() code_repo = EmailCodeRepository(db) # A fresh code must fully supersede every prior unused one for this # purpose - otherwise an old code (e.g. still sitting in an earlier # email) stays valid until it naturally expires, even after the user # explicitly asked for a new one. await code_repo.invalidate_unused(user.id, purpose) await code_repo.create( user_id=user.id, purpose=purpose, code_hash=hash_email_code(code), expires_at=datetime.now(UTC) + timedelta(hours=EMAIL_CODE_VALID_HOURS), ) if purpose == EmailCodePurpose.VERIFY_EMAIL: await security_email_service.send_verification_code_email(settings, user.email, code) else: await security_email_service.send_password_reset_email(settings, user.email, code) async def register( db: AsyncSession, settings: Settings, client_ip: str, payload: RegisterRequest ) -> User: if await ip_throttle_service.is_banned(db, client_ip): raise ThrottledError("This IP address has been temporarily blocked.") repo = UserRepository(db) existing = await repo.get_by_email(payload.email) if existing is not None: raise ConflictError("An account with this email already exists") # Bootstraps admin access on a fresh deployment - otherwise the only way # to ever get an admin account is direct DB access, which is a real # chicken-and-egg problem for anyone self-hosting from a clean clone. # Checked by admin *count*, not total user count, so this also # self-heals if the last admin ever deletes their own account (Settings # -> Delete account) - the next registration becomes admin again rather # than leaving the deployment permanently admin-less. A benign race is # possible if two people register in the same instant on a brand-new, # zero-admin deployment (both could become admin) - acceptable for a # bootstrapping check that only ever matters once, before any real # traffic exists. is_first_admin = await repo.count_admins() == 0 user = await repo.create( email=payload.email, password_hash=hash_password(payload.password), display_name=payload.display_name, timezone=payload.timezone, is_admin=is_first_admin, # Test suite has no inbox to read a real code from - same # app_env == "test" precedent already used to disable rate limiting # (app/core/rate_limit.py). The code-generation/sending/throttle # path below still runs unconditionally either way, so it's still # exercised by every test that registers a user, not skipped. email_verified=settings.app_env == "test", ) await _issue_and_send_email_code(db, settings, user, EmailCodePurpose.VERIFY_EMAIL) # Deliberately does NOT call ip_throttle_service.record_attempt here - # this is per-IP, not per-account (see ip_throttle_service's docstring), # so unconditionally charging every new registration against it would # let unrelated people sharing an IP (an office, a NAT'd network) drive # each other toward a shared ban purely through legitimate signups. The # escalation ladder starts from the first *manual* resend click instead # (resend_verification endpoint) - "first resend allowed in 30s" is a # frontend-only initial cooldown after registration, not a backend- # enforced one; the backend ladder governs resend #2 onward. await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.EMAIL_VERIFICATION_SENT, ip_address=client_ip ) await db.commit() return user async def verify_email(db: AsyncSession, client_ip: str, payload: VerifyEmailRequest) -> None: """Generic failure for both 'no such account' and 'wrong/expired code' - never distinguishes the two to the caller. Guessing the code itself is IP-throttled the same way a login password guess is - a 6-digit code has only 1M possible values, so without this an attacker could brute-force it well within its 36-hour validity window.""" throttle = await ip_throttle_service.peek_throttle( db, client_ip, ThrottleAction.VERIFY_EMAIL_CODE ) if not throttle.allowed: _raise_throttled(throttle) user_repo = UserRepository(db) code_repo = EmailCodeRepository(db) user = await user_repo.get_by_email(payload.email) record = ( await code_repo.get_latest_valid( user.id, EmailCodePurpose.VERIFY_EMAIL, hash_email_code(payload.code) ) if user is not None else None ) if record is None: await ip_throttle_service.record_attempt( db, client_ip, ThrottleAction.VERIFY_EMAIL_CODE, ip_throttle_service.LOGIN_BACKOFF_SECONDS, ) await db.commit() raise AuthenticationError("Invalid or expired verification code") await code_repo.mark_used(record) user.email_verified = True await ip_throttle_service.reset_on_success(db, client_ip, ThrottleAction.VERIFY_EMAIL_CODE) await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.EMAIL_VERIFIED, ip_address=client_ip ) await db.commit() async def resend_verification( db: AsyncSession, settings: Settings, client_ip: str, payload: ResendVerificationRequest ) -> None: throttle = await ip_throttle_service.peek_throttle( db, client_ip, ThrottleAction.RESEND_VERIFICATION ) if not throttle.allowed: _raise_throttled(throttle) user_repo = UserRepository(db) user = await user_repo.get_by_email(payload.email) # Always record the attempt, whether or not the account exists, so the # throttle behavior itself can never leak account existence. await ip_throttle_service.record_attempt( db, client_ip, ThrottleAction.RESEND_VERIFICATION, ip_throttle_service.RESEND_BACKOFF_SECONDS, ) if user is not None and not user.email_verified: await _issue_and_send_email_code(db, settings, user, EmailCodePurpose.VERIFY_EMAIL) await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.EMAIL_VERIFICATION_SENT, ip_address=client_ip, ) await db.commit() async def request_password_reset( db: AsyncSession, settings: Settings, client_ip: str, payload: RequestPasswordResetRequest ) -> None: throttle = await ip_throttle_service.peek_throttle(db, client_ip, ThrottleAction.RESEND_RESET) if not throttle.allowed: _raise_throttled(throttle) user_repo = UserRepository(db) user = await user_repo.get_by_email(payload.email) await ip_throttle_service.record_attempt( db, client_ip, ThrottleAction.RESEND_RESET, ip_throttle_service.RESEND_BACKOFF_SECONDS ) if user is not None: await _issue_and_send_email_code(db, settings, user, EmailCodePurpose.PASSWORD_RESET) await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.PASSWORD_RESET_REQUESTED, ip_address=client_ip, ) # Always a generic success, regardless of whether the account exists - # the classic enumeration-safe pattern. await db.commit() async def confirm_password_reset( db: AsyncSession, client_ip: str, payload: ConfirmPasswordResetRequest ) -> None: """Same code-guess throttling rationale as verify_email - a reset code is just as brute-forceable if left unthrottled.""" throttle = await ip_throttle_service.peek_throttle( db, client_ip, ThrottleAction.CONFIRM_RESET_CODE ) if not throttle.allowed: _raise_throttled(throttle) user_repo = UserRepository(db) code_repo = EmailCodeRepository(db) user = await user_repo.get_by_email(payload.email) record = ( await code_repo.get_latest_valid( user.id, EmailCodePurpose.PASSWORD_RESET, hash_email_code(payload.code) ) if user is not None else None ) if record is None: await ip_throttle_service.record_attempt( db, client_ip, ThrottleAction.CONFIRM_RESET_CODE, ip_throttle_service.LOGIN_BACKOFF_SECONDS, ) await db.commit() raise AuthenticationError("Invalid or expired reset code") # Checked before consuming the code, and before recording any throttle # attempt, so a rejected-for-reuse password never burns the (correctly # entered) code - the user can immediately retry with a different one. history_repo = PasswordHistoryRepository(db) previous_hashes = await history_repo.list_hashes_for_user(user.id) if user.password_hash is not None: previous_hashes.append(user.password_hash) if any(verify_password(payload.new_password, h) for h in previous_hashes): raise ValidationAppError("You've used this password before. Choose a different one.") await code_repo.mark_used(record) if user.password_hash is not None: await history_repo.add(user_id=user.id, password_hash=user.password_hash) user.password_hash = hash_password(payload.new_password) # This is the documented unlock mechanism - a successful reset always # clears any prior lockout, regardless of how it got there. user.locked_at = None user.failed_login_count = 0 await ip_throttle_service.reset_on_success(db, client_ip, ThrottleAction.CONFIRM_RESET_CODE) await RefreshTokenRepository(db).revoke_all_for_user(user.id) await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.PASSWORD_RESET_COMPLETED, ip_address=client_ip ) await db.commit() def _raise_throttled(throttle: ip_throttle_service.ThrottleResult) -> None: if throttle.banned: raise ThrottledError("This IP address has been temporarily blocked.") raise ThrottledError( f"Too many attempts. Try again in {throttle.retry_after_seconds} seconds.", retry_after_seconds=throttle.retry_after_seconds, ) async def _issue_token_pair(db: AsyncSession, settings: Settings, user: User) -> TokenResponse: access_token = create_access_token(user.id, settings) refresh_jwt, jti, expires_at = create_refresh_token(user.id, settings) token_repo = RefreshTokenRepository(db) await token_repo.create( user_id=user.id, token_hash=hash_token_identifier(jti), expires_at=expires_at ) await db.commit() return TokenResponse( access_token=access_token, refresh_token=refresh_jwt, expires_in_minutes=settings.jwt_access_token_minutes, ) # 5 free instant retries, then 5s/15s/30s/60s/2min/5min/15min - the 12th # failure (index past the end of LOGIN_BACKOFF_SECONDS) is also where the # account itself locks, in lockstep with the IP throttle's own escalation. LOGIN_LOCKOUT_THRESHOLD = len(ip_throttle_service.LOGIN_BACKOFF_SECONDS) + 1 async def login( db: AsyncSession, settings: Settings, client_ip: str, payload: LoginRequest ) -> TokenResponse: # Enforced *before* password verification - a correct password during a # penalty window must still be rejected, or the delay is meaningless. throttle = await ip_throttle_service.peek_throttle(db, client_ip, ThrottleAction.FAILED_LOGIN) if not throttle.allowed: _raise_throttled(throttle) repo = UserRepository(db) user = await repo.get_by_email(payload.email) if user is not None and user.locked_at is not None: # Generic message either way - never confirms the account exists. raise AuthenticationError("Account locked. Reset your password to unlock it.") password_ok = ( user is not None and user.password_hash is not None and verify_password(payload.password, user.password_hash) ) if not password_ok: await ip_throttle_service.record_attempt( db, client_ip, ThrottleAction.FAILED_LOGIN, ip_throttle_service.LOGIN_BACKOFF_SECONDS ) if user is not None: user.failed_login_count += 1 await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.LOGIN_FAILED, ip_address=client_ip ) if user.failed_login_count >= LOGIN_LOCKOUT_THRESHOLD: user.locked_at = datetime.now(UTC) await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.ACCOUNT_LOCKED, ip_address=client_ip, ) await security_email_service.send_account_locked_email(settings, user.email) await db.commit() raise AuthenticationError("Invalid email or password") assert user is not None # password_ok implies this if not user.is_active: raise AuthenticationError("This account has been deactivated") if not user.email_verified: raise AuthenticationError("Verify your email before signing in.") user.failed_login_count = 0 await ip_throttle_service.reset_on_success(db, client_ip, ThrottleAction.FAILED_LOGIN) await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.LOGIN_SUCCESS, ip_address=client_ip ) await UserKnownIpRepository(db).record_login(user.id, client_ip, datetime.now(UTC)) return await _issue_token_pair(db, settings, user) async def refresh(db: AsyncSession, settings: Settings, raw_refresh_token: str) -> TokenResponse: try: decoded = decode_token(raw_refresh_token, settings, TokenType.REFRESH) except InvalidTokenError as exc: raise AuthenticationError("Invalid or expired refresh token") from exc token_repo = RefreshTokenRepository(db) stored = await token_repo.get_valid_by_hash(hash_token_identifier(decoded.jti)) if stored is None: raise AuthenticationError("Invalid or expired refresh token") user_repo = UserRepository(db) user = await user_repo.get_by_id(decoded.user_id) if user is None or not user.is_active: raise AuthenticationError("Invalid or expired refresh token") # Rotate: revoke the token just used before issuing a new pair. await token_repo.revoke(stored) return await _issue_token_pair(db, settings, user) async def logout(db: AsyncSession, settings: Settings, raw_refresh_token: str) -> None: try: decoded = decode_token(raw_refresh_token, settings, TokenType.REFRESH) except InvalidTokenError: return # Already unusable; logout is idempotent. token_repo = RefreshTokenRepository(db) stored = await token_repo.get_valid_by_hash(hash_token_identifier(decoded.jti)) if stored is not None: await token_repo.revoke(stored) await db.commit() async def list_security_events(db: AsyncSession, user_id: uuid.UUID) -> list[UserSecurityEvent]: """The calling user's own security activity - the user-facing counterpart to the admin-only app-wide log feed (core/logging.py).""" return await UserSecurityEventRepository(db).list_for_user(user_id) async def delete_account(db: AsyncSession, user: User, password: str) -> None: """Permanently deletes the account and, via ON DELETE CASCADE foreign keys (see migrations), everything it owns - companies and everything under them, refresh tokens, security events, API keys, etc. There is no soft-delete or recovery path. Rejected for accounts with no password_hash (the fixed AUTH_MODE=local user) - there's nothing to verify the caller's identity against.""" if user.password_hash is None: raise ValidationAppError("Account deletion isn't available for this account.") if not verify_password(password, user.password_hash): raise AuthenticationError("Incorrect password") await UserRepository(db).delete(user) await db.commit() async def change_password( db: AsyncSession, settings: Settings, client_ip: str, user: User, payload: ChangePasswordRequest, ) -> TokenResponse: """Authenticated in-app password change - distinct from confirm_password_reset (which is the emailed-code flow for someone who can't log in at all). Also the only way to clear must_change_password, the admin-forced-reset flag (see require_password_change below).""" if user.password_hash is None: raise ValidationAppError("This account doesn't use password sign-in.") if not verify_password(payload.current_password, user.password_hash): raise AuthenticationError("Incorrect current password") history_repo = PasswordHistoryRepository(db) previous_hashes = await history_repo.list_hashes_for_user(user.id) previous_hashes.append(user.password_hash) if any(verify_password(payload.new_password, h) for h in previous_hashes): raise ValidationAppError("You've used this password before. Choose a different one.") await history_repo.add(user_id=user.id, password_hash=user.password_hash) user.password_hash = hash_password(payload.new_password) user.must_change_password = False # Rotate every session, including the one making this request - the # fresh token pair returned below replaces it immediately, so the # caller keeps working without a forced re-login. await RefreshTokenRepository(db).revoke_all_for_user(user.id) await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.PASSWORD_CHANGED, ip_address=client_ip ) return await _issue_token_pair(db, settings, user) async def require_password_change(db: AsyncSession, client_ip: str, email: str) -> User: """Admin action: flags an account so its next request is blocked everywhere except /auth/me, /auth/change-password, and /auth/logout (enforced in app.auth.dependencies.get_current_user) until they set a new password. Meant for handing a demo account to someone with a known sample password.""" user = await UserRepository(db).get_by_email(email) if user is None: raise NotFoundError("No account with that email") if user.password_hash is None: raise ValidationAppError("This account doesn't use password sign-in.") user.must_change_password = True await UserSecurityEventRepository(db).create( user_id=user.id, event_type=SecurityEventType.PASSWORD_CHANGE_REQUIRED, ip_address=client_ip, ) await db.commit() return user