refactor: consolidate login audit logging and add superadmin user audit endpoints

This commit is contained in:
Ubuntu
2026-05-08 06:26:32 +00:00
parent 6d794106be
commit 81a221bd2b
6 changed files with 303 additions and 12 deletions
+15
View File
@@ -121,6 +121,21 @@ def login():
user_session = AuthService.create_session(user, duration_seconds=duration, is_compliance_only=is_compliance_only)
# Log successful login (after MFA complete, if applicable)
login_org_id = None
if policy_result.compliance_summary and policy_result.compliance_summary.orgs:
login_org_id = policy_result.compliance_summary.orgs[0].organization_id
AuditService.log_action(
action=AuditAction.USER_LOGIN,
user_id=user.id,
organization_id=login_org_id,
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
description="User logged in (password)",
success=True,
)
response_data = {
"user": user.to_dict(),
"token": user_session.token,
+27
View File
@@ -1,4 +1,5 @@
"""TOTP authentication endpoints."""
import logging
from flask import request, session, g, current_app
from marshmallow import ValidationError
from gatehouse_app.api.v1 import api_v1_bp
@@ -12,6 +13,7 @@ from gatehouse_app.schemas.auth_schema import (
)
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
from gatehouse_app.exceptions.validation_exceptions import ConflictError
@@ -78,6 +80,21 @@ def verify_totp():
is_compliance_only = policy_result.create_compliance_only_session
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
# Log successful login (after MFA complete)
login_org_id = None
if policy_result.compliance_summary and policy_result.compliance_summary.orgs:
login_org_id = policy_result.compliance_summary.orgs[0].organization_id
AuditService.log_action(
action=AuditAction.USER_LOGIN,
user_id=user.id,
organization_id=login_org_id,
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
description="User logged in (TOTP)",
success=True,
)
session.pop("totp_pending_user_id", None)
session.pop("webauthn_pending_user_id", None)
@@ -112,6 +129,16 @@ def verify_totp():
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
except InvalidCredentialsError as e:
# Log failed TOTP verification
AuditService.log_action(
action=AuditAction.TOTP_VERIFY_FAILED,
user_id=user.id,
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
description="TOTP verification failed",
success=False,
error_message=e.message,
)
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
+26
View File
@@ -16,6 +16,7 @@ from gatehouse_app.schemas.webauthn_schema import (
from gatehouse_app.services.auth_service import AuthService
from gatehouse_app.services.webauthn_service import WebAuthnService
from gatehouse_app.services.mfa_policy_service import MfaPolicyService
from gatehouse_app.utils.constants import AuditAction
from gatehouse_app.utils.decorators import login_required
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
@@ -128,6 +129,21 @@ def complete_webauthn_login():
user_session = AuthService.create_session(user, is_compliance_only=is_compliance_only)
session.pop("webauthn_pending_user_id", None)
# Log successful login (after MFA complete)
login_org_id = None
if policy_result.compliance_summary and policy_result.compliance_summary.orgs:
login_org_id = policy_result.compliance_summary.orgs[0].organization_id
AuditService.log_action(
action=AuditAction.USER_LOGIN,
user_id=user.id,
organization_id=login_org_id,
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
description="User logged in (WebAuthn)",
success=True,
)
logger.info(f"WebAuthn login completed successfully for user: {user.email}")
response_data = {
@@ -161,6 +177,16 @@ def complete_webauthn_login():
except ValidationError as e:
return api_response(success=False, message="Validation failed", status=400, error_type="VALIDATION_ERROR", error_details=e.messages)
except InvalidCredentialsError as e:
# Log failed WebAuthn verification
AuditService.log_action(
action=AuditAction.WEBAUTHN_LOGIN_FAILED,
user_id=user.id,
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
description="WebAuthn login failed",
success=False,
error_message=e.message,
)
return api_response(success=False, message=e.message, status=e.status_code, error_type=e.error_type)
except Exception as e:
logger.exception(f"WebAuthn login complete unexpected error: {e}")
+232
View File
@@ -514,3 +514,235 @@ def remove_user_from_org(user_id, org_id):
status=500,
error_type="INTERNAL_ERROR",
)
# ============ User Audit Log Endpoints ============
@superadmin_bp.route("/users/<user_id>/audit-logs", methods=["GET"])
@superadmin_required
def get_user_audit_logs(user_id):
"""Get audit logs for a specific user (superadmin only).
Query params:
page: Page number (default 1)
per_page: Items per page (default 50, max 200)
action: Filter by action type
success: Filter by success (true/false)
start_date: Filter by start date (ISO 8601)
end_date: Filter by end date (ISO 8601)
"""
try:
from gatehouse_app.models.auth.audit_log import AuditLog
from gatehouse_app.models.user.user import User
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
page = max(1, int(request.args.get("page", 1)))
per_page = min(200, max(1, int(request.args.get("per_page", 50))))
query = AuditLog.query.filter_by(user_id=user_id)
# Filters
action_filter = request.args.get("action")
if action_filter:
query = query.filter(AuditLog.action == action_filter)
success_filter = request.args.get("success")
if success_filter is not None:
query = query.filter(AuditLog.success == (success_filter.lower() == "true"))
start_date = request.args.get("start_date")
if start_date:
from datetime import datetime, timezone
try:
start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
query = query.filter(AuditLog.created_at >= start_dt)
except ValueError:
return api_response(
success=False,
message="Invalid start_date format. Use ISO 8601.",
status=400,
error_type="VALIDATION_ERROR",
)
end_date = request.args.get("end_date")
if end_date:
from datetime import datetime, timezone
try:
end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
query = query.filter(AuditLog.created_at <= end_dt)
except ValueError:
return api_response(
success=False,
message="Invalid end_date format. Use ISO 8601.",
status=400,
error_type="VALIDATION_ERROR",
)
query = query.order_by(AuditLog.created_at.desc())
total = query.count()
logs = query.offset((page - 1) * per_page).limit(per_page).all()
def log_to_dict(log):
action = log.action
return {
"id": log.id,
"action": action.value if hasattr(action, "value") else action,
"user_id": log.user_id,
"user": (
{"id": log.user.id, "email": log.user.email, "full_name": log.user.full_name}
if log.user else None
),
"organization_id": log.organization_id,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"request_id": log.request_id,
"description": log.description,
"success": log.success,
"error_message": log.error_message,
"metadata": log.extra_data,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
return api_response(
data={
"user": {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
},
"audit_logs": [log_to_dict(log) for log in logs],
"count": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
},
message="User audit logs retrieved successfully",
)
except Exception as e:
logger.error(f"[SuperadminUsers] Get user audit logs error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
@superadmin_bp.route("/users/<user_id>/audit-logs/export", methods=["GET"])
@superadmin_required
def export_user_audit_logs(user_id):
"""Export audit logs for a specific user as CSV (superadmin only).
Query params:
action: Filter by action type
success: Filter by success (true/false)
start_date: Filter by start date (ISO 8601)
end_date: Filter by end date (ISO 8601)
"""
try:
from gatehouse_app.models.auth.audit_log import AuditLog
from gatehouse_app.models.user.user import User
import csv
from flask import make_response
from io import StringIO
user = User.query.get(user_id)
if not user or user.deleted_at is not None:
return api_response(
success=False,
message="User not found",
status=404,
error_type="NOT_FOUND",
)
query = AuditLog.query.filter_by(user_id=user_id)
# Apply same filters as get_user_audit_logs
action_filter = request.args.get("action")
if action_filter:
query = query.filter(AuditLog.action == action_filter)
success_filter = request.args.get("success")
if success_filter is not None:
query = query.filter(AuditLog.success == (success_filter.lower() == "true"))
start_date = request.args.get("start_date")
if start_date:
from datetime import datetime, timezone
try:
start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
query = query.filter(AuditLog.created_at >= start_dt)
except ValueError:
return api_response(
success=False,
message="Invalid start_date format. Use ISO 8601.",
status=400,
error_type="VALIDATION_ERROR",
)
end_date = request.args.get("end_date")
if end_date:
from datetime import datetime, timezone
try:
end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00"))
query = query.filter(AuditLog.created_at <= end_dt)
except ValueError:
return api_response(
success=False,
message="Invalid end_date format. Use ISO 8601.",
status=400,
error_type="VALIDATION_ERROR",
)
query = query.order_by(AuditLog.created_at.desc())
logs = query.all()
# Generate CSV
output = StringIO()
writer = csv.writer(output)
writer.writerow([
"id", "timestamp", "action", "user_email", "organization_id",
"resource_type", "resource_id", "ip_address", "success",
"description", "error_message"
])
for log in logs:
action = log.action
writer.writerow([
log.id,
log.created_at.isoformat() if log.created_at else "",
action.value if hasattr(action, "value") else action,
log.user.email if log.user else "",
log.organization_id or "",
log.resource_type or "",
log.resource_id or "",
log.ip_address or "",
log.success,
log.description or "",
log.error_message or "",
])
response = make_response(output.getvalue())
response.headers["Content-Type"] = "text/csv"
response.headers["Content-Disposition"] = f"attachment; filename=user_{user_id}_audit_logs.csv"
return response
except Exception as e:
logger.error(f"[SuperadminUsers] Export user audit logs error: {e}")
return api_response(
success=False,
message="An error occurred",
status=500,
error_type="INTERNAL_ERROR",
)
+1 -1
View File
@@ -214,7 +214,7 @@ class AuditService:
):
"""Log external auth login event."""
return AuditService.log_action(
action=AuditAction.EXTERNAL_AUTH_LOGIN,
action=AuditAction.USER_LOGIN,
user_id=user_id,
organization_id=organization_id,
resource_type="session",
+2 -11
View File
@@ -176,15 +176,6 @@ class AuthService:
)
session.save()
# Log session creation
AuditService.log_action(
action=AuditAction.SESSION_CREATE,
user_id=user.id,
resource_type="session",
resource_id=session.id,
description="User session created",
)
return session
@staticmethod
@@ -254,9 +245,9 @@ class AuthService:
if session:
session.revoke(reason=reason)
# Log session revocation
# Log session revocation (user logout)
AuditService.log_action(
action=AuditAction.SESSION_REVOKE,
action=AuditAction.USER_LOGOUT,
user_id=session.user_id,
resource_type="session",
resource_id=session.id,