feat: 声明式鉴权/原样响应注解 + 服务端验签 + AES body 解密
- @SimApiAuth:方法/类级鉴权注解(401/类型403/checker执行),对齐 C# [SimApiAuth] - @OriginResponse:跳过统一响应封装原样输出,对齐 C# [OriginResponse] - SimApiSignChecker + SimApiSignProviderBase:服务端验签(含 QueryExpires 过期与 nonce 去重) - SimApiAesBodyChecker + AesBodyProviderBase:服务端解密加密 body
This commit is contained in:
@@ -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() {}
|
||||
}
|
||||
@@ -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<UInt8>),故与 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`
|
||||
}
|
||||
}
|
||||
@@ -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<string>)。
|
||||
*/
|
||||
@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<XxxRequest>(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<AesBodyRequest>(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<Byte>(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)
|
||||
}
|
||||
}
|
||||
@@ -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<String> = []
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user