feat(phase-4): Task 1 — audit log backfill in auth.py and documents.py (D-13)

- Add write_audit_log import to auth.py and documents.py
- auth.py: login success (auth.login), login failure (auth.login_failed, no PII),
  logout (auth.logout), logout-all (auth.sign_out_all), change-password
  (auth.password_changed), TOTP enable (auth.totp_enrolled), TOTP disable
  (auth.totp_revoked), backup code used (auth.backup_code_used)
- documents.py: upload confirm (document.uploaded, size+backend only),
  document delete (document.deleted, size only — no filename/extracted_text)
- Add request: Request param to change_password, disable_totp, confirm_upload, delete_document
This commit is contained in:
curo1305
2026-05-25 21:48:15 +02:00
parent 2a0df32e92
commit e451b16f8f
2 changed files with 128 additions and 0 deletions
+96
View File
@@ -33,6 +33,7 @@ from db.models import BackupCode, Quota, RefreshToken, User
from deps.auth import get_current_user
from deps.db import get_db
from services import auth as auth_service
from services.audit import write_audit_log
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy import delete
@@ -226,8 +227,22 @@ async def login(
result = await session.execute(select(User).where(User.email == str(body.email)))
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)
# 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):
# D-13: log login failure WITHOUT PII (no email, no password) — T-04-07-01
await write_audit_log(
session,
event_type="auth.login_failed",
user_id=None,
actor_id=None,
resource_id=None,
ip_address=_ip,
metadata_=None,
)
await session.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
@@ -266,12 +281,33 @@ async def login(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or already used code",
)
# D-13: backup code used event
await write_audit_log(
session,
event_type="auth.backup_code_used",
user_id=user.id,
actor_id=user.id,
resource_id=None,
ip_address=_ip,
)
# Issue tokens
access_token = auth_service.create_access_token(str(user.id), user.role)
raw_refresh = await auth_service.create_refresh_token(session, user.id)
_set_refresh_cookie(response, raw_refresh)
# D-13: login success event
await write_audit_log(
session,
event_type="auth.login",
user_id=user.id,
actor_id=user.id,
resource_id=None,
ip_address=_ip,
metadata_={"totp_used": user.totp_enabled and body.totp_code is not None},
)
await session.commit()
return {
"access_token": access_token,
"user": {
@@ -350,7 +386,10 @@ 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)
raw_token = request.cookies.get("refresh_token")
_logout_user_id = None
if raw_token:
token_hash = _hashlib.sha256(raw_token.encode()).hexdigest()
result = await session.execute(
@@ -358,7 +397,17 @@ async def logout(request: Request, response: Response, session: AsyncSession = D
)
row: Optional[RefreshToken] = result.scalar_one_or_none()
if row is not None:
_logout_user_id = row.user_id
row.revoked = True
# D-13: logout event (written before commit, within same transaction)
await write_audit_log(
session,
event_type="auth.logout",
user_id=_logout_user_id,
actor_id=_logout_user_id,
resource_id=None,
ip_address=_ip,
)
await session.commit()
response.delete_cookie("refresh_token", path="/api/auth/refresh")
@@ -375,7 +424,19 @@ 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)
count = await auth_service.revoke_all_refresh_tokens(session, current_user.id)
# D-13: sign-out-all event
await write_audit_log(
session,
event_type="auth.sign_out_all",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
metadata_={"sessions_revoked": count},
)
await session.commit()
response.delete_cookie("refresh_token", path="/api/auth/refresh")
return {"message": f"Signed out of {count} session(s)"}
@@ -410,6 +471,7 @@ async def get_my_quota(
@router.post("/change-password")
async def change_password(
request: Request,
body: ChangePasswordRequest,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
@@ -443,8 +505,18 @@ async def change_password(
)
# Update password
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
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)
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,
)
await session.commit()
return {"message": "Password updated"}
@@ -522,6 +594,18 @@ async def enable_totp(
plain_codes = auth_service.generate_backup_codes(10)
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)
await write_audit_log(
session,
event_type="auth.totp_enrolled",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
)
await session.commit()
return {"backup_codes": plain_codes}
@@ -529,6 +613,7 @@ async def enable_totp(
@router.delete("/totp")
async def disable_totp(
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
@@ -536,12 +621,23 @@ 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)
user = await session.get(User, current_user.id)
user.totp_enabled = False
user.totp_secret = None
# Delete all backup codes for this user (including unused ones)
await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id))
# D-13: TOTP revoked event
await write_audit_log(
session,
event_type="auth.totp_revoked",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
)
await session.commit()
return {"message": "TOTP disabled"}