audit_service.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. """认证审计日志构造与独立失败事务。"""
  2. from __future__ import annotations
  3. import ipaddress
  4. import socket
  5. import time
  6. from typing import Any
  7. from flask import request
  8. from dms.common.enums import AuditAction, AuditResult, AuditTarget
  9. from dms.common.request_context import get_request_id
  10. from dms.extensions import db
  11. from dms.models import AuditLog, User
  12. from dms.security.auth_context import get_auth_context
  13. def client_ip() -> str | None:
  14. forwarded = request.headers.get("X-Forwarded-For", "")
  15. value = forwarded.split(",", 1)[0].strip() if forwarded else request.remote_addr
  16. if not value:
  17. return None
  18. # 同机浏览器 localhost 访问时 TCP 对端为回环地址(::1/127.0.0.1),
  19. # 审计展示替换为本机网卡 IPv4(部署形态无入向代理;有代理时以 X-Forwarded-For 为准)
  20. if _is_loopback(value):
  21. value = _primary_local_address()
  22. return value[:64]
  23. def _is_loopback(ip: str) -> bool:
  24. try:
  25. # 去掉 IPv6 zone id;覆盖 ::1、0:0:0:0:0:0:0:1、127.* 及 v4 映射形态
  26. # (::ffff:a.b.c.d 的 is_loopback 随 Python 版本存在差异,显式解映射后判定)
  27. parsed = ipaddress.ip_address(ip.split("%", 1)[0])
  28. if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped is not None:
  29. parsed = parsed.ipv4_mapped
  30. return parsed.is_loopback
  31. except ValueError:
  32. return False
  33. # 本机网卡首选 IPv4 缓存(TTL 1 分钟,避免每次审计重复选路)
  34. _primary_address_cache: tuple[str, float] | None = None
  35. def _primary_local_address() -> str:
  36. """本机首选非回环 IPv4;无可用网卡时回退 127.0.0.1(兼作 ::1 规范化显示)。"""
  37. global _primary_address_cache
  38. now = time.monotonic()
  39. if _primary_address_cache is not None and now < _primary_address_cache[1]:
  40. return _primary_address_cache[0]
  41. try:
  42. # UDP connect 不实际发包,仅让内核按路由表选出站网卡(192.0.2.1 为 RFC 5737 文档地址)
  43. with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
  44. probe.connect(("192.0.2.1", 80))
  45. resolved = probe.getsockname()[0]
  46. if _is_loopback(resolved):
  47. resolved = "127.0.0.1"
  48. except OSError:
  49. resolved = "127.0.0.1"
  50. _primary_address_cache = (resolved, now + 60)
  51. return resolved
  52. def auth_audit(
  53. *,
  54. action: AuditAction,
  55. result: AuditResult,
  56. user: User | None = None,
  57. attempted_username: str | None = None,
  58. failure_reason: str | None = None,
  59. detail: dict[str, Any] | None = None,
  60. ) -> AuditLog:
  61. username = user.username if user is not None else attempted_username
  62. return AuditLog(
  63. user_id=user.id if user is not None else None,
  64. username=username,
  65. real_name=user.real_name if user is not None else None,
  66. organization_id=user.organization_id if user is not None else None,
  67. organization_name=user.organization_name if user is not None else None,
  68. action_type=action.value,
  69. target_type=AuditTarget.AUTH.value,
  70. target_id=user.id if user is not None else None,
  71. target_name=username,
  72. operation_result=result.value,
  73. failure_reason=failure_reason,
  74. client_ip=client_ip(),
  75. user_agent=(request.user_agent.string or "")[:500] or None,
  76. request_id=get_request_id(),
  77. operation_detail=detail,
  78. )
  79. def business_audit(
  80. *,
  81. action: AuditAction,
  82. target: AuditTarget,
  83. target_id: int,
  84. target_name: str,
  85. detail: dict[str, Any] | None = None,
  86. ) -> AuditLog:
  87. """使用请求级认证快照构造修改类业务成功审计。"""
  88. context = get_auth_context()
  89. return AuditLog(
  90. user_id=context.user_id,
  91. username=context.username,
  92. real_name=context.real_name,
  93. organization_id=context.organization_id,
  94. organization_name=context.organization_name,
  95. action_type=action.value,
  96. target_type=target.value,
  97. target_id=target_id,
  98. target_name=target_name,
  99. operation_result=AuditResult.SUCCESS.value,
  100. failure_reason=None,
  101. client_ip=client_ip(),
  102. user_agent=(request.user_agent.string or "")[:500] or None,
  103. request_id=get_request_id(),
  104. operation_detail=detail,
  105. )
  106. def record_login_failure(
  107. *,
  108. user: User | None,
  109. attempted_username: str,
  110. failure_reason: str,
  111. ) -> None:
  112. """在登录主流程之外用短事务持久化失败审计。"""
  113. db.session.rollback()
  114. try:
  115. db.session.add(
  116. auth_audit(
  117. action=AuditAction.LOGIN,
  118. result=AuditResult.FAILURE,
  119. user=user,
  120. attempted_username=attempted_username,
  121. failure_reason=failure_reason,
  122. detail={"username": attempted_username},
  123. )
  124. )
  125. db.session.commit()
  126. except Exception:
  127. db.session.rollback()
  128. raise