diff --git a/backend/api/auth.py b/backend/api/auth.py index 16886e9..284284b 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -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"} diff --git a/backend/api/documents.py b/backend/api/documents.py index 69077cf..514e6f1 100644 --- a/backend/api/documents.py +++ b/backend/api/documents.py @@ -30,6 +30,7 @@ from db.models import Document, Quota, Share, User from deps.auth import get_regular_user from deps.db import get_db from services import classifier, storage +from services.audit import write_audit_log from storage import get_storage_backend from tasks.document_tasks import extract_and_classify @@ -95,6 +96,7 @@ async def request_upload_url( @router.post("/{doc_id}/confirm") async def confirm_upload( doc_id: str, + request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ): @@ -173,6 +175,17 @@ async def confirm_upload( used_bytes = row.used_bytes doc.status = "uploaded" + # D-13: document uploaded event — size_bytes + storage_backend only, NO filename, NO extracted_text (T-04-07-02) + _ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None) + await write_audit_log( + session, + event_type="document.uploaded", + user_id=current_user.id, + actor_id=current_user.id, + resource_id=doc.id, + ip_address=_ip, + metadata_={"size_bytes": size, "storage_backend": "minio"}, + ) await session.commit() extract_and_classify.delay(str(doc.id)) @@ -340,6 +353,7 @@ async def get_document( @router.delete("/{doc_id}") async def delete_document( doc_id: str, + request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ): @@ -360,9 +374,27 @@ async def delete_document( if doc is None or doc.user_id != current_user.id: raise HTTPException(404, "Document not found") + # Capture audit metadata before delete removes the row + _doc_size = doc.size_bytes + _doc_id = doc.id + _ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None) + ok = await storage.delete_document(session, doc_id) if not ok: raise HTTPException(404, "Document not found") + + # D-13: document deleted event — written AFTER successful delete, size_bytes only (T-04-07-02) + await write_audit_log( + session, + event_type="document.deleted", + user_id=current_user.id, + actor_id=current_user.id, + resource_id=_doc_id, + ip_address=_ip, + metadata_={"size_bytes": _doc_size}, + ) + await session.commit() + return {"success": True}