引言:一个老问题的新挑战
四个月前,谷雨开源 SaaS 平台(G2rain)重新出发。面对全新的开始,我们投入了大量时间进行架构设计和基础研究。作为长期深耕 Java 后端开发的团队,面临一个必然的挑战:一个 SaaS 平台必须拥有强大的前端交互能力。
这个前端不仅承载着用户界面,更承载着谷雨最核心的理念之一——应用化。我们的设想是:通过前后端彻底分离,微服务层提供平台核心能力,而应用层(理想情况下只需前端)专注于客户交互、授权管控和计费计量。这样,每个业务应用可以独立开发、部署、升级,实现真正的持续交付。
这个理念在三年前的第一版谷雨中就萌芽了。但当时一直有个困惑萦绕不去:安全性如何保证?一个纯前端应用,如何确保每个请求携带的身份令牌(token)准确无误?如何防止请求参数被篡改?这个问题我们讨论了无数次,甚至一度怀疑在 HTTPS 已经普及的今天,传统的 IAM 授权和 Session 机制是否已经足够。
直到人工智能给我们指明了一条全新的道路。
第一章:破局——从模糊理念到精准协议
一次偶然的技术讨论中,我们向 GPT 提出了我们的安全困境。在众多网络安全方案中,GPT 精准地为我们'捞出'了一个协议:DPoP(Demonstrating Proof-of-Possession)。
DPoP 的核心思想:将令牌与特定的客户端密钥对绑定。客户端在请求时不仅要提供 Access Token,还必须附加一个密码学证明,证明它持有与令牌关联的私钥。这样,即使令牌泄露,攻击者也无法使用它。
这与我们的需求完美契合!我们需要的不正是'证明此请求来自合法的前端应用本身'吗?
基于 DPoP 的理念,我们设计出了'谷雨 SaaS 平台安全交互规范':
七步安全交互的核心逻辑:
- 客户端密钥生成:每个用户会话开始时,浏览器生成唯一的 ECDSA 密钥对
- 首次身份认证:使用公钥登录 IAM,获得与公钥绑定的临时授权码
- 安全传输保障:所有关键数据都经过数字签名,防篡改、可验证
- 双重签名验证:OpenResty 网关和应用服务器共同验证请求的合法性
- 令牌精准绑定:最终颁发的 JWT 令牌与特定客户端密钥对严格绑定
这个设计确保了:即使 Token 被截获,没有对应的私钥也无法使用;即使请求被拦截,没有正确的签名也无法伪造。
第二章:攻坚——OpenResty 上的'依赖地狱'与 AI 的局限
理论很美好,但实践起来却是另一番景象。当我们在 OpenResty 网关中实现 ES256 签名验证时,遇到了意想不到的困境。
问题在于:OpenResty 的默认安装包并不包含现成的椭圆曲线加密库。我们开始了一场与 AI 工具的深度协作:
- 向 GPT 询问方案:得到的是基于
lua-resty-openssl或luaossl的通用建议 - 用 Cursor 尝试实现:生成的代码看似合理,但总在运行时报错
- Deepseek 提供思路:给出了几种不同的依赖组合方案
然而,我们很快陷入了一个'依赖怪圈':
为了解决 A,需要安装 B;安装 B 时,发现需要 C 的特定版本;编译 C 时,又需要 A 的某个功能……如此循环,无休无止。
更关键的是,我们最终锚定的那个较新的、专门为 OpenResty 优化的 Lua 加密库 luoss-rel-20250929,其文档和 API尚未被 AI 训练数据收录。这意味着:
- AI 无法理解这个库的特定设计模式
- AI 生成的代码基于过时或通用的库,无法直接使用
- 我们遇到了 AI 的'知识边界'
这是一个重要的发现:AI 的能力受限于其训练数据的时效性和覆盖面。对于前沿的、小众的、刚发布的技术文档,AI 可能一无所知。
第三章:协同——人与 AI 的正确分工
认识到 AI 的局限后,我们调整了策略,形成了全新的'人机协同'工作流:
第一步:人类负责'战略阅读'与'深度理解'
我们花了整整一个下午,仔细阅读那个新库的英文文档。虽然过程缓慢,但我们逐渐理解了:
- 库的设计哲学和核心抽象
- 密钥生成、签名、验证的 API 调用方式
- 必要的依赖关系和编译选项
- 与 OpenResty 生态集成的要点
第二步:将'理解后的知识'喂给 AI
我们把文档的关键部分、项目的上下文、以及具体要解决的问题,打包提交给 Cursor:
项目背景:我们需要在 OpenResty 的 Lua 脚本中实现 ES256 签名验证 已选库:luoss-rel-20250929 说明文档如下: pkey.new(string[, format]) Initializes a new pkey object from the PEM- or DER-encoded key in string. format defaults to "*", which means to automatically test the input encoding. If format is explicitly "PEM" or "DER", then only that decoding format is used. On failure throws an error. pkey.new{ . . . } Generates a new pkey object according to the specified parameters. field type:default description .type string:RSA public key algorithm—"RSA", "DSA", "EC", "DH", or an internal OpenSSL identifier of a subclass of one of those basic types .bits number:1024 private key size .exp number:65537 RSA exponent .generator number:2 Diffie-Hellman generator .dhparam string PEM encoded string with precomputed DH parameters .curve string:prime192v1 for elliptic curve keys, the OpenSSL string identifier of the curve The DH parameters "dhparam" will be generated on the fly, "bits" wide. This is a slow process, and especially for larger sizes, you would precompute those; for example: "openssl dhparam -2 -out dh-2048.pem -outform PEM 2048". Using the field "dhparam" overrides the "bits" field. 具体需求:实现一个 Lua 模块,实现密钥的生成,存储,签名和验证的方法,供 sign_api.lua 调用
第三步:AI 的精准助攻
这一次,Cursor 的表现完全不同了。它基于我们提供的准确信息,结合对整个项目代码结构的理解,生成了以下代码:
sign.lua
local cjson = require "cjson.safe"
local openssl = require "openssl"
local pkey_lib = require "openssl.pkey"
local digest_lib = require "openssl.digest"
local b64 = require "ngx.base64"
local _M = {}
-- Base64URL helper (binary input)
local function b64url(bin)
if not bin then return nil end
local s = ngx.encode_base64(bin)
return s:gsub('+','-'):gsub('/','_'):gsub('=','')
end
-- sha256 (returns binary)
local function sha256_bin(data)
local d = digest_lib.new("sha256")
d:update(data or "")
return d:final()
end
-- 解析 DER 长度
local
b = data:(i)
b ,
b < b, i +
n = b -
n <= n > ,
=
k = , n
bb = data:(i + k)
bb ,
= * + bb
, i + n +
der #der < ,
pos =
der:(pos) ~= ,
top_len, next_pos = parse_len(der, pos + )
next_pos , top_len
pos = next_pos
der:(pos) ~= ,
alg_len, alg_next = parse_len(der, pos + )
alg_next , alg_len
pos = alg_next + alg_len -
der:(pos + ) ~= ,
bit_len, bit_next = parse_len(der, pos + )
bit_next , bit_len
unused = der:(bit_next)
unused ~= ,
point_start = bit_next +
point = der:(point_start, point_start + bit_len - )
point #point < ,
point:() ~= ,
x = point:(, )
y = point:(, )
#x ~= #y ~= ,
x, y
der #der < ,
pos =
der:(pos) ~= ,
pos = pos +
= der:(pos); pos = pos +
>=
n = -
=
i = , n
= * + der:(pos); pos = pos +
der:(pos) ~= ,
pos = pos +
rlen = der:(pos); pos = pos +
der:(pos) == pos = pos + ; rlen = rlen -
r = der:(pos, pos + rlen - )
pos = pos + rlen
der:(pos) ~= ,
pos = pos +
slen = der:(pos); pos = pos +
der:(pos) == pos = pos + ; slen = slen -
s = der:(pos, pos + slen - )
#v < .(, - #v) .. v
#v > v:(#v - )
v
to32(r) .. to32(s)
der ngx.(ngx.ERR, ) ,
ngx.(ngx.ERR, )
ok, key = (
pkey = pkey_lib.new()
pkey:setPublicKey(der, )
pkey
)
ok key
ngx.(ngx.ERR, , (key))
, .. (key)
ngx.(ngx.ERR, )
x, y = extract_ec_point_from_spki(der)
x y
ngx.(ngx.ERR, )
,
ngx.(ngx.ERR, )
{ kty = , crv = , x = b64url(x), y = b64url(y) }
private_der ,
ngx.(ngx.ERR, )
ok, pkey_obj = (
pkey = pkey_lib.new()
pkey:setPrivateKey(private_der, )
pkey
)
ok pkey_obj
ngx.(ngx.ERR, , (pkey_obj))
, .. (pkey_obj)
ngx.(ngx.ERR, )
hash = pkey_obj:getDefaultDigestName()
ngx.(ngx.ERR, , (hash))
md_ctx = digest_lib.new(hash)
md_ctx:update(signing_input)
sig = pkey_obj:sign(md_ctx)
sig
ngx.(ngx.ERR, , (sig))
, .. (sig)
ngx.(ngx.ERR, )
.(sig, ) ==
rs, err = der_to_rs(sig)
rs , .. (err)
sig = rs
b64url(sig)
body = body
b64url(sha256_bin(body))
b64url(cjson.encode(obj))
private_der = private_der
public_der = public_der
ngx.(ngx.ERR, )
jwk, err = der_to_jwk(public_der)
jwk
ngx.(ngx.ERR, , err)
, .. (err)
ngx.(ngx.ERR, , cjson.encode(jwk))
kid = key_id compute_kid(jwk)
header = { typ = , alg = , ph_alg = , jwk = jwk, kid = kid }
h = encode_json_b64url(header)
p = encode_json_b64url(payload)
signing_input = h .. .. p
ngx.(ngx.ERR, , signing_input)
signature, serr = sign_es256(private_der, signing_input)
signature
ngx.(ngx.ERR, , serr)
, serr
signing_input .. .. signature
=
key = .get_active_key()
key ,
_M.generate_jwt(
payload,
key[],
key[],
key[]
)
_M
config.lua
-- config.lua -- 从文件加载密钥配置信息
local _M = {}
-- 密钥文件路径(相对于 lua 目录)
local KEY_BASE_PATH = "/usr/local/openresty/nginx/lua/keys"
local PUBLIC_KEY_FILE = KEY_BASE_PATH .. "/public-key.der" -- 修改为 DER 格式
local PRIVATE_KEY_FILE = KEY_BASE_PATH .. "/private-key.der" -- 修改为 DER 格式
-- 读取文件内容
local function read_file(file_path)
local file, err = io.open(file_path, "rb") -- 以二进制模式读取文件
if not file then
ngx.log(ngx.ERR, "Failed to open file: ", file_path, " Error: ", err)
return nil
end
local content = file:read("*all")
file:close()
if content then
ngx.log(ngx.ERR, "Successfully read file: ", file_path)
else
ngx.log(ngx.ERR, "Failed to read content from file: ", file_path)
end
return content
end
-- 加载密钥配置
local function load_keys
public_key = read_file(PUBLIC_KEY_FILE)
private_key = read_file(PRIVATE_KEY_FILE)
public_key private_key
ngx.(ngx.ERR, )
application_code =
ok, env_value = (
.()
)
ok env_value
application_code = env_value
{
{
[] = ,
algorithm = ,
active = ,
applicationCode = application_code,
[] = public_key,
[] = private_key
}
}
cached_keys =
cached_keys
cached_keys = load_keys()
cached_keys
ngx.(ngx.ERR, )
_, key (cached_keys)
key.active key
ngx.(ngx.ERR, )
cached_keys =
_M.get_active_key() ~=
public_key = _M.get_active_key()[]
public_key
ngx.(ngx.ERR, )
ngx.(ngx.ERR, )
public_key
private_key = _M.get_active_key()[]
private_key
ngx.(ngx.ERR, )
ngx.(ngx.ERR, )
private_key
_M
sign_api.lua
-- sign_api.lua -- 接收 JSON 入参,使用 ES256 算法生成 JWT 签名
local cjson = require "cjson"
local config = require "config"
local sign = require "sign"
-- 主处理函数
local function handle_request()
-- 设置响应头
ngx.header.content_type = "application/json; charset=utf-8"
-- 只接受 POST 请求
if ngx.var.request_method ~= "POST" then
ngx.status = ngx.HTTP_METHOD_NOT_ALLOWED
ngx.say(cjson.encode({ error = "Method not allowed", message = "Only POST method is supported" }))
return
end
-- 获取 URL 中的 jti 参数
local jti = ngx.var.arg_jti
if not jti or jti == "" then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say(cjson.encode({ error = "Bad request", message = "Missing or invalid 'jti' parameter" }))
ngx.log(ngx.ERR, "Missing or invalid 'jti' parameter")
return
end
-- 读取请求体
ngx.req.read_body()
local body = ngx.req.get_body_data()
if not body body ==
ngx. = ngx.HTTP_BAD_REQUEST
ngx.say(cjson.encode({ = , message = }))
args, err = cjson.decode(body)
args
ngx. = ngx.HTTP_BAD_REQUEST
ngx.say(cjson.encode({ = , message = .. (err ) }))
args.grantType args.code
ngx. = ngx.HTTP_BAD_REQUEST
ngx.say(cjson.encode({ = , message = }))
ngx.(ngx.ERR, .. (args.grantType) .. .. (args.code))
key_config = .get_active_key()
key_config
ngx. = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.say(cjson.encode({ = , message = }))
ngx.(ngx.ERR, )
pha = sign.calculate_pha(body)
current_time = ngx.()
payload = {
htu = ,
htm = ,
acd = key_config.applicationCode,
pha = pha,
jti = jti,
iat = current_time,
= current_time +
}
jwt, err = sign.generate_jwt(payload, key_config[], key_config[], key_config[])
jwt
ngx.(ngx.ERR, , err)
ngx. = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.say(cjson.encode({ = , message = .. (err ) }))
ngx. = ngx.HTTP_OK
ngx.header[] =
ngx.header[] =
ngx.header[] =
ngx.say(cjson.encode({ token = jwt }))
ok, err = (handle_request)
ok
ngx.(ngx.ERR, , err)
ngx. = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.header.content_type =
ngx.say(cjson.encode({ = , message = }))
这个代码不仅语法正确,而且:
- 完全符合新库的 API 规范
- 错误处理完整
- 与项目现有的 Lua 模块风格一致
- 考虑了 OpenResty 的特殊环境
总结与思考:我们的 AI 开发哲学
经过这次实践,我们形成了清晰的'人机协作'开发哲学:
AI 的三大核心价值
- 创意激发与方案探索:在初期探索阶段,AI 能快速提供多种思路(如发现 DPoP 协议)
- 代码生成与模板创建:对于通用模式、重复性代码,AI 能极大提升效率
- 问题诊断与调试辅助:遇到错误时,AI 能提供可能的解决方案和调试方向
人类不可替代的三大角色
- 架构师与决策者:在技术选型、方案设计等关键决策上,人类的专业判断无可替代
- 前沿知识的学习者:对于 AI 尚未掌握的新技术、新文档,人类必须亲自阅读和理解
- 质量守门员:对 AI 生成的所有代码,必须进行阅读,审查,测试。
给技术团队的实用建议
- 明确分工:让 AI 做它擅长的事(生成、搜索、建议),让人做 AI 不擅长的事(理解、决策、判断)
- 验证一切:对 AI 输出的任何方案,都要用批判性思维验证,尤其是在安全领域
- 持续学习:AI 工具在进化,我们的使用方式也需要不断优化。建立适合自己的 AI 协作流程
展望:更开放、更安全的 SaaS 未来
如今,谷雨 SaaS 平台的 DPoP 安全架构已经贯通。这套方案不仅解决了前端应用化的安全难题,更为平台的开放生态奠定了基础:
- 第三方开发者可以基于这套安全协议,开发合规的前端应用
- 企业客户可以放心地将敏感业务部署在平台上
- 持续交付真正成为可能,每个应用可以独立更新、部署
更令人兴奋的是,我们找到了一条高效的人机协作路径。在这个过程中,AI 不是替代者,而是真正的'搭档'——它放大了我们的能力边界,让我们能专注于更高层次的设计和决策。
开源 SaaS 平台的未来,是开放的、安全的、智能的。在谷雨平台的下一阶段,我们将继续探索 AI 在测试生成、文档编写、性能优化等更多场景的应用。同时,我们也期待与更多开发者一起,共同探索 AI 时代的新型开发模式。


