An admin can now queue up an email address in advance (POST/GET/DELETE /system/pending-provisioning) with a source user and an optional admin flag. The moment that email actually verifies a real account - not raw registration, which proves nothing about ownership - it gets a deep copy of the source user's per-user API keys and every company they own (company profile, aliases, competitors, monitor config, sources, source documents, monitoring runs, reports, snapshots, detected changes, and enrichment - not just the company row), plus an email notification destination for its own address linked to the copied companies. The clone logic (_copy_row/_clone_company in provisioning_service.py) is generic over every table it touches via column introspection, so it doesn't need hand-maintained field lists per model. Co-Authored-By: Claude Sonnet 5 <[email protected]>
527 lines
22 KiB
Python
527 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, provisioning_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
|
|
# Verifying an email is the point at which the app can actually trust
|
|
# someone owns this address - if an admin queued this address up for
|
|
# pre-provisioning (see provisioning_service), this is where it fires,
|
|
# never at raw registration (which proves nothing about ownership).
|
|
await provisioning_service.apply_if_pending(db, user)
|
|
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
|