169 lines
7.3 KiB
Python
169 lines
7.3 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.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
|