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:
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user