当AI成为我的编程“搭档”:一个开源SaaS平台的前端安全架构攻坚记
本文分享了谷雨SaaS平台在实现前端应用化安全架构时的人机协作实践。团队通过AI发现了DPoP协议,设计出七步安全交互规范,但在OpenResty实现ES256签名时遇到"依赖地狱"问题,暴露出AI对新库认知的局限性。通过调整策略,形成人机协同工作流:人类负责深度理解文档,AI基于准确信息生成代码,最终实现了安全可靠的签名验证模块。文章总结了AI的三大价值(创意激发、代码生成、

从DPoP协议的理论到OpenResty的ES256签名实践,我们如何与GPT、Cursor并肩作战
引言:一个老问题的新挑战
四个月前,谷雨开源SaaS平台(G2rain)重新出发。面对全新的开始,我和搭档Alpha投入了大量时间进行架构设计和基础研究。作为长期深耕Java后端开发的我们,面临一个必然的挑战:一个SaaS平台必须拥有强大的前端交互能力。
这个前端不仅承载着用户界面,更承载着谷雨最核心的理念之一——应用化。我们的设想是:通过前后端彻底分离,微服务层提供平台核心能力,而应用层(理想情况下只需前端)专注于客户交互、授权管控和计费计量。这样,每个业务应用可以独立开发、部署、升级,实现真正的持续交付。
这个理念在三年前的第一版谷雨中就萌芽了。但当时一直有个困惑萦绕不去:安全性如何保证?一个纯前端应用,如何确保每个请求携带的身份令牌(token)准确无误?如何防止请求参数被篡改?这个问题我和Alpha讨论了无数次,甚至一度怀疑在HTTPS已经普及的今天,传统的IAM授权和Session机制是否已经足够。
直到人工智能给我们指明了一条全新的道路。
第一章:破局——从模糊理念到精准协议
一次偶然的技术讨论中,Alpha向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:
text
项目背景:我们需要在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 function parse_len(data, i)
local b = data:byte(i)
if not b then return nil, "short input in parse_len" end
if b < 0x80 then
return b, i + 1
end
local n = b - 0x80
if n <= 0 or n > 4 then return nil, "unsupported length bytes" end
local len = 0
for k = 1, n do
local bb = data:byte(i + k)
if not bb then return nil, "short input in parse_len body" end
len = len * 256 + bb
end
return len, i + n + 1
end
-- 从SPKI DER格式中提取EC点(X,Y)
local function extract_ec_point_from_spki(der)
if not der or #der < 1 then return nil, "empty der" end
-- 确认开头是SEQUENCE标志 (0x30)
local pos = 1
if der:byte(pos) ~= 0x30 then return nil, "not SEQUENCE" end
-- 解析SEQUENCE长度
local top_len, next_pos = parse_len(der, pos + 1)
if not next_pos then return nil, top_len end
pos = next_pos
-- 确认第二个SEQUENCE标志 (0x30)
if der:byte(pos) ~= 0x30 then return nil, "AlgorithmIdentifier not SEQUENCE" end
-- 解析算法标识符的长度
local alg_len, alg_next = parse_len(der, pos + 1)
if not alg_next then return nil, alg_len end
pos = alg_next + alg_len - 1
-- 确认BIT STRING标志 (0x03)
if der:byte(pos + 1) ~= 0x03 then return nil, "BIT STRING not found" end
-- 解析BIT STRING长度
local bit_len, bit_next = parse_len(der, pos + 2)
if not bit_next then return nil, bit_len end
local unused = der:byte(bit_next)
if unused ~= 0 then return nil, "unsupported unused bits" end
-- 提取EC点数据
local point_start = bit_next + 1
local point = der:sub(point_start, point_start + bit_len - 2)
if not point or #point < 1 then return nil, "empty EC point" end
-- 确认是未压缩的EC点 (0x04)
if point:byte(1) ~= 0x04 then return nil, "not uncompressed EC point" end
-- 提取X和Y坐标
local x = point:sub(2, 33)
local y = point:sub(34, 65)
-- 校验X和Y的长度是否正确
if #x ~= 32 or #y ~= 32 then return nil, "unexpected coordinate length" end
return x, y
end
-- Convert DER ECDSA signature -> raw R||S (64 bytes)
local function der_to_rs(der)
if not der or #der < 8 then return nil, "der too short" end
local pos = 1
if der:byte(pos) ~= 0x30 then return nil, "not DER SEQUENCE" end
pos = pos + 1
local len = der:byte(pos); pos = pos + 1
if len >= 0x80 then
local n = len - 0x80
len = 0
for i = 1, n do
len = len * 256 + der:byte(pos); pos = pos + 1
end
end
if der:byte(pos) ~= 0x02 then return nil, "no R" end
pos = pos + 1
local rlen = der:byte(pos); pos = pos + 1
if der:byte(pos) == 0x00 then pos = pos + 1; rlen = rlen - 1 end
local r = der:sub(pos, pos + rlen - 1)
pos = pos + rlen
if der:byte(pos) ~= 0x02 then return nil, "no S" end
pos = pos + 1
local slen = der:byte(pos); pos = pos + 1
if der:byte(pos) == 0x00 then pos = pos + 1; slen = slen - 1 end
local s = der:sub(pos, pos + slen - 1)
local function to32(v)
if #v < 32 then return string.rep("\0", 32 - #v) .. v end
if #v > 32 then return v:sub(#v - 31) end
return v
end
return to32(r) .. to32(s)
end
-- Generate JWK from public DER
local function der_to_jwk(der)
if not der then
ngx.log(ngx.ERR, "no public der")
return nil, "no public der"
end
ngx.log(ngx.ERR, "Attempting to load public key from DER")
-- try to load pkey from DER using setPublicKey
local ok, key = pcall(function()
local pkey = pkey_lib.new()
pkey:setPublicKey(der, "DER") -- Correct method to set the public key
return pkey
end)
if not ok or not key then
ngx.log(ngx.ERR, "Failed to load public key. Error: ", tostring(key))
return nil, "failed to load public key: " .. tostring(key)
end
ngx.log(ngx.ERR, "Successfully loaded public key")
-- try extracting EC point from the DER
local x, y = extract_ec_point_from_spki(der)
if not x or not y then
ngx.log(ngx.ERR, "Failed to extract EC point from SPKI DER")
return nil, "failed to extract EC point"
end
ngx.log(ngx.ERR, "Successfully extracted EC point from DER")
return {
kty = "EC",
crv = "P-256",
x = b64url(x),
y = b64url(y)
}
end
-- ES256 sign: returns base64url(R||S)
local function sign_es256(private_der, signing_input)
if not private_der then return nil, "no private der" end
ngx.log(ngx.ERR, "Loading private key from DER")
-- load private key from DER using setPrivateKey
local ok, pkey_obj = pcall(function()
local pkey = pkey_lib.new()
pkey:setPrivateKey(private_der, "DER") -- Correct method to set the private key
return pkey
end)
if not ok or not pkey_obj then
ngx.log(ngx.ERR, "Failed to load private key: ", tostring(pkey_obj))
return nil, "failed to load private key: " .. tostring(pkey_obj)
end
ngx.log(ngx.ERR, "Successfully loaded private key")
local hash = pkey_obj:getDefaultDigestName()
ngx.log(ngx.ERR, "DefaultDigestName: ", tostring(hash))
local md_ctx = digest_lib.new(hash) -- Initialize the SHA256 context
md_ctx:update(signing_input) -- Update the context with the data to sign
local sig = pkey_obj:sign(md_ctx)
if not sig then
ngx.log(ngx.ERR, "Failed to sign with private key: ", tostring(sig))
return nil, "sign failed: " .. tostring(sig)
end
ngx.log(ngx.ERR, "Successfully generated signature")
if string.byte(sig, 1) == 0x30 then
local rs, err = der_to_rs(sig)
if not rs then return nil, "der_to_rs failed: " .. tostring(err) end
sig = rs
end
return b64url(sig)
end
-- calculate pha = base64url(SHA256(body))
function _M.calculate_pha(body)
body = body or ""
return b64url(sha256_bin(body))
end
local function encode_json_b64url(obj)
return b64url(cjson.encode(obj))
end
-- generate DPoP JWT
function _M.generate_jwt(payload, private_der, public_der, key_id)
private_der = private_der or ""
public_der = public_der or ""
ngx.log(ngx.ERR, "Generating JWT...")
local jwk, err = der_to_jwk(public_der)
if not jwk then
ngx.log(ngx.ERR, "der_to_jwk failed: ", err)
return nil, "der_to_jwk failed: " .. tostring(err)
end
ngx.log(ngx.ERR, "JWK: ", cjson.encode(jwk))
local kid = key_id or compute_kid(jwk)
local header = {
typ = "dpop+jwt",
alg = "ES256",
ph_alg = "SHA-256",
jwk = jwk,
kid = kid
}
local h = encode_json_b64url(header)
local p = encode_json_b64url(payload)
local signing_input = h .. "." .. p
ngx.log(ngx.ERR, "Signing input: ", signing_input)
local signature, serr = sign_es256(private_der, signing_input)
if not signature then
ngx.log(ngx.ERR, "Failed to sign JWT: ", serr)
return nil, serr
end
return signing_input .. "." .. signature
end
-- generate with config active key
function _M.generate_jwt_with_config(payload)
local config = require "config"
local key = config.get_active_key()
if not key then return nil, "no active key" end
return _M.generate_jwt(
payload,
key["private-key"],
key["public-key"],
key["key-id"]
)
end
return _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()
local public_key = read_file(PUBLIC_KEY_FILE)
local private_key = read_file(PRIVATE_KEY_FILE)
if not public_key or not private_key then
ngx.log(ngx.ERR, "Failed to load keys from files")
return nil
end
-- 从环境变量读取 applicationCode,如果没有则使用默认值
-- 注意:在 OpenResty 中,os.getenv 可能不可用,可以通过配置文件或 nginx 变量提供
local application_code = "g2rain-main-shell"
local ok, env_value = pcall(function()
return os.getenv("APPLICATION_CODE")
end)
if ok and env_value then
application_code = env_value
end
-- 密钥配置(从 application.yml 获取的其他信息)
return {
{
["key-id"] = "yEMzeGLlhMpK5GxQKP5Fhg7JH9eALB7BK2BkadTOUxw",
algorithm = "ES256",
active = true,
applicationCode = application_code,
["public-key"] = public_key,
["private-key"] = private_key
}
}
end
-- 缓存密钥配置(避免每次调用都读取文件)
local cached_keys = nil
-- 获取活动的密钥
function _M.get_active_key()
-- 如果缓存不存在,加载密钥
if not cached_keys then
cached_keys = load_keys()
if not cached_keys then
ngx.log(ngx.ERR, "Failed to load keys from files.")
return nil
end
end
-- 查找活动的密钥
for _, key in ipairs(cached_keys) do
if key.active then
return key
end
end
ngx.log(ngx.ERR, "No active key found.")
return nil
end
-- 重新加载密钥(用于密钥轮换)
function _M.reload_keys()
cached_keys = nil
return _M.get_active_key() ~= nil
end
-- 获取 DER 格式的公钥
function _M.get_public_key_der()
local public_key = _M.get_active_key()["public-key"]
if not public_key then
ngx.log(ngx.ERR, "No public key found in active key configuration.")
return nil
end
ngx.log(ngx.ERR, "Returning public key (DER format).")
return public_key
end
-- 获取 DER 格式的私钥
function _M.get_private_key_der()
local private_key = _M.get_active_key()["private-key"]
if not private_key then
ngx.log(ngx.ERR, "No private key found in active key configuration.")
return nil
end
ngx.log(ngx.ERR, "Returning private key (DER format).")
return private_key
end
return _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 or body == "" then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say(cjson.encode({
error = "Bad request",
message = "Request body is required"
}))
return
end
-- 解析 JSON
local args, err = cjson.decode(body)
if not args then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say(cjson.encode({
error = "Bad request",
message = "Invalid JSON: " .. (err or "unknown error")
}))
return
end
-- 验证必要参数
if not args.grantType or not args.code then
ngx.status = ngx.HTTP_BAD_REQUEST
ngx.say(cjson.encode({
error = "Bad request",
message = "Missing required parameters: grantType and code"
}))
ngx.log(ngx.ERR, "Missing parameters: grantType=" .. tostring(args.grantType) .. ", code=" .. tostring(args.code))
return
end
-- 获取活动的密钥配置
local key_config = config.get_active_key()
if not key_config then
ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.say(cjson.encode({
error = "Internal server error",
message = "No active key found in configuration"
}))
ngx.log(ngx.ERR, "Failed to load active key configuration")
return
end
-- 计算 pha (Payload Hash Algorithm)
local pha = sign.calculate_pha(body)
-- 构建 JWT Payload(参考 jwt.util.ts 的 DpopPayload 格式)
local current_time = ngx.time()
local payload = {
htu = "/auth/token", -- HTTP URI
htm = "POST", -- HTTP Method
acd = key_config.applicationCode, -- Application Code
pha = pha, -- Payload Hash Algorithm
jti = jti, -- 请求id
iat = current_time, -- 签发时间(issued at),对应 setIssuedAt()
exp = current_time + 300 -- 过期时间(5分钟后),对应 setExpirationTime('5m')
}
-- 生成 JWT
local jwt, err = sign.generate_jwt(payload, key_config["private-key"], key_config["public-key"], key_config["key-id"])
if not jwt then
ngx.log(ngx.ERR, "Failed to generate JWT: ", err) -- 添加日志
ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.say(cjson.encode({
error = "Internal server error",
message = "Failed to generate JWT: " .. (err or "unknown error")
}))
return
end
-- 返回结果
ngx.status = ngx.HTTP_OK
ngx.header["Access-Control-Allow-Origin"] = "*"
ngx.header["Access-Control-Allow-Methods"] = "POST"
ngx.header["Access-Control-Allow-Headers"] = "Content-Type"
ngx.say(cjson.encode({
token = jwt
}))
end
-- 执行主处理函数
local ok, err = pcall(handle_request)
if not ok then
ngx.log(ngx.ERR, "Error in sign_api.lua: ", err)
ngx.status = ngx.HTTP_INTERNAL_SERVER_ERROR
ngx.header.content_type = "application/json"
ngx.say(cjson.encode({
error = "Internal server error",
message = "An unexpected error occurred"
}))
end
这个代码不仅语法正确,而且:
- 完全符合新库的API规范
- 错误处理完整
- 与项目现有的Lua模块风格一致
- 考虑了OpenResty的特殊环境
总结与思考:我们的AI开发哲学
经过这次实践,我们形成了清晰的“人机协作”开发哲学:
AI的三大核心价值
- 创意激发与方案探索:在初期探索阶段,AI能快速提供多种思路(如发现DPoP协议)
- 代码生成与模板创建:对于通用模式、重复性代码,AI能极大提升效率
- 问题诊断与调试辅助:遇到错误时,AI能提供可能的解决方案和调试方向
人类不可替代的三大角色
- 架构师与决策者:在技术选型、方案设计等关键决策上,人类的专业判断无可替代
- 前沿知识的学习者:对于AI尚未掌握的新技术、新文档,人类必须亲自阅读和理解
- 质量守门员:对AI生成的所有代码,必须进行阅读,审查,测试。
给技术团队的实用建议
- 明确分工:让AI做它擅长的事(生成、搜索、建议),让人做AI不擅长的事(理解、决策、判断)
- 验证一切:对AI输出的任何方案,都要用批判性思维验证,尤其是在安全领域
- 持续学习:AI工具在进化,我们的使用方式也需要不断优化。建立适合自己的AI协作流程
展望:更开放、更安全的SaaS未来
如今,谷雨SaaS平台的DPoP安全架构已经贯通。这套方案不仅解决了前端应用化的安全难题,更为平台的开放生态奠定了基础:
- 第三方开发者可以基于这套安全协议,开发合规的前端应用
- 企业客户可以放心地将敏感业务部署在平台上
- 持续交付真正成为可能,每个应用可以独立更新、部署
更令人兴奋的是,我们找到了一条高效的人机协作路径。在这个过程中,AI不是替代者,而是真正的“搭档”——它放大了我们的能力边界,让我们能专注于更高层次的设计和决策。
开源SaaS平台的未来,是开放的、安全的、智能的。在谷雨平台的下一阶段,我们将继续探索AI在测试生成、文档编写、性能优化等更多场景的应用。同时,我们也期待与更多开发者一起,共同探索AI时代的新型开发模式。
思考题:在你的开发实践中,是否遇到过类似的“AI边界”时刻?你是如何与AI工具协同解决复杂技术问题的?欢迎在评论区分享你的经验。
谷雨开源SaaS平台正在招募早期贡献者,如果你对微服务架构、SaaS中台、或SaaS垂直领域能力(如:CMD, CRM等)感兴趣,欢迎访问我们的GitHub仓库https://github.com/g2rain/g2rain或通过公众号“谷雨开源SaaS”加入讨论。
更多推荐



所有评论(0)