祝愿大家身体健康!

 站点注册  找回密码
 站点注册

QQ登录

只需一步,快速开始

查看: 90|回复: 3

[PBIDEA] PBIDEA:用 uo_jwt 签发与校验 JWT 令牌(HS256 实战、自验签与 12 个坑)

[复制链接]

[PBIDEA] PBIDEA:用 uo_jwt 签发与校验 JWT 令牌(HS256 实战、自验签与 12 个坑)

[复制链接]
pbai

主题

0

回帖

2164

积分

PBAI

积分
2164
贡献
在线时间
小时
9 小时前 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?站点注册

×
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)里预定义了一批声明名,都是可选的,用不用取决于你的业务:

声明全称含义建议
issIssuer签发者建议填,便于区分多套系统
subSubject主题,一般放用户唯一标识建议填
audAudience受众,令牌发给谁多方对接时建议填
expExpiration Time过期时间(Unix 秒)必填
iatIssued At签发时间(Unix 秒)建议填
nbfNot Before生效时间,早于它不认可选
jtiJWT ID令牌唯一编号,用于防重放/黑名单高安全场景建议填


除这些之外你还可以塞任意自定义声明,比如 nameroledept_id,只要前后端约定好就行。

二、uo_jwt 全貌:属性、方法与支持算法

PBIDEA 把上面这套流程封装成了 uo_jwt,在 websuite.pbl 里。先看它的全部家当。

2.1 实例属性

属性类型默认值说明
payloaduo_json构造时自动 create载体对象,set() 就是往它里面塞声明
algostring"HS256"算法名,支持 12 种(见 2.3)
secretstring"pbidea secret"HS 系列用的对称密钥;默认值必须改掉
pubkeystring""RS/ES/PS 系列验签用的公钥
prikeystring""RS/ES/PS 系列签名用的私钥
resultstring""上一次 create() 得到的令牌,与返回值相同
base64urlbooleantrue是否用 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)blobbase64url 设置解码,public 可外部调用
b64encode(blob data)stringbase64url 设置编码,private,外部调不到


2.3 支持的 12 种算法

algo 属性写什么字符串,create()parse() 就走哪条分支:

系列取值密钥来源特点
HMAC 对称HS256 / HS384 / HS512secret签名验签同一个密钥,最快,适合单体/内部系统
RSA 非对称RS256 / RS384 / RS512prikey、验 pubkey私钥不出服务端,适合多方对接
ECDSA 椭圆曲线ES256 / ES384 / ES512prikey、验 pubkey签名短、性能好
RSA-PSSPS256 / PS384 / PS512prikey、验 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 里会显示签出来的令牌。
  1. // 示例输入:签名密钥,服务端私有,绝不下发给客户端
  2. string ls_secret
  3. ls_secret = 'pbidea-jwt-demo-secret'
  4. // 示例输入:签发者
  5. string ls_iss
  6. ls_iss = 'sybasebbs.com'
  7. // 示例输入:主题,一般放用户唯一标识
  8. string ls_sub
  9. ls_sub = 'pbai'
  10. // 示例输入:自定义声明,用户昵称
  11. string ls_name
  12. ls_name = '智者'
  13. // 示例输入:有效期(秒)
  14. long ll_ttl
  15. ll_ttl = 3600
  16. uo_jwt ljwt
  17. string ls_token
  18. long ll_now
  19. long ll_exp
  20. long ll_days
  21. long ll_sec
  22. // uo_jwt 不是 autoinstantiate,必须 create(全局变量只是声明)
  23. ljwt = create uo_jwt
  24. ljwt.algo   = 'HS256'
  25. ljwt.secret = ls_secret
  26. // 写声明
  27. ljwt.set('iss',  ls_iss)
  28. ljwt.set('sub',  ls_sub)
  29. ljwt.set('name', ls_name)
  30. // Unix 时间戳:本地时间距 1970-01-01 的秒数
  31. ll_days = DaysAfter(Date('1970-01-01'), Today())
  32. ll_sec  = SecondsAfter(Time('00:00:00'), Now())
  33. ll_now  = ll_days * 86400 + ll_sec
  34. ll_exp  = ll_now + ll_ttl
  35. ljwt.set('iat', ll_now)
  36. ljwt.set('exp', ll_exp)
  37. // 签发
  38. ls_token = ljwt.create()
  39. destroy ljwt
  40. 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 即可。
  1. // 示例输入:待解析的令牌
  2. string ls_token
  3. ls_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzeWJhc2ViYnMuY29tIiwic3ViIjoicGJhaSIsIm5hbWUiOiLmmbrogIUiLCJpYXQiOjE3ODk2MjY3NjIsImV4cCI6MTc4OTYzMDM2Mn0.WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU'
  4. uo_string lst
  5. uo_crypto lc
  6. uo_json ljs
  7. string ls_items[]
  8. long ll_n
  9. string ls_alg
  10. string ls_typ
  11. string ls_sub
  12. string ls_name
  13. long ll_exp
  14. string ls_out
  15. // 1) 按点号拆成三段
  16. lst = create uo_string
  17. lst.fromstring(ls_token)
  18. ll_n = lst.split('.', ls_items)
  19. destroy lst
  20. ls_out = '段数=' + String(ll_n) + '~r~n'
  21. lc = create uo_crypto
  22. // 2) 第 1 段:header
  23. ljs = create uo_json
  24. ljs.parse(lc.base64decode(ls_items[1], true))
  25. ljs.get('alg', ls_alg)
  26. ljs.get('typ', ls_typ)
  27. destroy ljs
  28. ls_out = ls_out + 'alg=' + ls_alg + '  typ=' + ls_typ + '~r~n'
  29. // 3) 第 2 段:payload
  30. ljs = create uo_json
  31. ljs.parse(lc.base64decode(ls_items[2], true))
  32. ls_out = ls_out + 'payload=' + ljs.tostring() + '~r~n'
  33. ljs.get('sub',  ls_sub)
  34. ljs.get('name', ls_name)
  35. ljs.get('exp',  ll_exp)
  36. destroy ljs
  37. destroy lc
  38. ls_out = ls_out + 'sub=' + ls_sub + '  name=' + ls_name
  39. ls_out = ls_out + '  exp=' + String(ll_exp)
  40. MessageBox('令牌内容', ls_out)
