- Create api/auth/shared.py: limiter, all Pydantic request models, _set_refresh_cookie, _user_dict - Create api/auth/tokens.py: register, login, refresh, logout, logout-all, me, quota, preferences handlers - Create api/auth/totp.py: totp/setup, totp/enable, totp DELETE handlers (CR-02, CR-03 preserved) - Create api/auth/password.py: change-password, password-reset, password-reset/confirm handlers (CR-01 preserved) - Create api/auth/__init__.py: router aggregator with prefix=/api/auth; re-exports limiter - Rename auth.py monolith to auth_OLD_REMOVE_IN_TASK_2.py (deleted in Task 2 after tests pass) - All 15 routes confirmed; limiter re-export verified; skip_token_hash calls preserved verbatim
193 lines
7.3 KiB
Python
193 lines
7.3 KiB
Python
"""
|
|
Auth API — password management endpoints.
|
|
|
|
Handles:
|
|
POST /change-password — update password (requires current password)
|
|
POST /password-reset — request a password reset email
|
|
POST /password-reset/confirm — confirm a password reset using the token
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import time
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from config import settings
|
|
from db.models import User
|
|
from deps.auth import get_current_user
|
|
from deps.db import get_db
|
|
from deps.utils import get_client_ip
|
|
from services import auth as auth_service
|
|
from services.audit import write_audit_log
|
|
from api.auth.shared import (
|
|
limiter,
|
|
ChangePasswordRequest,
|
|
PasswordResetRequest,
|
|
PasswordResetConfirmRequest,
|
|
)
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── POST /change-password ─────────────────────────────────────────────────────
|
|
|
|
@router.post("/change-password")
|
|
async def change_password(
|
|
request: Request,
|
|
body: ChangePasswordRequest,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Update the current user's password.
|
|
|
|
Checks:
|
|
1. current_password matches stored hash
|
|
2. new_password has not appeared in HIBP (SEC-03)
|
|
3. new_password meets strength requirements (AUTH-01)
|
|
"""
|
|
# Verify current password
|
|
if not auth_service.verify_password(body.current_password, current_user.password_hash):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Current password is incorrect",
|
|
)
|
|
|
|
# HIBP breach check on new password (SEC-03)
|
|
if await auth_service.check_hibp(body.new_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="This password has appeared in a data breach. Choose a different password.",
|
|
)
|
|
|
|
# Password strength check
|
|
try:
|
|
auth_service.validate_password_strength(body.new_password)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
|
|
|
# Update password
|
|
_ip = get_client_ip(request)
|
|
user = await session.get(User, current_user.id)
|
|
user.password_hash = auth_service.hash_password(body.new_password)
|
|
# Revoke other sessions; keep current one alive via skip_token_hash (CR-01)
|
|
raw_cookie = request.cookies.get("refresh_token")
|
|
skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None
|
|
revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)
|
|
# D-13: password changed event (flush within same transaction before commit)
|
|
await write_audit_log(
|
|
session,
|
|
event_type="auth.password_changed",
|
|
user_id=current_user.id,
|
|
actor_id=current_user.id,
|
|
resource_id=None,
|
|
ip_address=_ip,
|
|
metadata_={"sessions_revoked": revoked},
|
|
)
|
|
# Revoke any pre-change access tokens still within their TTL window (T-7.2-01)
|
|
await request.app.state.redis.set(
|
|
f"user_nbf:{current_user.id}",
|
|
int(time.time()),
|
|
ex=settings.access_token_expire_minutes * 60,
|
|
)
|
|
await session.commit()
|
|
|
|
return {"message": "Password updated", "sessions_revoked": revoked}
|
|
|
|
|
|
# ── POST /password-reset ──────────────────────────────────────────────────────
|
|
|
|
@router.post("/password-reset", status_code=status.HTTP_202_ACCEPTED)
|
|
@limiter.limit("5/hour")
|
|
async def password_reset_request(
|
|
request: Request,
|
|
body: PasswordResetRequest,
|
|
session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Request a password reset email.
|
|
|
|
Always returns 202 regardless of whether the email exists (anti-enumeration, T-02-22).
|
|
If the user is found, a signed reset token (1-hour JWT) is generated and a Celery
|
|
task is enqueued to send the email (D-02, D-03).
|
|
"""
|
|
from sqlalchemy import select as _select # noqa: PLC0415 (already imported above)
|
|
|
|
result = await session.execute(_select(User).where(User.email == str(body.email)))
|
|
user: Optional[User] = result.scalar_one_or_none()
|
|
|
|
if user is not None:
|
|
token = auth_service.create_password_reset_token(str(user.id))
|
|
reset_link = f"{settings.frontend_url}/password-reset/confirm?token={token}"
|
|
# Deferred import to avoid circular import; Celery task is fire-and-forget
|
|
from tasks.email_tasks import send_reset_email # noqa: PLC0415
|
|
send_reset_email.delay(user.email, reset_link)
|
|
|
|
# Always return 202 (anti-enumeration — never reveal whether email exists)
|
|
return {
|
|
"message": (
|
|
"If an account exists for that email, you will receive a reset link shortly."
|
|
)
|
|
}
|
|
|
|
|
|
# ── POST /password-reset/confirm ──────────────────────────────────────────────
|
|
|
|
@router.post("/password-reset/confirm")
|
|
async def password_reset_confirm(
|
|
request: Request,
|
|
body: PasswordResetConfirmRequest,
|
|
session: AsyncSession = Depends(get_db),
|
|
):
|
|
"""Confirm a password reset using the token from the email link.
|
|
|
|
Validates the reset token, enforces password strength + HIBP check, updates
|
|
the password, and revokes all refresh tokens. Does NOT issue new tokens —
|
|
the user must sign in again through /login (AUTH-05, T-02-21).
|
|
"""
|
|
try:
|
|
user_id_str = auth_service.decode_password_reset_token(body.token)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired reset link",
|
|
)
|
|
|
|
# Password strength validation
|
|
try:
|
|
auth_service.validate_password_strength(body.new_password)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
|
|
|
# HIBP breach check (SEC-03)
|
|
if await auth_service.check_hibp(body.new_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="This password has appeared in a data breach. Choose a different password.",
|
|
)
|
|
|
|
# Load user
|
|
user = await session.get(User, uuid.UUID(user_id_str))
|
|
if user is None or not user.is_active:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Invalid or expired reset link",
|
|
)
|
|
|
|
# Update password and revoke all sessions (forces re-auth through TOTP if enabled)
|
|
user.password_hash = auth_service.hash_password(body.new_password)
|
|
await auth_service.revoke_all_refresh_tokens(session, user.id)
|
|
# Revoke any pre-reset access tokens still within their TTL window (T-7.2-01)
|
|
await request.app.state.redis.set(
|
|
f"user_nbf:{user.id}",
|
|
int(time.time()),
|
|
ex=settings.access_token_expire_minutes * 60,
|
|
)
|
|
await session.commit()
|
|
|
|
# Do NOT issue tokens (AUTH-05 — user must pass TOTP gate on next login)
|
|
return {"message": "Password updated. Please sign in."}
|