refactor(backend): extract shared helper modules per architecture rules
- Add backend/ai/utils.py — parse_classification, parse_suggestions, strip_code_fences shared by all AI providers; removes duplicated private functions from anthropic_provider.py and openai_provider.py - Add backend/deps/utils.py — get_client_ip, parse_uuid request-parsing helpers; removes local _ip() variants from admin.py, auth.py, shares.py, folders.py - Add backend/storage/exceptions.py — canonical CloudConnectionError definition; all routers and backends import from here instead of redefining - Move validate_password_strength to backend/services/auth.py; removes duplicated _validate_password_strength from admin.py and auth.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+19
-46
@@ -19,7 +19,6 @@ Security invariants:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from typing import Literal, Optional
|
||||
|
||||
@@ -32,6 +31,7 @@ from config import settings
|
||||
from db.models import BackupCode, Quota, RefreshToken, 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 slowapi import Limiter
|
||||
@@ -43,30 +43,6 @@ router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
# IP-level rate limiter (SEC-02 — 10 req/min on register/login/refresh)
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# ── Password strength validation ─────────────────────────────────────────────
|
||||
_PASSWORD_DETAIL = (
|
||||
"Password must be at least 12 characters and include uppercase, "
|
||||
"lowercase, a number, and a special character."
|
||||
)
|
||||
|
||||
|
||||
def _validate_password_strength(password: str) -> bool:
|
||||
"""Return True if password passes all strength rules (AUTH-01).
|
||||
|
||||
Rules: min 12 chars, has uppercase, has lowercase, has digit, has special char.
|
||||
"""
|
||||
if len(password) < 12:
|
||||
return False
|
||||
if not re.search(r"[A-Z]", password):
|
||||
return False
|
||||
if not re.search(r"[a-z]", password):
|
||||
return False
|
||||
if not re.search(r"[0-9]", password):
|
||||
return False
|
||||
if not re.search(r"[^A-Za-z0-9]", password):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -132,11 +108,10 @@ async def register(
|
||||
- Inserts User + Quota rows in a single transaction
|
||||
"""
|
||||
# Password strength check
|
||||
if not _validate_password_strength(body.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=_PASSWORD_DETAIL,
|
||||
)
|
||||
try:
|
||||
auth_service.validate_password_strength(body.password)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
|
||||
# HIBP breach check
|
||||
if await auth_service.check_hibp(body.password):
|
||||
@@ -228,7 +203,7 @@ async def login(
|
||||
user: Optional[User] = result.scalar_one_or_none()
|
||||
|
||||
# IP extraction for audit log (used in both success and failure paths)
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip = get_client_ip(request)
|
||||
|
||||
# Verify password (anti-enumeration: same error regardless of whether user exists)
|
||||
if user is None or not auth_service.verify_password(body.password, user.password_hash):
|
||||
@@ -385,7 +360,7 @@ async def logout(request: Request, response: Response, session: AsyncSession = D
|
||||
"""Revoke current refresh token and clear the cookie."""
|
||||
import hashlib as _hashlib
|
||||
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip = get_client_ip(request)
|
||||
|
||||
raw_token = request.cookies.get("refresh_token")
|
||||
_logout_user_id = None
|
||||
@@ -423,7 +398,7 @@ async def logout_all(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Sign out of all devices: revoke all refresh tokens for current user."""
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip = get_client_ip(request)
|
||||
count = await auth_service.revoke_all_refresh_tokens(session, current_user.id)
|
||||
# D-13: sign-out-all event
|
||||
await write_audit_log(
|
||||
@@ -497,14 +472,13 @@ async def change_password(
|
||||
)
|
||||
|
||||
# Password strength check
|
||||
if not _validate_password_strength(body.new_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=_PASSWORD_DETAIL,
|
||||
)
|
||||
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 = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip = get_client_ip(request)
|
||||
user = await session.get(User, current_user.id)
|
||||
user.password_hash = auth_service.hash_password(body.new_password)
|
||||
# D-13: password changed event (flush within same transaction before commit)
|
||||
@@ -594,7 +568,7 @@ async def enable_totp(
|
||||
await auth_service.store_backup_codes(session, current_user.id, plain_codes)
|
||||
|
||||
# D-13: TOTP enrolled event
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip = get_client_ip(request)
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="auth.totp_enrolled",
|
||||
@@ -620,7 +594,7 @@ async def disable_totp(
|
||||
|
||||
Clears totp_secret, sets totp_enabled=False, and deletes all backup codes.
|
||||
"""
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip = get_client_ip(request)
|
||||
user = await session.get(User, current_user.id)
|
||||
user.totp_enabled = False
|
||||
user.totp_secret = None
|
||||
@@ -699,11 +673,10 @@ async def password_reset_confirm(
|
||||
)
|
||||
|
||||
# Password strength validation
|
||||
if not _validate_password_strength(body.new_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=_PASSWORD_DETAIL,
|
||||
)
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user