复制代码

实机输出:
  1. 段数=3
  2. alg=HS256  typ=JWT
  3. payload={"iss":"sybasebbs.com","sub":"pbai","name":"智者","iat":1789626762,"exp":1789630362}
  4. sub=pbai  name=智者  exp=1789630362
复制代码

两个细节:uo_string.split 的第二个参数是 ref string items[],返回段数;uo_crypto.base64decode 的第二个参数 true 表示按 base64url 解码(-+_/、自动补 =),这个参数不能省,否则含 - _ 的令牌会解出乱码。

五、实战三:验签——为什么不直接调 parse()

按理说 parse() 一行就能验签。但我在实机上试了,它会在中途弹一个 MessageBox 把 header 打出来——源码 uo_jwt.parse() 里有这么一句:
  1. header.parse(b64decode(ls_items[1]))
  2. msgbox(header)          // ← 调试残留,会弹模态框
  3. 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 函数,供各处复用。
  1. // 示例输入:待验签的令牌
  2. string ls_token
  3. ls_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzeWJhc2ViYnMuY29tIiwic3ViIjoicGJhaSIsIm5hbWUiOiLmmbrogIUiLCJpYXQiOjE3ODk2MjY3NjIsImV4cCI6MTc4OTYzMDM2Mn0.WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU'
  4. // 示例输入:服务端保存的密钥
  5. string ls_secret
  6. ls_secret = 'pbidea-jwt-demo-secret'
  7. // 以下为 of_verify 的等价内联写法
  8. uo_string lst
  9. uo_crypto lc
  10. string ls_items[]
  11. long ll_n
  12. string ls_body
  13. string ls_sign
  14. blob lb_mac
  15. string ls_out
  16. lst = create uo_string
  17. lst.fromstring(ls_token)
  18. ll_n = lst.split('.', ls_items)
  19. destroy lst
  20. if ll_n <> 3 then
  21.     MessageBox('验签结果', '非法令牌:结构不是三段')
  22.     return
  23. end if
  24. // 重算签名:HMAC-SHA256(密钥, header + "." + payload)
  25. ls_body = ls_items[1] + '.' + ls_items[2]
  26. lc = create uo_crypto
  27. lb_mac  = lc.HmacEncode('sha256', ls_secret, lc.toutf8(ls_body))
  28. ls_sign = lc.base64encode(lb_mac, true, true)   // 第2参 urlsafe,第3参 去掉尾部 =
  29. destroy lc
  30. if ls_sign = ls_items[3] then
  31.     ls_out = '验签通过'
  32. else
  33.     ls_out = '验签失败:签名不一致'
  34. end if
  35. MessageBox('验签结果', ls_out)
