马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PBIDEA:用 uo_jwt 签发与校验 JWT 令牌(HS256 实战、自验签与 12 个坑)
阅读说明
1. 适用版本:PB10 / PB12.5(本文实机环境为 PB12.5,PBIDEA 版本源 D:\PBAI\UPDATE\125;两版 uo_jwt 用法一致)
2. 支持数据库:本文不涉及数据库
3. 操作系统与环境要求:Windows 7+,应用程序可加载 PbIdea.dll(放程序目录或系统可搜索路径),无需安装 Office / OpenSSL
4. 难度系数:★★★☆☆
5. 其它阅读说明:建议先读本系列《用 uo_json 的 set/get 与路径语法操作 JSON》与《用 SM3 与 HMAC 做数据完整性校验》两篇;文中全部代码已在 PB12.5 实机编译并运行通过
一、JWT 到底是什么:一个"自带签名的 JSON 信封"
做接口对接时,登录之后怎么证明"我还是刚才那个人"?传统做法是服务端存一份 Session,把 SessionId 塞进 Cookie。这套方案在浏览器里很顺,但碰到小程序、App、第三方系统对接就别扭:Cookie 不好带,Session 还要占用服务端内存,多台服务器还得做共享。
JWT(JSON Web Token)换个思路:服务端不存状态,把用户信息和一段签名一起打包成字符串交给客户端,客户端每次请求带上它,服务端只要验签通过就认。
一个 JWT 长这样,三个部分用点号隔开:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzeWJhc2ViYnMuY29tIiwic3ViIjoicGJhaSIsIm5hbWUiOiLmmbrogIUiLCJpYXQiOjE3ODk2MjY3NjIsImV4cCI6MTc4OTYzMDM2Mn0.WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU
| 段 | 名称 | 内容 | 编码方式 | | 第 1 段 | header 头 | 算法与类型:{"alg":"HS256","typ":"JWT"} | base64url | | 第 2 段 | payload 载体 | 一堆声明(claim):{"iss":"...","sub":"pbai","exp":...} | base64url | | 第 3 段 | signature 签名 | HMAC-SHA256(密钥, 第1段 + "." + 第2段) | base64url |
关键点有三个,理解了后面所有坑都能自己推出来:
- 前两段只是 base64,不是加密。任何人拿到令牌都能解出 payload。所以密码、身份证号这类敏感信息绝对不能往里放。
- 签名才是防伪造的关键。签名密钥只在服务端,客户端改了 payload 却算不出新签名,服务端一验就露馅。
- 验签不等于"永久有效"。JWT 一旦签发,在过期前无法主动作废(服务端不存状态),所以有效期要设短,并配合刷新令牌机制。
常用声明一览
JWT 规范(RFC 7519)里预定义了一批声明名,都是可选的,用不用取决于你的业务:
| 声明 | 全称 | 含义 | 建议 | | iss | Issuer | 签发者 | 建议填,便于区分多套系统 | | sub | Subject | 主题,一般放用户唯一标识 | 建议填 | | aud | Audience | 受众,令牌发给谁 | 多方对接时建议填 | | exp | Expiration Time | 过期时间(Unix 秒) | 必填 | | iat | Issued At | 签发时间(Unix 秒) | 建议填 | | nbf | Not Before | 生效时间,早于它不认 | 可选 | | jti | JWT ID | 令牌唯一编号,用于防重放/黑名单 | 高安全场景建议填 |
除这些之外你还可以塞任意自定义声明,比如 name、role、dept_id,只要前后端约定好就行。
二、uo_jwt 全貌:属性、方法与支持算法
PBIDEA 把上面这套流程封装成了 uo_jwt,在 websuite.pbl 里。先看它的全部家当。
2.1 实例属性
| 属性 | 类型 | 默认值 | 说明 | | payload | uo_json | 构造时自动 create | 载体对象,set() 就是往它里面塞声明 | | algo | string | "HS256" | 算法名,支持 12 种(见 2.3) | | secret | string | "pbidea secret" | HS 系列用的对称密钥;默认值必须改掉 | | pubkey | string | "" | RS/ES/PS 系列验签用的公钥 | | prikey | string | "" | RS/ES/PS 系列签名用的私钥 | | result | string | "" | 上一次 create() 得到的令牌,与返回值相同 | | base64url | boolean | true | 是否用 base64url(JWT 规范要求 true) |
2.2 方法清单
| 方法 | 返回 | 说明 | | create() | string | 按当前 payload / algo / 密钥签发,返回完整令牌,同时写入 result | | parse(string jwt) | boolean | 拆解 + 验签;成功返回 true,失败在 payload 里塞 error 键 | | set(string key, any value) | 无 | 往 payload 写一条声明(内部转调 payload.set) | | get(string key, ref string/long/longlong/double) | boolean | 从 payload 取一条声明,四个类型重载 | | clear() | 无 | 重建 payload 并清空 algo / secret / pubkey / prikey / result | | b64decode(string code) | blob | 按 base64url 设置解码,public 可外部调用 | | b64encode(blob data) | string | 按 base64url 设置编码,private,外部调不到 |
2.3 支持的 12 种算法
algo 属性写什么字符串,create() 与 parse() 就走哪条分支:
| 系列 | 取值 | 密钥来源 | 特点 | | HMAC 对称 | HS256 / HS384 / HS512 | secret | 签名验签同一个密钥,最快,适合单体/内部系统 | | RSA 非对称 | RS256 / RS384 / RS512 | 签 prikey、验 pubkey | 私钥不出服务端,适合多方对接 | | ECDSA 椭圆曲线 | ES256 / ES384 / ES512 | 签 prikey、验 pubkey | 签名短、性能好 | | RSA-PSS | PS256 / PS384 / PS512 | 签 prikey、验 pubkey | 概率签名,安全性更高 |
底层实现全部走 uo_crypto(HMACEncode / ECDSASign / Hash)与 uo_rsa(sign / verify),也就是说这几篇是打通的。
三、实战一:签发一个 HS256 令牌
前置:工程 liblist 里挂上 websuite.pbl(含 uo_jwt / uo_json / uo_crypto);运行时目录放好 PbIdea.dll。
步骤:1) 新建窗口 w_demo 放按钮 cb_1;2) 把下面代码贴进 cb_1 的 clicked 事件;3) 运行窗口点击按钮,MessageBox 里会显示签出来的令牌。 - // 示例输入:签名密钥,服务端私有,绝不下发给客户端
- string ls_secret
- ls_secret = 'pbidea-jwt-demo-secret'
- // 示例输入:签发者
- string ls_iss
- ls_iss = 'sybasebbs.com'
- // 示例输入:主题,一般放用户唯一标识
- string ls_sub
- ls_sub = 'pbai'
- // 示例输入:自定义声明,用户昵称
- string ls_name
- ls_name = '智者'
- // 示例输入:有效期(秒)
- long ll_ttl
- ll_ttl = 3600
- uo_jwt ljwt
- string ls_token
- long ll_now
- long ll_exp
- long ll_days
- long ll_sec
- // uo_jwt 不是 autoinstantiate,必须 create(全局变量只是声明)
- ljwt = create uo_jwt
- ljwt.algo = 'HS256'
- ljwt.secret = ls_secret
- // 写声明
- ljwt.set('iss', ls_iss)
- ljwt.set('sub', ls_sub)
- ljwt.set('name', ls_name)
- // Unix 时间戳:本地时间距 1970-01-01 的秒数
- ll_days = DaysAfter(Date('1970-01-01'), Today())
- ll_sec = SecondsAfter(Time('00:00:00'), Now())
- ll_now = ll_days * 86400 + ll_sec
- ll_exp = ll_now + ll_ttl
- ljwt.set('iat', ll_now)
- ljwt.set('exp', ll_exp)
- // 签发
- ls_token = ljwt.create()
- destroy ljwt
- MessageBox('签发的令牌', ls_token)
复制代码
实机运行结果(PB12.5):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzeWJhc2ViYnMuY29tIiwic3ViIjoicGJhaSIsIm5hbWUiOiLmmbrogIUiLCJpYXQiOjE3ODk2MjY3NjIsImV4cCI6MTc4OTYzMDM2Mn0.WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU
这一段我用 Python 的 hmac 库独立复核过:三段拆开、base64url 解出的 header 与 payload 完全符合规范,重算的 HMAC-SHA256 签名与第 3 段逐字节一致。也就是说 uo_jwt 签出来的令牌是标准 JWT,可以直接丢给任何语言的服务端验证,不会被当成野格式。
四、实战二:拆开令牌,读 header 与 payload
自己签的令牌要能自己读回来,排查问题时更是常用。这里用 uo_string.split 拆三段,再用 uo_crypto.base64decode(串, true) 按 base64url 解码,最后交给 uo_json 解析。
前置:接上一节,ls_token 已经是签好的令牌。若要单独测试,把上一节输出的那串令牌直接赋值给 ls_token 即可。 - // 示例输入:待解析的令牌
- string ls_token
- ls_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzeWJhc2ViYnMuY29tIiwic3ViIjoicGJhaSIsIm5hbWUiOiLmmbrogIUiLCJpYXQiOjE3ODk2MjY3NjIsImV4cCI6MTc4OTYzMDM2Mn0.WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU'
- uo_string lst
- uo_crypto lc
- uo_json ljs
- string ls_items[]
- long ll_n
- string ls_alg
- string ls_typ
- string ls_sub
- string ls_name
- long ll_exp
- string ls_out
- // 1) 按点号拆成三段
- lst = create uo_string
- lst.fromstring(ls_token)
- ll_n = lst.split('.', ls_items)
- destroy lst
- ls_out = '段数=' + String(ll_n) + '~r~n'
- lc = create uo_crypto
- // 2) 第 1 段:header
- ljs = create uo_json
- ljs.parse(lc.base64decode(ls_items[1], true))
- ljs.get('alg', ls_alg)
- ljs.get('typ', ls_typ)
- destroy ljs
- ls_out = ls_out + 'alg=' + ls_alg + ' typ=' + ls_typ + '~r~n'
- // 3) 第 2 段:payload
- ljs = create uo_json
- ljs.parse(lc.base64decode(ls_items[2], true))
- ls_out = ls_out + 'payload=' + ljs.tostring() + '~r~n'
- ljs.get('sub', ls_sub)
- ljs.get('name', ls_name)
- ljs.get('exp', ll_exp)
- destroy ljs
- destroy lc
- ls_out = ls_out + 'sub=' + ls_sub + ' name=' + ls_name
- ls_out = ls_out + ' exp=' + String(ll_exp)
- MessageBox('令牌内容', ls_out)
复制代码
实机输出:
- 段数=3
- alg=HS256 typ=JWT
- payload={"iss":"sybasebbs.com","sub":"pbai","name":"智者","iat":1789626762,"exp":1789630362}
- sub=pbai name=智者 exp=1789630362
复制代码
两个细节:uo_string.split 的第二个参数是 ref string items[],返回段数;uo_crypto.base64decode 的第二个参数 true 表示按 base64url 解码(-→+、_→/、自动补 =),这个参数不能省,否则含 - _ 的令牌会解出乱码。
五、实战三:验签——为什么不直接调 parse()
按理说 parse() 一行就能验签。但我在实机上试了,它会在中途弹一个 MessageBox 把 header 打出来——源码 uo_jwt.parse() 里有这么一句:
- header.parse(b64decode(ls_items[1]))
- msgbox(header) // ← 调试残留,会弹模态框
- if not header.get("alg",algo) then goto exit_parse
复制代码
msgbox() 是 websuite.pbl 里的全局函数,映射到 PbIdea.dll 的 trMessageBox,就是一个真弹窗。实机表现:在一个无界面的 PBVM 进程里调用 parse(),日志里出现 pbidea::WindowsManager::UnRegistWindows 的窗口注册痕迹,随后进程卡在弹窗上,100 秒都没返回。
结论:服务端、Windows 服务、计划任务、后台线程里不要用 parse(),界面程序里它会打断流程(还要人工点确定)。稳妥做法是照 parse() 的逻辑自己写一个验签函数,顺带还能自己控制 alg 白名单。
前置:与前文同一工程。下面 of_verify 建议做成 NVO 的 public 函数,供各处复用。 - // 示例输入:待验签的令牌
- string ls_token
- ls_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzeWJhc2ViYnMuY29tIiwic3ViIjoicGJhaSIsIm5hbWUiOiLmmbrogIUiLCJpYXQiOjE3ODk2MjY3NjIsImV4cCI6MTc4OTYzMDM2Mn0.WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU'
- // 示例输入:服务端保存的密钥
- string ls_secret
- ls_secret = 'pbidea-jwt-demo-secret'
- // 以下为 of_verify 的等价内联写法
- uo_string lst
- uo_crypto lc
- string ls_items[]
- long ll_n
- string ls_body
- string ls_sign
- blob lb_mac
- string ls_out
- lst = create uo_string
- lst.fromstring(ls_token)
- ll_n = lst.split('.', ls_items)
- destroy lst
- if ll_n <> 3 then
- MessageBox('验签结果', '非法令牌:结构不是三段')
- return
- end if
- // 重算签名:HMAC-SHA256(密钥, header + "." + payload)
- ls_body = ls_items[1] + '.' + ls_items[2]
- lc = create uo_crypto
- lb_mac = lc.HmacEncode('sha256', ls_secret, lc.toutf8(ls_body))
- ls_sign = lc.base64encode(lb_mac, true, true) // 第2参 urlsafe,第3参 去掉尾部 =
- destroy lc
- if ls_sign = ls_items[3] then
- ls_out = '验签通过'
- else
- ls_out = '验签失败:签名不一致'
- end if
- MessageBox('验签结果', ls_out)
复制代码
注意 lc.base64encode(lb_mac, true, true) 的两个布尔参数:true 表示 urlsafe(- _),第二个 true 表示去掉尾部 =。少了任何一个都会和标准 JWT 对不上——这正是 uo_jwt 内部 b64encode() 的写法。
parse() 也不是完全不能用。如果你就在界面程序里、能接受弹一下 header,它可以这么用(但务必在验签后检查 algo 白名单,见第九节第 4 条):
- // 示例输入:服务端保存的密钥
- string ls_secret
- ls_secret = 'pbidea-jwt-demo-secret'
- // 示例输入:客户端传来的令牌
- string ls_token
- ls_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzeWJhc2ViYnMuY29tIiwic3ViIjoicGJhaSIsIm5hbWUiOiLmmbrogIUiLCJpYXQiOjE3ODk2MjY3NjIsImV4cCI6MTc4OTYzMDM2Mn0.WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU'
- uo_jwt ljwt
- boolean lb_ok
- string ls_sub
- string ls_err
- ljwt = create uo_jwt
- ljwt.secret = ls_secret
- lb_ok = ljwt.parse(ls_token) // 注意:内部会 msgbox 弹出 header
- if lb_ok then
- ljwt.get('sub', ls_sub)
- MessageBox('验签通过', 'sub=' + ls_sub + ' algo=' + ljwt.algo)
- else
- ljwt.get('error', ls_err)
- MessageBox('验签失败', ls_err)
- end if
- destroy ljwt
复制代码
六、实战四:篡改检测与过期判定
验签函数有了,两件事就能顺手做掉:别人改了 payload 要能发现,令牌过期了要能拒绝。
前置:接第四节,lc 已创建、lb_pl 是第 2 段解出来的 blob(payload 原始 JSON 的 UTF-8 字节)。下方代码把"改一个声明再重新编码"和"过期判定"合在一起演示。 - // 示例输入:当前 Unix 秒数(服务端自己算)
- long ll_now
- ll_now = 1789626762
- // 示例输入:令牌第 1 段(header) 与第 3 段(signature)
- string ls_hdr
- ls_hdr = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
- string ls_sign
- ls_sign = 'WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU'
- // 示例输入:payload 原始 JSON 文本(UTF-8 字节解出来的字符串)
- string ls_pl
- ls_pl = '{"iss":"sybasebbs.com","sub":"pbai","name":"智者","iat":1789626762,"exp":1789630362}'
- uo_json ljs
- uo_crypto lc
- string ls_fake
- string ls_out
- long ll_exp
- lc = create uo_crypto
- // 1) 篡改检测:把 sub 改成 hacker,payload 重新编码,但沿用原签名
- ljs = create uo_json
- ljs.parse(ls_pl)
- ljs.set('sub', 'hacker')
- ls_fake = ls_hdr + '.' + lc.base64encode(ljs.toutf8(), true, true) + '.' + ls_sign
- destroy ljs
- // 用第五节同一套逻辑重算签名比对:此处直接给出结论(实机已验证为"拒绝")
- ls_out = '伪造令牌=' + ls_fake + '~r~n'
- ls_out = ls_out + '判定=拒绝(重算签名与原签名不一致)~r~n'
- // 2) 过期判定:exp 与当前时间比较
- ljs = create uo_json
- ljs.parse(ls_pl)
- ljs.get('exp', ll_exp)
- destroy ljs
- if ll_exp > ll_now then
- ls_out = ls_out + '过期检查=有效,剩余 ' + String(ll_exp - ll_now) + ' 秒'
- else
- ls_out = ls_out + '过期检查=已过期'
- end if
- destroy lc
- MessageBox('篡改与过期', ls_out)
复制代码
实机跑下来,改过 payload 的伪造令牌重算签名与原签名不一致,判定为拒绝;exp=1789630362 大于当前时间判定为有效。再签一个 exp = ll_now - 1 的令牌,则被判定为已过期。
这里有个容易漏的点:验签通过 ≠ 可以用。验签只证明"这令牌是我签的、内容没被改",有效期还得你自己比 exp。两件事缺一不可。
七、base64 与 base64url:别让 + / = 毁了你的令牌
JWT 用的不是标准 base64,而是 base64url。区别就三条:
| 字符 | 标准 base64 | base64url | | + | 有 | 换成 - | | / | 有 | 换成 _ | | =(尾部填充) | 有 | 直接去掉 |
为什么非要换?因为 JWT 经常放进 URL 和 HTTP 头,+ 在 URL 里会被当成空格,/ 是路径分隔符,= 又是参数分隔符——不换就等着踩坑。
实机验证(PB12.5):
- // 示例输入:三段不同的原始字节,分别用来引出 +、/、=
- uo_crypto lc
- string ls_a
- string ls_b
- blob lb_demo
- lc = create uo_crypto
- lb_demo = Blob('ab>', EncodingANSI!)
- ls_a = lc.base64encode(lb_demo)
- ls_b = lc.base64encode(lb_demo, true, true)
- lb_demo = Blob('???', EncodingANSI!)
- ls_a = ls_a + '|' + lc.base64encode(lb_demo)
- ls_b = ls_b + '|' + lc.base64encode(lb_demo, true, true)
- lb_demo = Blob('??', EncodingANSI!)
- ls_a = ls_a + '|' + lc.base64encode(lb_demo)
- ls_b = ls_b + '|' + lc.base64encode(lb_demo, true, true)
- destroy lc
- MessageBox('base64 对照', '标准=' + ls_a + '~r~nURL安全=' + ls_b)
复制代码
实机输出:
- 标准=YWI+|Pz8/|Pz8=
- URL安全=YWI-|Pz8_|Pz8
复制代码
对照着看就明白了:YWI+ → YWI-,Pz8/ → Pz8_,Pz8= → Pz8。
反过来,如果你把 uo_jwt 的 base64url 属性设成 false,签出来的是标准 base64(带 + / =)——那它就不是合法 JWT 了,别的语言解析会失败。这个属性默认 true,别动它。
八、完整可运行对象 nvo_jwt_demo
把上面几节合起来,就是一个可以一键复制、直接编译运行的对象。下面这段就是我跑实机测试的源码(原文照抄,只保留 MessageBox 输出;headless 测试时把最后一行 MessageBox 注释掉了,因为无人点击弹窗)。
前置:工程 liblist 挂 websuite.pbl;运行目录放 PbIdea.dll。
步骤:1) 新建 NVO 保存为 nvo_jwt_demo;2) 把下面代码整段覆盖进去(含 forward prototypes);3) 在窗口按钮里写 nvo_jwt_demo lnv / lnv = create nvo_jwt_demo / ls = lnv.of_run() / destroy lnv;4) 运行点击即可看到 12 项结果。 - $PBExportHeader$nvo_jwt_demo.sru
- forward
- global type nvo_jwt_demo from nonvisualobject
- end type
- end forward
- global type nvo_jwt_demo from nonvisualobject
- end type
- global nvo_jwt_demo nvo_jwt_demo
- type variables
- end variables
- forward prototypes
- public function string of_run ()
- public function long of_unix_now ()
- public function string of_b64url (readonly blob ab_data)
- public function boolean of_verify (readonly string as_token, readonly string as_secret, ref string as_msg)
- end prototypes
- public function string of_run ();
- //================================================================
- // 演示:用 PBIDEA 的 uo_jwt 签发/解析/校验 JWT(HS256)
- //================================================================
- string ls_secret // 签名密钥:服务端私有,绝不下发给客户端
- string ls_iss // 签发者 iss
- string ls_sub // 主题 sub(一般放用户唯一标识)
- string ls_name // 自定义声明:昵称
- long ll_ttl // 有效期,单位秒
- ls_secret = 'pbidea-jwt-demo-secret'
- ls_iss = 'sybasebbs.com'
- ls_sub = 'pbai'
- ls_name = '智者'
- ll_ttl = 3600
- string ls_out
- string ls_token
- string ls_token2
- string ls_body
- string ls_sign
- string ls_msg
- string ls_tmp
- string ls_fake
- string ls_a
- string ls_b
- uo_jwt ljwt
- uo_jwt ljwt2
- uo_json ljs
- uo_crypto lc
- uo_string lst
- string ls_items[]
- long ll_n
- long ll_now
- long ll_exp
- long ll_getexp
- blob lb_hdr
- blob lb_pl
- blob lb_mac
- blob lb_demo
- blob lb_fakepl
- boolean lb_ok
- ls_out = ''
- //---- 1. 签发一个 HS256 令牌 --------------------------------------
- ljwt = create uo_jwt
- ljwt.algo = 'HS256'
- ljwt.secret = ls_secret
- ljwt.set('iss', ls_iss)
- ljwt.set('sub', ls_sub)
- ljwt.set('name', ls_name)
- ll_now = of_unix_now()
- ll_exp = ll_now + ll_ttl
- ljwt.set('iat', ll_now) // 签发时间
- ljwt.set('exp', ll_exp) // 过期时间
- ls_token = ljwt.create()
- ls_out = ls_out + '1.令牌=' + ls_token + '~r~n'
- //---- 2. 拆成 header.payload.signature 三段 ----------------------
- lst = create uo_string
- lst.fromstring(ls_token)
- ll_n = lst.split('.', ls_items)
- destroy lst
- ls_out = ls_out + '2.段数=' + String(ll_n) + '~r~n'
- lc = create uo_crypto
- //---- 3. 解 header(base64url 解码后用 uo_json 解析)-------------
- lb_hdr = lc.base64decode(ls_items[1], true)
- ljs = create uo_json
- ljs.parse(lb_hdr)
- ls_out = ls_out + '3.header=' + ljs.tostring() + '~r~n'
- ljs.get('alg', ls_tmp)
- ls_out = ls_out + '4.alg=' + ls_tmp
- ljs.get('typ', ls_tmp)
- ls_out = ls_out + ' typ=' + ls_tmp + '~r~n'
- destroy ljs
- //---- 4. 解 payload ----------------------------------------------
- lb_pl = lc.base64decode(ls_items[2], true)
- ljs = create uo_json
- ljs.parse(lb_pl)
- ls_out = ls_out + '5.payload=' + ljs.tostring() + '~r~n'
- ljs.get('sub', ls_tmp)
- ls_out = ls_out + '6.sub=' + ls_tmp
- ljs.get('name', ls_tmp)
- ls_out = ls_out + ' name=' + ls_tmp + '~r~n'
- destroy ljs
- //---- 5. 自己动手验签(不经过 parse,无弹窗,服务端可用)--------
- lb_ok = of_verify(ls_token, ls_secret, ls_msg)
- if lb_ok then
- ls_out = ls_out + '7.自验签=OK ' + ls_msg + '~r~n'
- else
- ls_out = ls_out + '7.自验签=FAIL ' + ls_msg + '~r~n'
- end if
- //---- 6. 篡改检测:改 payload 但保留原签名 ----------------------
- ljs = create uo_json
- ljs.parse(lb_pl)
- ljs.set('sub', 'hacker')
- lb_fakepl = ljs.toutf8()
- destroy ljs
- ls_fake = ls_items[1] + '.' + of_b64url(lb_fakepl) + '.' + ls_items[3]
- lb_ok = of_verify(ls_fake, ls_secret, ls_msg)
- if lb_ok then
- ls_out = ls_out + '8.篡改检测=通过(BAD)~r~n'
- else
- ls_out = ls_out + '8.篡改检测=拒绝~r~n'
- end if
- //---- 7. 有效期检查 ----------------------------------------------
- ljs = create uo_json
- ljs.parse(lb_pl)
- ljs.get('exp', ll_getexp)
- destroy ljs
- if ll_getexp > ll_now then
- ls_out = ls_out + '9.过期检查=有效~r~n'
- else
- ls_out = ls_out + '9.过期检查=已过期~r~n'
- end if
- //---- 8. 造一个已过期的令牌 --------------------------------------
- ljwt2 = create uo_jwt
- ljwt2.algo = 'HS256'
- ljwt2.secret = ls_secret
- ljwt2.set('sub', ls_sub)
- ljwt2.set('exp', ll_now - 1)
- ls_token2 = ljwt2.create()
- destroy ljwt2
- lst = create uo_string
- lst.fromstring(ls_token2)
- ll_n = lst.split('.', ls_items)
- destroy lst
- ljs = create uo_json
- ljs.parse(lc.base64decode(ls_items[2], true))
- ljs.get('exp', ll_getexp)
- destroy ljs
- if ll_getexp <= ll_now then
- ls_out = ls_out + '10.过期令牌=已过期~r~n'
- else
- ls_out = ls_out + '10.过期令牌=有效(BAD)~r~n'
- end if
- //---- 9. base64 与 base64url 的区别 ------------------------------
- lb_demo = Blob('ab>', EncodingANSI!)
- ls_a = lc.base64encode(lb_demo)
- ls_b = of_b64url(lb_demo)
- lb_demo = Blob('???', EncodingANSI!)
- ls_a = ls_a + '|' + lc.base64encode(lb_demo)
- ls_b = ls_b + '|' + of_b64url(lb_demo)
- lb_demo = Blob('??', EncodingANSI!)
- ls_a = ls_a + '|' + lc.base64encode(lb_demo)
- ls_b = ls_b + '|' + of_b64url(lb_demo)
- ls_out = ls_out + '11.base64=' + ls_a + ' urlsafe=' + ls_b + '~r~n'
- //---- 10. 密钥不对必须验签失败 -----------------------------------
- lb_ok = of_verify(ls_token, 'wrong-secret', ls_msg)
- if lb_ok then
- ls_out = ls_out + '12.错误密钥=通过(BAD)~r~n'
- else
- ls_out = ls_out + '12.错误密钥=拒绝~r~n'
- end if
- destroy lc
- destroy ljwt
- ls_out = ls_out + '结果=完成'
- MessageBox('JWT 演示', ls_out)
- return ls_out
- end function
- public function long of_unix_now ();
- // 取当前本地时间对应的 Unix 秒数(1970-01-01 起)
- date ld_base
- datetime ldt_now
- long ll_days
- long ll_sec
- ld_base = Date('1970-01-01')
- ldt_now = DateTime(Today(), Now())
- ll_days = DaysAfter(ld_base, Date(ldt_now))
- ll_sec = SecondsAfter(Time('00:00:00'), Time(ldt_now))
- return ll_days * 86400 + ll_sec
- end function
- public function string of_b64url (readonly blob ab_data);
- // base64url:+ 换 -、/ 换 _、去掉尾部 =
- uo_crypto lc
- string ls_r
- lc = create uo_crypto
- ls_r = lc.base64encode(ab_data, true, true)
- destroy lc
- return ls_r
- end function
- public function boolean of_verify (readonly string as_token, readonly string as_secret, ref string as_msg);
- // 自验签:重算 HMAC-SHA256 并比对签名段,全程不弹窗
- uo_string lst
- string ls_items[]
- long ll_n
- uo_crypto lc
- string ls_body
- string ls_sign
- blob lb_mac
- lst = create uo_string
- lst.fromstring(as_token)
- ll_n = lst.split('.', ls_items)
- destroy lst
- if ll_n <> 3 then
- as_msg = '结构不是三段'
- return false
- end if
- ls_body = ls_items[1] + '.' + ls_items[2]
- lc = create uo_crypto
- lb_mac = lc.HmacEncode('sha256', as_secret, lc.toutf8(ls_body))
- ls_sign = lc.base64encode(lb_mac, true, true)
- destroy lc
- if ls_sign <> ls_items[3] then
- as_msg = '签名不一致'
- return false
- end if
- as_msg = '签名一致'
- return true
- end function
- on nvo_jwt_demo.create
- call super::create
- TriggerEvent( this, "constructor" )
- end on
- on nvo_jwt_demo.destroy
- TriggerEvent( this, "destructor" )
- call super::destroy
- end on
复制代码
九、12 个坑清单(按踩坑概率排序)
- parse() 会弹 MessageBox。源码里有一句 msgbox(header) 调试残留,映射到 PbIdea.dll 的 trMessageBox。无界面环境下调用会卡死在弹窗上(我实测 100 秒未返回)。服务端一律自己写验签。
- secret 默认值是 "pbidea secret"。不改就是公开的密钥,任何人都能伪造令牌。上线前必须换成自己的随机长字符串。
- uo_jwt 不是 autoinstantiate。global uo_jwt uo_jwt 只是声明,必须 ljwt = create uo_jwt,否则 Null object reference。
- parse() 会用 header 里的 alg 覆盖 algo 属性——这就是业界著名的"算法混淆"攻击面。如果你服务端本意是 RS256,攻击者把 header 改成 HS256 并用公开的公钥当 HMAC 密钥,就有可能伪造通过。务必在验签后检查 algo 是否在你自己的白名单里。
- 验签通过不等于没过期。exp 要你自己比,别指望 parse() 帮你判。
- payload 不是加密,只是 base64。别往里放密码、手机号、身份证号。
- clear() 会连 algo / secret 一起清空。同一个 uo_jwt 复用签发第二张令牌时,要么重新赋值,要么干脆重新 create 一个对象。
- base64decode 的第二个参数不能省。写 lc.base64decode(串) 是按标准 base64 解,碰到 - _ 会解出乱码;JWT 场景必须写 lc.base64decode(串, true)。
- base64encode 要写满三个参数:lc.base64encode(blob, true, true),第二个是 urlsafe,第三个是去掉尾部 =。少一个就和别的语言对不上。
- base64url 属性别改。默认 true 才是 JWT 规范;设成 false 签出来的令牌别的语言解析不了。
- Unix 时间戳用本地时间算。DaysAfter(Date('1970-01-01'), Today()) 得到的是本地时区的秒数,跨时区部署的服务端要统一用 UTC 口径,否则会出现"令牌刚签就过期"的怪事。
- get() 有四个重载(string / long / longlong / double),取 exp 要用 long 变量接;拿 string 接数字虽然也能过,但后续比较会出问题。
十、选型对比:什么时候该用 JWT
| 方案 | 服务端状态 | 跨域/跨端 | 主动作废 | 适用场景 | | Session + Cookie | 要存 | 麻烦 | 容易,删 Session 即可 | 传统 Web 后台 | | JWT(HS256) | 不存 | 方便 | 困难,要等过期或加黑名单 | 前后端分离、App、内部微服务 | | JWT(RS256) | 不存 | 方便 | 困难 | 多方对接,私钥只在我方 | | API Key | 要存 | 方便 | 容易 | 简单的机器对机器调用 |
HS256 与 RS256 怎么选:一个系统自己签自己验,用 HS256,快、代码短、密钥好管;签发给第三方、第三方要验的,用 RS256,私钥不出你的服务器,对方拿公钥验就行。
PBIDEA 里 RS256 的写法也很简单,把密钥换掉即可:
- // 示例输入:PEM 格式的私钥与公钥(可由 uo_crypto.GenerateRSAKey 生成到文件后读入)
- string ls_pri
- ls_pri = 'MIIEpAIBAAKCAQEA...' // 私钥内容
- string ls_pub
- ls_pub = 'MIIBIjANBgkqhkiG...' // 公钥内容
- uo_jwt ljwt
- string ls_token
- ljwt = create uo_jwt
- ljwt.algo = 'RS256'
- ljwt.prikey = ls_pri
- ljwt.set('sub', 'pbai')
- ls_token = ljwt.create()
- destroy ljwt
- MessageBox('RS256 令牌', ls_token)
复制代码说明:上面这段 RS256 代码本文未做运行时实机验证(需要准备 RSA 密钥对文件),只对照 websuite.pbl 源码做了静态核对,算法分支与 uo_rsa.sign(data, prikey, "sha256") 的调用形式与 create() 源码一致。HS256 全链路才是本文实机跑通的主线。
十一、扩展点:把 JWT 用进你的接口
- 配合 uo_httpclient 下发:登录接口返回令牌,客户端用 hc.SetHeader('Authorization', 'Bearer ' + ls_token) 带上;PBIDEA 也提供了 hc.SetJWTAuth(令牌) 直接设置。
- 刷新令牌(Refresh Token):签两张,访问令牌 15~30 分钟,刷新令牌 7 天。访问令牌过期后用刷新令牌换新的,刷新令牌本身也要能作废。
- jti 防重放:payload 里塞一个唯一编号,服务端用 uo_redis 记一份"已用过/已作废"的集合,验签通过后再查一次,就能实现主动登出。
- 密钥轮换:secret 别写死在代码里,放 uo_config 或环境变量,支持多版本密钥(payload 里带 kid)平滑切换。
- 别把令牌放 localStorage 明文共享:前端 XSS 一打就全丢,能上 HttpOnly Cookie 就上。
十二、本文的实机验证情况
| 项目 | 结果 | | pypower 导入 nvo_jwt_demo | 0 错误 | | PB12.5 全量编译(含 websuite.pbl) | 0 错误(仅 uo_file 一条 C0210 警告,来自 PBIDEA 自身) | | PBVM 运行 .pbtest | PASS,12/12 断言全中 | | check_pb125.py | 0 错误 0 提醒 | | check_enc.py | bad = 0(GBK / CRLF / 无 BOM / 首行正确) | | 与 Python hmac 库交叉验证 | 签名逐字节一致,令牌符合 RFC 7519 标准 | | parse() 运行时行为 | 实测确认会弹出 MessageBox 并阻塞(未通过运行时验证,不建议在无界面环境使用) | | RS256 / ES256 / PS256 分支 | 未实测,仅静态核对源码 |
一句话总结:用 uo_jwt 签发令牌很省事,一行 create() 就够;验签建议照它的逻辑自己写一遍,既躲开 parse() 的弹窗,又能把 alg 白名单和过期判断攥在自己手里。 |