当AI成为我的编程“搭档”:一个开源SaaS平台的前端安全架构攻坚记

jagger|阅读 2
2026/01/21 10:25
谷雨前端安全架构AI编程
当AI成为我的编程“搭档”:一个开源SaaS平台的前端安全架构攻坚记

从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平台安全交互规范”:

七步安全交互的核心逻辑 :

  1. 客户端密钥生成 :每个用户会话开始时,浏览器生成唯一的ECDSA密钥对
  2. 首次身份认证 :使用公钥登录IAM,获得与公钥绑定的临时授权码
  3. 安全传输保障 :所有关键数据都经过数字签名,防篡改、可验证
  4. 双重签名验证 :OpenResty网关和应用服务器共同验证请求的合法性
  5. 令牌精准绑定 :最终颁发的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 OpenSSLidentifier 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 curveThe 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 -outdh-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 + 1end -- 从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, yend -- 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 DERlocal 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 JWTfunction _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 .. "." .. signatureend -- generate with config active keyfunction _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 contentend -- 加载密钥配置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 nilend -- 重新加载密钥(用于密钥轮换)function _M.reload_keys()    cached_keys = nil    return _M.get_active_key() ~= nilend -- 获取 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_keyend -- 获取 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_keyend 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的三大核心价值

  1. 创意激发与方案探索 :在初期探索阶段,AI能快速提供多种思路(如发现DPoP协议)
  2. 代码生成与模板创建 :对于通用模式、重复性代码,AI能极大提升效率
  3. 问题诊断与调试辅助 :遇到错误时,AI能提供可能的解决方案和调试方向

人类不可替代的三大角色

  1. 架构师与决策者 :在技术选型、方案设计等关键决策上,人类的专业判断无可替代
  2. 前沿知识的学习者 :对于AI尚未掌握的新技术、新文档,人类必须亲自阅读和理解
  3. 质量守门员 :对AI生成的所有代码,必须进行阅读,审查,测试。

给技术团队的实用建议

  1. 明确分工 :让AI做它擅长的事(生成、搜索、建议),让人做AI不擅长的事(理解、决策、判断)
  2. 验证一切 :对AI输出的任何方案,都要用批判性思维验证,尤其是在安全领域
  3. 持续学习 :AI工具在进化,我们的使用方式也需要不断优化。建立适合自己的AI协作流程

展望:更开放、更安全的SaaS未来

如今,谷雨SaaS平台的DPoP安全架构已经贯通。这套方案不仅解决了前端应用化的安全难题,更为平台的开放生态奠定了基础:

  • 第三方开发者 可以基于这套安全协议,开发合规的前端应用
  • 企业客户 可以放心地将敏感业务部署在平台上
  • 持续交付 真正成为可能,每个应用可以独立更新、部署

更令人兴奋的是,我们找到了一条高效的人机协作路径。在这个过程中,AI不是替代者,而是真正的“搭档”——它放大了我们的能力边界,让我们能专注于更高层次的设计和决策。

开源SaaS平台的未来,是开放的、安全的、智能的。在谷雨平台的下一阶段,我们将继续探索AI在测试生成、文档编写、性能优化等更多场景的应用。同时,我们也期待与更多开发者一起,共同探索AI时代的新型开发模式。

思考题 :在你的开发实践中,是否遇到过类似的“AI边界”时刻?你是如何与AI工具协同解决复杂技术问题的?欢迎在评论区分享你的经验。

谷雨开源SaaS平台正在招募早期贡献者,如果你对微服务架构、SaaS中台、或SaaS垂直领域能力(如:CMD, CRM等)感兴趣,欢迎访问我们的GitHub仓库 https://github.com/g2rain/g2rain 或通过公众号“谷雨开源SaaS”加入讨论。