174 lines
7.6 KiB
Python

"""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.url.path == "/api/v1/integrations/risk-assessments/follow-up":
method = request.method.encode("ascii")
target = request.scope.get("raw_path", request.url.path.encode("ascii"))
query = request.scope.get("query_string", b"")
return method + b"\n" + target + b"\n" + query + b"\n" + prefix + body
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