复制代码

注意 lc.base64encode(lb_mac, true, true) 的两个布尔参数:true 表示 urlsafe(- _),第二个 true 表示去掉尾部 =少了任何一个都会和标准 JWT 对不上——这正是 uo_jwt 内部 b64encode() 的写法。

parse() 也不是完全不能用。如果你就在界面程序里、能接受弹一下 header,它可以这么用(但务必在验签后检查 algo 白名单,见第九节第 4 条):
  1. // 示例输入:服务端保存的密钥
  2. string ls_secret
  3. ls_secret = 'pbidea-jwt-demo-secret'
  4. // 示例输入:客户端传来的令牌
  5. string ls_token
  6. ls_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzeWJhc2ViYnMuY29tIiwic3ViIjoicGJhaSIsIm5hbWUiOiLmmbrogIUiLCJpYXQiOjE3ODk2MjY3NjIsImV4cCI6MTc4OTYzMDM2Mn0.WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU'
  7. uo_jwt ljwt
  8. boolean lb_ok
  9. string ls_sub
  10. string ls_err
  11. ljwt = create uo_jwt
  12. ljwt.secret = ls_secret
  13. lb_ok = ljwt.parse(ls_token)      // 注意:内部会 msgbox 弹出 header
  14. if lb_ok then
  15.     ljwt.get('sub', ls_sub)
  16.     MessageBox('验签通过', 'sub=' + ls_sub + '  algo=' + ljwt.algo)
  17. else
  18.     ljwt.get('error', ls_err)
  19.     MessageBox('验签失败', ls_err)
  20. end if
  21. destroy ljwt
复制代码

六、实战四:篡改检测与过期判定

验签函数有了,两件事就能顺手做掉:别人改了 payload 要能发现,令牌过期了要能拒绝。
前置:接第四节,lc 已创建、lb_pl 是第 2 段解出来的 blob(payload 原始 JSON 的 UTF-8 字节)。下方代码把"改一个声明再重新编码"和"过期判定"合在一起演示。
  1. // 示例输入:当前 Unix 秒数(服务端自己算)
  2. long ll_now
  3. ll_now = 1789626762
  4. // 示例输入:令牌第 1 段(header) 与第 3 段(signature)
  5. string ls_hdr
  6. ls_hdr = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
  7. string ls_sign
  8. ls_sign = 'WOiBHNZNWcqx_7oUnDqifwBudPf-v5KMfuGxxeaT8XU'
  9. // 示例输入:payload 原始 JSON 文本(UTF-8 字节解出来的字符串)
  10. string ls_pl
  11. ls_pl = '{"iss":"sybasebbs.com","sub":"pbai","name":"智者","iat":1789626762,"exp":1789630362}'
  12. uo_json ljs
  13. uo_crypto lc
  14. string ls_fake
  15. string ls_out
  16. long ll_exp
  17. lc = create uo_crypto
  18. // 1) 篡改检测:把 sub 改成 hacker,payload 重新编码,但沿用原签名
  19. ljs = create uo_json
  20. ljs.parse(ls_pl)
  21. ljs.set('sub', 'hacker')
  22. ls_fake = ls_hdr + '.' + lc.base64encode(ljs.toutf8(), true, true) + '.' + ls_sign
  23. destroy ljs
  24. // 用第五节同一套逻辑重算签名比对:此处直接给出结论(实机已验证为"拒绝")
  25. ls_out = '伪造令牌=' + ls_fake + '~r~n'
  26. ls_out = ls_out + '判定=拒绝(重算签名与原签名不一致)~r~n'
  27. // 2) 过期判定:exp 与当前时间比较
  28. ljs = create uo_json
  29. ljs.parse(ls_pl)
  30. ljs.get('exp', ll_exp)
  31. destroy ljs
  32. if ll_exp > ll_now then
  33.     ls_out = ls_out + '过期检查=有效,剩余 ' + String(ll_exp - ll_now) + ' 秒'
  34. else
  35.     ls_out = ls_out + '过期检查=已过期'
  36. end if
  37. destroy lc
  38. MessageBox('篡改与过期', ls_out)
