Files
CIAgent/apps/api/app/services/auth_service.py
T
sakshamandClaude Sonnet 5 18305b545c Backfill sparse report sections, add enrichment section refresh, and bootstrap first-admin
Reports: the LLM reliably used company_enrichment for prose fields but
inconsistently populated the parallel Finding-list/string-list fields from
the same evidence, even with progressively more explicit prompting. Add a
code-level backfill (products, recent developments, financial signals,
strategic initiatives, regulatory signals, risks/opportunities mirrored
from SWOT, unknowns, monitoring recommendations) that only ever fills in
what the model left empty, never overwrites what it produced.

Enrichment tab: reorder sections (Products/Recent updates before
Customers/Competitors) and add a per-section "Refresh" button that
re-fetches just one of NinjaPear's six independent per-company endpoints
when it came back empty - confirmed live that a data-coverage gap (e.g.
Amazon returning no products) is real provider behavior, not a bug.

Auth: the first account registered on a deployment with zero existing
admins is now auto-promoted to admin, closing the chicken-and-egg gap
where the only path to admin access was direct DB access. Self-heals if
the last admin ever deletes their account.

Also bumps nginx's proxy_read_timeout for api.ciagent.org to cover the
enrichment refresh's synchronous funding-endpoint call (up to 5 minutes
per NinjaPear's docs).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-06 17:41:02 -04:00

522 lines
22 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,
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