"""System HMAC authentication, durable replay protection and DingTalk authorization.""" import base64 import hashlib import hmac import json import re from datetime import UTC, datetime, timedelta from fastapi import Request from sqlalchemy import delete, func, select, text from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.core.exceptions import AppException from app.models.entities import IntegrationNonce, UserAccount from app.repositories.users import UserRepository from app.schemas.integration_risk import IntegrationOperator def signature_message(request: Request, timestamp: str, nonce: str, body: bytes) -> bytes: prefix = timestamp.encode("ascii") + b"\n" + nonce.encode("ascii") + b"\n" if request.method == "GET": # GET binds both the endpoint and query so a signature cannot be moved to another report. target = request.scope.get("raw_path", request.url.path.encode("ascii")) query = request.scope.get("query_string", b"") return b"GET\n" + target + b"\n" + query + b"\n" + prefix return prefix + body async def authenticate_integration(request: Request, session: AsyncSession) -> str: settings = get_settings() client_id = request.headers.get("X-Client-Id", "") timestamp = request.headers.get("X-Timestamp", "") nonce = request.headers.get("X-Nonce", "") signature = request.headers.get("X-Signature", "") if not all((client_id, timestamp, nonce, signature)): raise AppException("缺少系统签名认证信息", code=401, status_code=401) try: clients = json.loads(settings.integration_clients.get_secret_value()) if not isinstance(clients, dict) or any( not isinstance(key, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key) or not isinstance(value, str) or len(value) < 32 for key, value in clients.items() ): raise ValueError except (ValueError, TypeError) as exc: raise AppException("外部系统鉴权配置无效", code=503, status_code=503) from exc secret = clients.get(client_id) if secret is None: raise AppException("调用系统未开通接口", code=403, status_code=403) if not re.fullmatch(r"[0-9]{1,12}", timestamp) or not re.fullmatch(r"[A-Za-z0-9_-]{8,64}", nonce): raise AppException("时间戳或随机串格式错误", code=401, status_code=401) now = datetime.now(UTC) if abs(now.timestamp() - int(timestamp)) > 300: raise AppException("系统签名时间戳已过期", code=401, status_code=401) expected = base64.b64encode( hmac.new( secret.encode("utf-8"), signature_message(request, timestamp, nonce, await request.body()), hashlib.sha256 ).digest() ).decode("ascii") if not re.fullmatch(r"[A-Za-z0-9+/]{43}=", signature) or not hmac.compare_digest(signature, expected): raise AppException("系统签名校验失败", code=401, status_code=401) # Serialize only the short authentication transaction for this client across workers. lock_key = int.from_bytes(hashlib.sha256(f"integration-auth:{client_id}".encode()).digest()[:8], signed=True) await session.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}) await session.execute(delete(IntegrationNonce).where(IntegrationNonce.expires_at <= now)) replay = await session.scalar( select(IntegrationNonce.id).where( IntegrationNonce.client_id == client_id, IntegrationNonce.nonce == nonce, ) ) if replay is not None: raise AppException("随机串已使用,请重新签名", code=401, status_code=401) count = await session.scalar( select(func.count()) .select_from(IntegrationNonce) .where( IntegrationNonce.client_id == client_id, IntegrationNonce.created_at > now - timedelta(seconds=60), ) ) if count >= settings.integration_requests_per_minute: raise AppException("超过系统调用频率限制", code=429, status_code=429) await session.execute( insert(IntegrationNonce).values( client_id=client_id, nonce=nonce, created_at=now, expires_at=now + timedelta(minutes=10), ) ) # Persist nonce even if body validation or assessment later fails. await session.commit() return client_id async def authorize_operator( session: AsyncSession, operator: IntegrationOperator, client_id: str | None = None, ) -> UserAccount: if operator.external_user_id: if not client_id: raise AppException("外部账号必须关联已认证的调用系统", code=403, status_code=403) try: mappings = json.loads(get_settings().integration_operator_mappings.get_secret_value()) if not isinstance(mappings, dict) or any( not isinstance(client, str) or not isinstance(accounts, dict) or any( not isinstance(external, str) or not external or not isinstance(local, str) or not local or len(local) > 20 for external, local in accounts.items() ) for client, accounts in mappings.items() ): raise ValueError except (ValueError, TypeError) as exc: raise AppException("外部账号映射配置无效", code=503, status_code=503) from exc local_employee_no = mappings.get(client_id, {}).get(operator.external_user_id) if not local_employee_no: raise AppException("外部用户未在当前调用系统开通", code=403, status_code=403) users = list( ( await session.scalars( select(UserAccount) .where( UserAccount.emp_no == local_employee_no, ) .limit(2) ) ).all() ) else: users = list( ( await session.scalars( select(UserAccount) .where( UserAccount.dingtalk_union_id == operator.dingtalk_union_id, ) .limit(2) ) ).all() ) if len(users) != 1: kind = "外部" if operator.external_user_id else "钉钉" message = f"{kind}用户未在风险平台开通" if not users else f"{kind}用户映射不唯一,请联系管理员" raise AppException(message, code=403, status_code=403) user = users[0] if user.status != "启用": raise AppException("用户已停用", code=403, status_code=403) if operator.dingtalk_user_id and operator.dingtalk_user_id != user.dingtalk_id: raise AppException("钉钉 UnionId 与 UserId 不一致", code=403, status_code=403) if user.role != "admin": permissions = await UserRepository(session).permissions_for_role(user.role, operate=True) if not any(item.func_key == "risk.assess" for item in permissions): raise AppException("用户无风险评估权限", code=403, status_code=403) return user