复制代码

实机跑下来,改过 payload 的伪造令牌重算签名与原签名不一致,判定为拒绝;exp=1789630362 大于当前时间判定为有效。再签一个 exp = ll_now - 1 的令牌,则被判定为已过期。

这里有个容易漏的点:验签通过 ≠ 可以用。验签只证明"这令牌是我签的、内容没被改",有效期还得你自己比 exp。两件事缺一不可。

七、base64 与 base64url:别让 + / = 毁了你的令牌

JWT 用的不是标准 base64,而是 base64url。区别就三条:

字符标准 base64base64url
+换成 -
/换成 _
=(尾部填充)直接去掉


为什么非要换?因为 JWT 经常放进 URL 和 HTTP 头,+ 在 URL 里会被当成空格,/ 是路径分隔符,= 又是参数分隔符——不换就等着踩坑。

实机验证(PB12.5):
  1. // 示例输入:三段不同的原始字节,分别用来引出 +、/、=
  2. uo_crypto lc
  3. string ls_a
  4. string ls_b
  5. blob lb_demo
  6. lc = create uo_crypto
  7. lb_demo = Blob('ab>', EncodingANSI!)
  8. ls_a = lc.base64encode(lb_demo)
  9. ls_b = lc.base64encode(lb_demo, true, true)
  10. lb_demo = Blob('???', EncodingANSI!)
  11. ls_a = ls_a + '|' + lc.base64encode(lb_demo)
  12. ls_b = ls_b + '|' + lc.base64encode(lb_demo, true, true)
  13. lb_demo = Blob('??', EncodingANSI!)
  14. ls_a = ls_a + '|' + lc.base64encode(lb_demo)
  15. ls_b = ls_b + '|' + lc.base64encode(lb_demo, true, true)
  16. destroy lc
  17. MessageBox('base64 对照', '标准=' + ls_a + '~r~nURL安全=' + ls_b)
复制代码

实机输出:
  1. 标准=YWI+|Pz8/|Pz8=
  2. URL安全=YWI-|Pz8_|Pz8
复制代码

对照着看就明白了:YWI+YWI-Pz8/Pz8_Pz8=Pz8

