From d68a31077a83b2215fce5b53de274676889e1d32 Mon Sep 17 00:00:00 2001 From: xRain Date: Sun, 16 Aug 2026 22:46:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A3=B0=E6=98=8E=E5=BC=8F=E9=89=B4?= =?UTF-8?q?=E6=9D=83/=E5=8E=9F=E6=A0=B7=E5=93=8D=E5=BA=94=E6=B3=A8?= =?UTF-8?q?=E8=A7=A3=20+=20=E6=9C=8D=E5=8A=A1=E7=AB=AF=E9=AA=8C=E7=AD=BE?= =?UTF-8?q?=20+=20AES=20body=20=E8=A7=A3=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @SimApiAuth:方法/类级鉴权注解(401/类型403/checker执行),对齐 C# [SimApiAuth] - @OriginResponse:跳过统一响应封装原样输出,对齐 C# [OriginResponse] - SimApiSignChecker + SimApiSignProviderBase:服务端验签(含 QueryExpires 过期与 nonce 去重) - SimApiAesBodyChecker + AesBodyProviderBase:服务端解密加密 body --- src/attributes/OriginResponse.cj | 21 ++++ src/attributes/SimApiAuth.cj | 35 ++++++ src/helpers/SimApiAesBodyChecker.cj | 136 ++++++++++++++++++++++ src/helpers/SimApiSignChecker.cj | 168 ++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 src/attributes/OriginResponse.cj create mode 100644 src/attributes/SimApiAuth.cj create mode 100644 src/helpers/SimApiAesBodyChecker.cj create mode 100644 src/helpers/SimApiSignChecker.cj diff --git a/src/attributes/OriginResponse.cj b/src/attributes/OriginResponse.cj new file mode 100644 index 0000000..7ca9be4 --- /dev/null +++ b/src/attributes/OriginResponse.cj @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.attributes + +/** + * 原样响应注解(对齐 C# SimApi.Attributes.OriginResponseAttribute)。 + * + * 标注在控制器方法或类上,请求派发时 SimApiRequestDelegateFactory 跳过 + * 统一响应封装(SimApiBaseResponse 包装),接口返回什么就输出什么。 + * + * 用法: + * @OriginResponse + * public func raw(): String { "hello" } // 直接输出 "hello",不包 {code,message,data} + */ +@Annotation[target: [MemberFunction, Type]] +public class OriginResponse { + public const init() {} +} diff --git a/src/attributes/SimApiAuth.cj b/src/attributes/SimApiAuth.cj new file mode 100644 index 0000000..222065f --- /dev/null +++ b/src/attributes/SimApiAuth.cj @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.attributes + +/** + * 声明式鉴权注解(对齐 C# SimApi.Attributes.SimApiAuthAttribute)。 + * + * 标注在控制器方法或类上,请求派发时(SimApiRequestDelegateFactory)自动执行鉴权: + * - 未登录(无 LoginInfo)→ 401 + * - type 非空且登录用户类型不匹配 → 403 + * - 遍历执行所有已注册的 ISimApiAuthChecker + * + * 用法: + * @SimApiAuth // 任意已登录用户 + * @SimApiAuth["admin"] // 仅 admin 类型 + * + * 说明:仓颉注解参数须为编译期常量,且 String 无法作为 const 值数组元素 + * (内部为 Array),故与 C# 的 string[] 不同,这里支持单个类型参数。 + */ +@Annotation[target: [MemberFunction, Type]] +public class SimApiAuth { + /// 允许访问的用户类型(空 = 任意已登录用户) + public let `type`: String + + public const init() { + this.`type` = "" + } + + public const init(`type`: String) { + this.`type` = `type` + } +} diff --git a/src/helpers/SimApiAesBodyChecker.cj b/src/helpers/SimApiAesBodyChecker.cj new file mode 100644 index 0000000..c1568d0 --- /dev/null +++ b/src/helpers/SimApiAesBodyChecker.cj @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import std.collection.* +import std.io.* +import soulsoft_serialization.* +import soulsoft_serialization.macros.* +import soulsoft_web_http.* +import simapi.communications.* +import simapi.exceptions.* + +/** + * AES body 请求({"data": "密文"},对齐 C# SimApiOneFieldRequest)。 + */ +@Serialization +public class AesBodyRequest { + public var _data: String = "" + + public init() {} +} + +/** + * AES body 密钥提供器(对齐 C# ModelBinders/AesBodyProviderBase): + * 应用继承本类并实现 getKey(appId),返回 appId 对应的 AES 密钥。 + */ +public open class AesBodyProviderBase { + /// appId 字段名(None 表示不带 appId) + public var appIdName: ?String = Some("appId") + + public init() {} + + /** + * 根据 appId 获取密钥。 + * @param appId 应用 ID(未配置 appIdName 时为 None)。 + * @return 密钥;返回 None 表示获取失败。 + */ + public open func getKey(appId: ?String): ?String { + None + } +} + +/** + * 服务端 AES body 解密校验器(对齐 C# ModelBinders/AesBodyModelBinder)。 + * + * 仓颉无 ModelBinder 机制,按项目惯例由控制器在方法开头调用: + * let jsonStr = SimApiAesBodyChecker.decryptBody(context, provider) + * let request = JsonSerializer.deserializeObject(jsonStr) + * + * 流程(与 C# 一致): + * 1. 读取 body 并反序列化为 {"data": "密文"} + * 2. 校验 Data 非空 + * 3. 提取 appId(Query/Header) + * 4. provider.getKey(appId) 获取密钥 + * 5. SimApiAesUtil.decrypt 解密得到明文 JSON 字符串 + * 返回解密后的 JSON 字符串,由控制器按目标类型反序列化。 + */ +public class SimApiAesBodyChecker { + private init() {} + + /** + * 解密请求体,返回明文 JSON 字符串。 + * @param context 当前请求上下文。 + * @param provider AES 密钥提供器。 + * @return 解密后的 JSON 字符串。 + */ + public static func decryptBody(context: HttpContext, provider: AesBodyProviderBase): String { + // 1. 读取 body + let body = readBody(context) + if (body.isEmpty()) { + SimApiError.error(code: 400, message: "请求体不能为空") + } + + // 2. 反序列化 {"data": "密文"} + let req = JsonSerializer.deserializeObject(body) + if (req._data.isEmpty()) { + SimApiError.error(code: 400, message: "请求体缺少密文Data字段") + } + + // 3. 提取 appId + var appId: ?String = None + if (let Some(name) <- provider.appIdName) { + if (!name.isEmpty()) { + appId = getParam(context, name) + if (appId == None || appId == Some("")) { + SimApiError.error(code: 400, message: "未找到${name}") + } + } + } + + // 4. 获取密钥 + let key = provider.getKey(appId) + if (key == None || key == Some("")) { + SimApiError.error(code: 400, message: "获取密钥失败(应用不存在或密钥未配置)") + } + + // 5. 解密 + let jsonStr = SimApiAesUtil.decrypt(req._data, key.getOrThrow()) + if (jsonStr.isEmpty()) { + SimApiError.error(code: 400, message: "解密失败") + } + jsonStr + } + + private static func readBody(context: HttpContext): String { + try { + context.request.enableBuffering() + var buffer = Array(4096, repeat: 0) + var sb = StringBuilder() + var read = context.request.body.read(buffer) + while (read > 0) { + sb.appendFromUtf8(buffer.slice(0, read)) + read = context.request.body.read(buffer) + } + // 重置流位置,供后续业务读取 + if (let seekable: Seekable <- context.request.body) { + seekable.seek(SeekPosition.Begin(0)) + } + sb.toString() + } catch (ex: Exception) { + SimApiError.error(code: 400, message: "读取请求体失败: ${ex.message}") + } + "" + } + + private static func getParam(context: HttpContext, name: String): ?String { + let q = context.request.query.get(name) + if (q != None && q != Some("")) { + return q + } + context.request.headers.get(name) + } +} diff --git a/src/helpers/SimApiSignChecker.cj b/src/helpers/SimApiSignChecker.cj new file mode 100644 index 0000000..838bfed --- /dev/null +++ b/src/helpers/SimApiSignChecker.cj @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2025 SimcuTeam. All rights reserved. + * 移植自 C# 项目 SimApi(E:\simcu\simapi-net),遵循 MIT 许可证。 + */ + +package simapi.helpers + +import std.convert.* +import soulsoft_web_http.* +import simapi.exceptions.* + +/** + * 签名提供器(对齐 C# ModelBinders/SimApiSignProviderBase): + * 应用继承本类并实现 getKey(appId),返回 appId 对应的密钥。 + */ +public open class SimApiSignProviderBase { + /// appId 字段名(None 表示签名中不包含 appId) + public var appIdName: ?String = Some("appId") + + /// 时间戳字段名 + public var timestampName: String = "timestamp" + + /// 随机串字段名 + public var nonceName: String = "nonce" + + /// 签名字段名 + public var signName: String = "sign" + + /// 请求过期秒数(0 表示不校验 timestamp) + public var queryExpires: Int64 = 5 + + /// 是否开启 nonce 去重(需配置缓存) + public var duplicateRequestProtection: Bool = true + + /// 参与签名的额外字段(与 appId/timestamp/nonce 一起拼入签名字符串) + public var signFields: Array = [] + + public init() {} + + /** + * 根据 appId 获取密钥。 + * @param appId 应用 ID(未配置 appIdName 时为 None)。 + * @return 密钥;返回 None 表示获取失败。 + */ + public open func getKey(appId: ?String): ?String { + None + } +} + +/** + * 服务端验签校验器(对齐 C# Attributes/SimApiSignAttribute.OnActionExecuting)。 + * + * 仓颉无声明式 ActionFilter 机制,按项目惯例(同 requireLogin)由控制器在需要验签的方法开头调用: + * SimApiSignChecker.verify(context, provider, cache) + * + * 校验流程(与 C# 完全一致): + * 1. 提取 appId(Query/Header) + * 2. provider.getKey(appId) 获取密钥 + * 3. 提取并解析 timestamp / nonce + * 4. QueryExpires 过期校验(ts > now+2 → 校准时间;ts+expires < now → 已过期) + * 5. DuplicateRequestProtection:nonce 去重(缓存 "SignQuery:{nonce}") + * 6. 拼接 SignFields + appId + timestamp + nonce + key,MD5 比对 sign + */ +public class SimApiSignChecker { + private init() {} + + /** + * 校验当前请求签名。 + * @param context 当前请求上下文。 + * @param provider 签名提供器(含字段名/过期/去重配置与密钥获取)。 + * @param cache 缓存(nonce 去重用;None 时跳过去重,保持兼容)。 + */ + public static func verify(context: HttpContext, provider: SimApiSignProviderBase, cache: ?SimApiCache): Unit { + // 1. 提取 appId + var appId: ?String = None + if (let Some(name) <- provider.appIdName) { + if (!name.isEmpty()) { + appId = getParam(context, name) + if (appId == None || appId == Some("")) { + SimApiError.error(code: 400, message: "获取${name}失败") + } + } + } + + // 2. 获取密钥 + let key = provider.getKey(appId) + if (key == None || key == Some("")) { + SimApiError.error(code: 400, message: "获取签名KEY失败") + } + let secret = key.getOrThrow() + + // 3. 提取 timestamp / nonce + let timestamp = getParam(context, provider.timestampName) + if (timestamp == None || timestamp == Some("")) { + SimApiError.error(code: 400, message: "${provider.timestampName}不能为空") + } + let nonce = getParam(context, provider.nonceName) + if (nonce == None || nonce == Some("")) { + SimApiError.error(code: 400, message: "${provider.nonceName}不能为空") + } + let tsStr = timestamp.getOrThrow() + let nonceStr = nonce.getOrThrow() + let ts = parseTimestamp(tsStr) + + // 4. 过期校验 + if (provider.queryExpires != 0) { + let now = Int64(SimApiUtil.timestampNow) + if (ts > now + 2) { + SimApiError.error(code: 400, message: "请校准本地时间") + } + if (ts + provider.queryExpires < now) { + SimApiError.error(code: 400, message: "请求已过期") + } + + // 5. nonce 去重 + if (provider.duplicateRequestProtection) { + if (let Some(cache) <- cache) { + let nonceKey = "SignQuery:${nonceStr}" + if (!cache.hasKey(nonceKey)) { + cache.set(nonceKey, tsStr, expireSeconds: provider.queryExpires + 2) + } else { + SimApiError.error(code: 400, message: "重复请求") + } + } + } + } + + // 6. 拼接签名串并比对 + var sb = StringBuilder() + for (field in provider.signFields) { + sb.append("${field}=") + if (let Some(v) <- getParam(context, field)) { + sb.append(v) + } + sb.append("&") + } + if (let Some(name) <- provider.appIdName) { + if (!name.isEmpty()) { + sb.append("${name}=${appId.getOrThrow()}&") + } + } + sb.append("${provider.timestampName}=${ts}&${provider.nonceName}=${nonceStr}&${secret}") + let expect = SimApiUtil.md5(sb.toString()) + let sign = getParam(context, provider.signName) + if (sign == None || sign != Some(expect)) { + SimApiError.error(code: 400, message: "签名错误") + } + } + + /// 从 Query 或 Header 取参数(Query 优先,对齐 C# FirstOrDefault 语义) + private static func getParam(context: HttpContext, name: String): ?String { + let q = context.request.query.get(name) + if (q != None && q != Some("")) { + return q + } + context.request.headers.get(name) + } + + /// 解析秒级时间戳 + private static func parseTimestamp(s: String): Int64 { + try { + Int64.parse(s) + } catch (ex: Exception) { + SimApiError.error(code: 400, message: "时间戳格式错误") + } + Int64.parse(s) + } +}