Files
CIAgent/apps/api/app/services/auth_service.py
T
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
2026-08-05 10:48:20 -04:00

429 lines
18 KiB
Python

"""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, 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 (
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")
user = await repo.create(
email=payload.email,
password_hash=hash_password(payload.password),
display_name=payload.display_name,
timezone=payload.timezone,
# 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)