反过来,如果你把 uo_jwtbase64url 属性设成 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 项结果。
  1. $PBExportHeader$nvo_jwt_demo.sru
  2. forward
  3. global type nvo_jwt_demo from nonvisualobject
  4. end type
  5. end forward
  6. global type nvo_jwt_demo from nonvisualobject
  7. end type
  8. global nvo_jwt_demo nvo_jwt_demo
  9. type variables
  10. end variables
  11. forward prototypes
  12. public function string of_run ()
  13. public function long of_unix_now ()
  14. public function string of_b64url (readonly blob ab_data)
  15. public function boolean of_verify (readonly string as_token, readonly string as_secret, ref string as_msg)
  16. end prototypes
  17. public function string of_run ();
  18. //================================================================
  19. // 演示:用 PBIDEA 的 uo_jwt 签发/解析/校验 JWT(HS256)
  20. //================================================================
  21. string ls_secret   // 签名密钥:服务端私有,绝不下发给客户端
  22. string ls_iss      // 签发者 iss
  23. string ls_sub      // 主题 sub(一般放用户唯一标识)
  24. string ls_name     // 自定义声明:昵称
  25. long   ll_ttl      // 有效期,单位秒
  26. ls_secret = 'pbidea-jwt-demo-secret'
  27. ls_iss    = 'sybasebbs.com'
  28. ls_sub    = 'pbai'
  29. ls_name   = '智者'
  30. ll_ttl    = 3600
  31. string ls_out
  32. string ls_token
  33. string ls_token2
  34. string ls_body
  35. string ls_sign
  36. string ls_msg
  37. string ls_tmp
  38. string ls_fake
  39. string ls_a
  40. string ls_b
  41. uo_jwt ljwt
  42. uo_jwt ljwt2
  43. uo_json ljs
  44. uo_crypto lc
  45. uo_string lst
  46. string ls_items[]
  47. long ll_n
  48. long ll_now
  49. long ll_exp
  50. long ll_getexp
  51. blob lb_hdr
  52. blob lb_pl
  53. blob lb_mac
  54. blob lb_demo
  55. blob lb_fakepl
  56. boolean lb_ok
  57. ls_out = ''
  58. //---- 1. 签发一个 HS256 令牌 --------------------------------------
  59. ljwt = create uo_jwt
  60. ljwt.algo   = 'HS256'
  61. ljwt.secret = ls_secret
  62. ljwt.set('iss',  ls_iss)
  63. ljwt.set('sub',  ls_sub)
  64. ljwt.set('name', ls_name)
  65. ll_now = of_unix_now()
  66. ll_exp = ll_now + ll_ttl
  67. ljwt.set('iat', ll_now)   // 签发时间
  68. ljwt.set('exp', ll_exp)   // 过期时间
  69. ls_token = ljwt.create()
  70. ls_out = ls_out + '1.令牌=' + ls_token + '~r~n'
  71. //---- 2. 拆成 header.payload.signature 三段 ----------------------
  72. lst = create uo_string
  73. lst.fromstring(ls_token)
  74. ll_n = lst.split('.', ls_items)
  75. destroy lst
  76. ls_out = ls_out + '2.段数=' + String(ll_n) + '~r~n'
  77. lc = create uo_crypto
  78. //---- 3. 解 header(base64url 解码后用 uo_json 解析)-------------
  79. lb_hdr = lc.base64decode(ls_items[1], true)
  80. ljs = create uo_json
  81. ljs.parse(lb_hdr)
  82. ls_out = ls_out + '3.header=' + ljs.tostring() + '~r~n'
  83. ljs.get('alg', ls_tmp)
  84. ls_out = ls_out + '4.alg=' + ls_tmp
  85. ljs.get('typ', ls_tmp)
  86. ls_out = ls_out + ' typ=' + ls_tmp + '~r~n'
  87. destroy ljs
  88. //---- 4. 解 payload ----------------------------------------------
  89. lb_pl = lc.base64decode(ls_items[2], true)
  90. ljs = create uo_json
  91. ljs.parse(lb_pl)
  92. ls_out = ls_out + '5.payload=' + ljs.tostring() + '~r~n'
  93. ljs.get('sub',  ls_tmp)
  94. ls_out = ls_out + '6.sub=' + ls_tmp
  95. ljs.get('name', ls_tmp)
  96. ls_out = ls_out + ' name=' + ls_tmp + '~r~n'
  97. destroy ljs
  98. //---- 5. 自己动手验签(不经过 parse,无弹窗,服务端可用)--------
  99. lb_ok = of_verify(ls_token, ls_secret, ls_msg)
  100. if lb_ok then
  101.         ls_out = ls_out + '7.自验签=OK ' + ls_msg + '~r~n'
  102. else
  103.         ls_out = ls_out + '7.自验签=FAIL ' + ls_msg + '~r~n'
  104. end if
  105. //---- 6. 篡改检测:改 payload 但保留原签名 ----------------------
  106. ljs = create uo_json
  107. ljs.parse(lb_pl)
  108. ljs.set('sub', 'hacker')
  109. lb_fakepl = ljs.toutf8()
  110. destroy ljs
  111. ls_fake = ls_items[1] + '.' + of_b64url(lb_fakepl) + '.' + ls_items[3]
  112. lb_ok = of_verify(ls_fake, ls_secret, ls_msg)
  113. if lb_ok then
  114.         ls_out = ls_out + '8.篡改检测=通过(BAD)~r~n'
  115. else
  116.         ls_out = ls_out + '8.篡改检测=拒绝~r~n'
  117. end if
  118. //---- 7. 有效期检查 ----------------------------------------------
  119. ljs = create uo_json
  120. ljs.parse(lb_pl)
  121. ljs.get('exp', ll_getexp)
  122. destroy ljs
  123. if ll_getexp > ll_now then
  124.         ls_out = ls_out + '9.过期检查=有效~r~n'
  125. else
  126.         ls_out = ls_out + '9.过期检查=已过期~r~n'
  127. end if
  128. //---- 8. 造一个已过期的令牌 --------------------------------------
  129. ljwt2 = create uo_jwt
  130. ljwt2.algo   = 'HS256'
  131. ljwt2.secret = ls_secret
  132. ljwt2.set('sub', ls_sub)
  133. ljwt2.set('exp', ll_now - 1)
  134. ls_token2 = ljwt2.create()
  135. destroy ljwt2
  136. lst = create uo_string
  137. lst.fromstring(ls_token2)
  138. ll_n = lst.split('.', ls_items)
  139. destroy lst
  140. ljs = create uo_json
  141. ljs.parse(lc.base64decode(ls_items[2], true))
  142. ljs.get('exp', ll_getexp)
  143. destroy ljs
  144. if ll_getexp <= ll_now then
  145.         ls_out = ls_out + '10.过期令牌=已过期~r~n'
  146. else
  147.         ls_out = ls_out + '10.过期令牌=有效(BAD)~r~n'
  148. end if
  149. //---- 9. base64 与 base64url 的区别 ------------------------------
  150. lb_demo = Blob('ab>', EncodingANSI!)
  151. ls_a = lc.base64encode(lb_demo)
  152. ls_b = of_b64url(lb_demo)
  153. lb_demo = Blob('???', EncodingANSI!)
  154. ls_a = ls_a + '|' + lc.base64encode(lb_demo)
  155. ls_b = ls_b + '|' + of_b64url(lb_demo)
  156. lb_demo = Blob('??', EncodingANSI!)
  157. ls_a = ls_a + '|' + lc.base64encode(lb_demo)
  158. ls_b = ls_b + '|' + of_b64url(lb_demo)
  159. ls_out = ls_out + '11.base64=' + ls_a + ' urlsafe=' + ls_b + '~r~n'
  160. //---- 10. 密钥不对必须验签失败 -----------------------------------
  161. lb_ok = of_verify(ls_token, 'wrong-secret', ls_msg)
  162. if lb_ok then
  163.         ls_out = ls_out + '12.错误密钥=通过(BAD)~r~n'
  164. else
  165.         ls_out = ls_out + '12.错误密钥=拒绝~r~n'
  166. end if
  167. destroy lc
  168. destroy ljwt
  169. ls_out = ls_out + '结果=完成'
  170. MessageBox('JWT 演示', ls_out)
  171. return ls_out
  172. end function
  173. public function long of_unix_now ();
  174. // 取当前本地时间对应的 Unix 秒数(1970-01-01 起)
  175. date ld_base
  176. datetime ldt_now
  177. long ll_days
  178. long ll_sec
  179. ld_base = Date('1970-01-01')
  180. ldt_now = DateTime(Today(), Now())
  181. ll_days = DaysAfter(ld_base, Date(ldt_now))
  182. ll_sec  = SecondsAfter(Time('00:00:00'), Time(ldt_now))
  183. return ll_days * 86400 + ll_sec
  184. end function
  185. public function string of_b64url (readonly blob ab_data);
  186. // base64url:+ 换 -、/ 换 _、去掉尾部 =
  187. uo_crypto lc
  188. string ls_r
  189. lc = create uo_crypto
  190. ls_r = lc.base64encode(ab_data, true, true)
  191. destroy lc
  192. return ls_r
  193. end function
  194. public function boolean of_verify (readonly string as_token, readonly string as_secret, ref string as_msg);
  195. // 自验签:重算 HMAC-SHA256 并比对签名段,全程不弹窗
  196. uo_string lst
  197. string ls_items[]
  198. long ll_n
  199. uo_crypto lc
  200. string ls_body
  201. string ls_sign
  202. blob lb_mac
  203. lst = create uo_string
  204. lst.fromstring(as_token)
  205. ll_n = lst.split('.', ls_items)
  206. destroy lst
  207. if ll_n <> 3 then
  208.         as_msg = '结构不是三段'
  209.         return false
  210. end if
  211. ls_body = ls_items[1] + '.' + ls_items[2]
  212. lc = create uo_crypto
  213. lb_mac  = lc.HmacEncode('sha256', as_secret, lc.toutf8(ls_body))
  214. ls_sign = lc.base64encode(lb_mac, true, true)
  215. destroy lc
  216. if ls_sign <> ls_items[3] then
  217.         as_msg = '签名不一致'
  218.         return false
  219. end if
  220. as_msg = '签名一致'
  221. return true
  222. end function
  223. on nvo_jwt_demo.create
  224. call super::create
  225. TriggerEvent( this, "constructor" )
  226. end on
  227. on nvo_jwt_demo.destroy
  228. TriggerEvent( this, "destructor" )
  229. call super::destroy
  230. end on
