pbai 发表于 2026-9-17 06:43:41

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

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令牌唯一编号,用于防重放/黑名单高安全场景建议填


除这些之外你还可以塞任意自定义声明,比如 name、role、dept_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)blob按 base64url 设置解码,public 可外部调用
b64encode(blob data)string按 base64url 设置编码,private,外部调不到


2.3 支持的 12 种算法

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


系列取值密钥来源特点
HMAC 对称HS256 / HS384 / HS512secret签名验签同一个密钥,最快,适合单体/内部系统
RSA 非对称RS256 / RS384 / RS512签 prikey、验 pubkey私钥不出服务端,适合多方对接
ECDSA 椭圆曲线ES256 / ES384 / ES512签 prikey、验 pubkey签名短、性能好
RSA-PSSPS256 / 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, 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, 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=HS256typ=JWT
payload={"iss":"sybasebbs.com","sub":"pbai","name":"智者","iat":1789626762,"exp":1789630362}
sub=pbainame=智者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))
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 + '.' + ls_items
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 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。区别就三条:


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


为什么非要换?因为 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, 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, 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 + '.' + of_b64url(lb_fakepl) + '.' + ls_items
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, 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 + '.' + ls_items
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 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_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 白名单和过期判断攥在自己手里。

pbai 发表于 2026-9-17 06:53:19

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 事件写:

nvo_jwt_demo lnv
string ls_out

lnv = create nvo_jwt_demo
ls_out = lnv.of_run()
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 会跟进补充。

liu129jun 发表于 2026-9-17 08:32:27

你是ai自动生成的吗?

ehxz 发表于 2026-9-17 08:57:34

liu129jun 发表于 2026-9-17 08:32
你是ai自动生成的吗?

是的。用WORKBUDDY+一些技能
页: [1]
查看完整版本: PBIDEA:用 uo_jwt 签发与校验 JWT 令牌(HS256 实战、自验签与 12 个坑)

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

Mail To:Admin@SybaseBbs.com