| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146 |
- """认证审计日志构造与独立失败事务。"""
- from __future__ import annotations
- import ipaddress
- import socket
- import time
- from typing import Any
- from flask import request
- from dms.common.enums import AuditAction, AuditResult, AuditTarget
- from dms.common.request_context import get_request_id
- from dms.extensions import db
- from dms.models import AuditLog, User
- from dms.security.auth_context import get_auth_context
- def client_ip() -> str | None:
- forwarded = request.headers.get("X-Forwarded-For", "")
- value = forwarded.split(",", 1)[0].strip() if forwarded else request.remote_addr
- if not value:
- return None
- # 同机浏览器 localhost 访问时 TCP 对端为回环地址(::1/127.0.0.1),
- # 审计展示替换为本机网卡 IPv4(部署形态无入向代理;有代理时以 X-Forwarded-For 为准)
- if _is_loopback(value):
- value = _primary_local_address()
- return value[:64]
- def _is_loopback(ip: str) -> bool:
- try:
- # 去掉 IPv6 zone id;覆盖 ::1、0:0:0:0:0:0:0:1、127.* 及 v4 映射形态
- # (::ffff:a.b.c.d 的 is_loopback 随 Python 版本存在差异,显式解映射后判定)
- parsed = ipaddress.ip_address(ip.split("%", 1)[0])
- if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped is not None:
- parsed = parsed.ipv4_mapped
- return parsed.is_loopback
- except ValueError:
- return False
- # 本机网卡首选 IPv4 缓存(TTL 1 分钟,避免每次审计重复选路)
- _primary_address_cache: tuple[str, float] | None = None
- def _primary_local_address() -> str:
- """本机首选非回环 IPv4;无可用网卡时回退 127.0.0.1(兼作 ::1 规范化显示)。"""
- global _primary_address_cache
- now = time.monotonic()
- if _primary_address_cache is not None and now < _primary_address_cache[1]:
- return _primary_address_cache[0]
- try:
- # UDP connect 不实际发包,仅让内核按路由表选出站网卡(192.0.2.1 为 RFC 5737 文档地址)
- with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
- probe.connect(("192.0.2.1", 80))
- resolved = probe.getsockname()[0]
- if _is_loopback(resolved):
- resolved = "127.0.0.1"
- except OSError:
- resolved = "127.0.0.1"
- _primary_address_cache = (resolved, now + 60)
- return resolved
- def auth_audit(
- *,
- action: AuditAction,
- result: AuditResult,
- user: User | None = None,
- attempted_username: str | None = None,
- failure_reason: str | None = None,
- detail: dict[str, Any] | None = None,
- ) -> AuditLog:
- username = user.username if user is not None else attempted_username
- return AuditLog(
- user_id=user.id if user is not None else None,
- username=username,
- real_name=user.real_name if user is not None else None,
- organization_id=user.organization_id if user is not None else None,
- organization_name=user.organization_name if user is not None else None,
- action_type=action.value,
- target_type=AuditTarget.AUTH.value,
- target_id=user.id if user is not None else None,
- target_name=username,
- operation_result=result.value,
- failure_reason=failure_reason,
- client_ip=client_ip(),
- user_agent=(request.user_agent.string or "")[:500] or None,
- request_id=get_request_id(),
- operation_detail=detail,
- )
- def business_audit(
- *,
- action: AuditAction,
- target: AuditTarget,
- target_id: int,
- target_name: str,
- detail: dict[str, Any] | None = None,
- ) -> AuditLog:
- """使用请求级认证快照构造修改类业务成功审计。"""
- context = get_auth_context()
- return AuditLog(
- user_id=context.user_id,
- username=context.username,
- real_name=context.real_name,
- organization_id=context.organization_id,
- organization_name=context.organization_name,
- action_type=action.value,
- target_type=target.value,
- target_id=target_id,
- target_name=target_name,
- operation_result=AuditResult.SUCCESS.value,
- failure_reason=None,
- client_ip=client_ip(),
- user_agent=(request.user_agent.string or "")[:500] or None,
- request_id=get_request_id(),
- operation_detail=detail,
- )
- def record_login_failure(
- *,
- user: User | None,
- attempted_username: str,
- failure_reason: str,
- ) -> None:
- """在登录主流程之外用短事务持久化失败审计。"""
- db.session.rollback()
- try:
- db.session.add(
- auth_audit(
- action=AuditAction.LOGIN,
- result=AuditResult.FAILURE,
- user=user,
- attempted_username=attempted_username,
- failure_reason=failure_reason,
- detail={"username": attempted_username},
- )
- )
- db.session.commit()
- except Exception:
- db.session.rollback()
- raise
|