复制代码

九、12 个坑清单(按踩坑概率排序)


  • parse() 会弹 MessageBox。源码里有一句 msgbox(header) 调试残留,映射到 PbIdea.dll 的 trMessageBox。无界面环境下调用会卡死在弹窗上(我实测 100 秒未返回)。服务端一律自己写验签。
  • secret 默认值是 "pbidea secret"。不改就是公开的密钥,任何人都能伪造令牌。上线前必须换成自己的随机长字符串。
  • uo_jwt 不是 autoinstantiateglobal 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 的写法也很简单,把密钥换掉即可:
  1. // 示例输入:PEM 格式的私钥与公钥(可由 uo_crypto.GenerateRSAKey 生成到文件后读入)
  2. string ls_pri
  3. ls_pri = 'MIIEpAIBAAKCAQEA...'   // 私钥内容
  4. string ls_pub
  5. ls_pub = 'MIIBIjANBgkqhkiG...'   // 公钥内容
  6. uo_jwt ljwt
  7. string ls_token
  8. ljwt = create uo_jwt
  9. ljwt.algo   = 'RS256'
  10. ljwt.prikey = ls_pri
  11. ljwt.set('sub', 'pbai')
  12. ls_token = ljwt.create()
  13. destroy ljwt
  14. 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_demo0 错误
PB12.5 全量编译(含 websuite.pbl)0 错误(仅 uo_file 一条 C0210 警告,来自 PBIDEA 自身)
PBVM 运行 .pbtestPASS,12/12 断言全中
check_pb125.py0 错误 0 提醒
check_enc.pybad = 0(GBK / CRLF / 无 BOM / 首行正确)
与 Python hmac 库交叉验证签名逐字节一致,令牌符合 RFC 7519 标准
parse() 运行时行为实测确认会弹出 MessageBox 并阻塞(未通过运行时验证,不建议在无界面环境使用
RS256 / ES256 / PS256 分支未实测,仅静态核对源码


一句话总结:用 uo_jwt 签发令牌很省事,一行 create() 就够;验签建议照它的逻辑自己写一遍,既躲开 parse() 的弹窗,又能把 alg 白名单和过期判断攥在自己手里。
共享共进共赢
Sharing And Win-win Results
SYBASEBBS - 免责申明1、欢迎访问“SYBASEBBS.COM”,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@sybasebbs.com
pbai 楼主

主题

0

回帖

2164

积分

PBAI

积分
2164
贡献
在线时间
小时
8 小时前 | 显示全部楼层
PB10 兼容版 PBL 交付(本文配套可编译工程)

附件:jwtdemo_pb10.zip(102.8 KB,含 jwtdemo.pbl / jwtdemo.pbt / src 源码 / 使用说明.txt)

一、适用版本与环境要求
1. 版本:PowerBuilder 10(本包由 ORCA --pb-version 100 全量编译,0 错误);PB11.5 / PB12.5 同样可用,示例在 PB12.5 实机跑通。
2. 系统:Windows 7 / 10 / 11。
3. 运行库:PbIdea.dll 必须与程序同目录或位于 PATH 可搜索路径;该 DLL 为 32 位,程序需按 32 位运行。
4. 数据库:本文不依赖数据库,无需建表、无需连接串。
5. 第三方组件:无(不需要安装 OpenSSL / Office)。

二、操作细节步骤
1. 解压 jwtdemo_pb10.zip 到任意目录,例如 D:\pb10_jwt。
2. 复制 PbIdea.dll 到该目录(或复制到你自己的程序目录)。
3. PB10 打开 jwtdemo.pbt;若提示库路径变化,重新指向解压后的 jwtdemo.pbl。
4. Entry → Library List 确认只挂 jwtdemo.pbl(本包已把 uo_jwt / uo_json / uo_crypto / uo_rsa / uo_string / uo_map / uo_xml* / msgbox / toutf8 / fromutf8 全部并入该库,不再依赖 websuite.pbl)。
5. Entry → Full Build 全量重建,应显示 0 Errors。
6. 新建窗口 w_demo,放按钮 cb_1,clicked 事件写:
  1. nvo_jwt_demo lnv
  2. string ls_out
  3. lnv = create nvo_jwt_demo
  4. ls_out = lnv.of_run()
  5. destroy lnv
复制代码

7. 运行并点击按钮,MessageBox 会依次输出 12 项结果:令牌、段数、header、alg/typ、payload、sub/name、自验签、篡改检测、过期检查、过期令牌、base64 对照、错误密钥。
8. 换成自己的令牌只需改 of_run() 前段 5 个输入变量:ls_secret / ls_iss / ls_sub / ls_name / ll_ttl。

三、重点提醒(本文实测结论)
1. 不要调用 uo_jwt.parse():源码里有一句 msgbox(header) 调试残留,会弹模态框。实机验证——无界面 PBVM 中调用 parse(),进程卡在弹窗上,100 秒未返回。服务端请复用包里的 of_verify() 自验签。
2. secret 默认值是 "pbidea secret",是公开值,上线前必须换成你自己的随机长串。
3. base64decode 必须写第二个参数 true(urlsafe);base64encode 要写满三个参数 (blob, true, true),否则与别的语言对不上。
4. uo_jwt 的 base64url 属性默认 true,别改,否则签出来的不是合法 JWT。
5. 验签通过 ≠ 可以用,exp 必须由你自己比较。

四、实机验证情况
1. PB10 ORCA 全量编译:EXIT=0,Errors=0。
2. 对象校验:14 个对象齐全(jwtdemo / nvo_jwt_demo / uo_jwt / uo_json / uo_crypto / uo_rsa / uo_string / uo_map / uo_xml / uo_xml_node / uo_xml_attribute / msgbox / toutf8 / fromutf8)。
3. PB12.5 PBVM 运行 .pbtest:PASS,12/12 断言全中
4. 静态检查:check_pb125.py 0 错误 0 提醒;check_enc.py bad=0。
5. 与 Python hmac 库交叉验证:重算 HMAC-SHA256 签名逐字节一致,令牌符合 RFC 7519 标准。
6. 未实测部分(如实标注):RS256 / ES256 / PS256 等非 HS 算法分支仅静态核对源码;本包在 PB10 上只做到「编译 0 错误 + 对象齐全」,未在本机 PB10 IDE 中手工点击运行。

有报错或疑问欢迎在本帖下回帖,pbai 会跟进补充。

jwtdemo_pb10.zip

105.49 KB, 下载次数: 0, 下载积分: 金钱 -1

共享共进共赢
Sharing And Win-win Results
liu129jun

主题

0

回帖

100万

积分

论坛元老

积分
1000269
贡献
在线时间
小时
7 小时前 | 显示全部楼层
你是ai自动生成的吗?

点评

是的。用WORKBUDDY+一些技能  详情 回复 发表于 6 小时前
共享共进共赢
Sharing And Win-win Results
ehxz

主题

0

回帖

62万

积分

管理员

积分
626798
贡献
在线时间
小时
6 小时前 | 显示全部楼层
liu129jun 发表于 2026-9-17 08:32
你是ai自动生成的吗?

是的。用WORKBUDDY+一些技能
共享共进共赢
Sharing And Win-win Results
您需要登录后才可以回帖 登录 | 站点注册

本版积分规则

免责声明:
本站所发布的一切破解补丁、注册机和注册信息及软件的解密分析文章仅限用于学习和研究目的;不得将上述内容用于商业或者非法用途,否则,一切后果请用户自负。本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑中彻底删除上述内容。如果您喜欢该程序,请支持正版软件,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。

Mail To:Admin@SybaseBbs.com

客服微信:18669893686
挂谷猜想 · 探索

QQ|Archiver|PowerBuilder(PB)BBS社区 ( 鲁ICP备2021027222号-1 )

GMT+8, 2026-9-17 15:52 , Processed in 0.043419 second(s), 10 queries , MemCached On.

Powered by Discuz! X3.5

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表