把 authorization code + PKCE 流程跑完整
目标
在不使用 SDK 的情况下从头到尾执行 authorization code + PKCE 流程,并通过失败案例确认 PKCE 和 state 分别防止什么攻击。
为什么重要
使用 OIDC 库时,这套流程只是两次函数调用。因此,人们往往在不了解具体过程的情况下使用它,出现问题时也不知道该查看哪里。在本实验中手动执行各个阶段后,会明确三点。第一,授权码会暴露在浏览器 URL 中,但为什么仅凭授权码毫无用处。第二,不知道 code_verifier 时为什么交换会失败——你将在第 6 步亲自使其失败。第三,没有 state 时,攻击者为什么能让受害者登录到攻击者自己的账号。完成最后的刷新轮换后,还能理解实际工作中推荐的组合(短期访问令牌 + 刷新令牌轮换)为什么无需黑名单也能产生失效效果。
Keycloak 启动需要 40~90 秒,因此完成前面的实验后,它通常已经准备就绪。
步骤
- 使用
/root/pk/pkce.py生成code_verifier(43~128 个字符,使用 URL 安全字符)和code_challenge,分别以单行保存到/root/pk/verifier.txt和/root/pk/challenge.txt。 - 在
/root/pk/authz_url.txt中写入一行授权请求 URL。目标是预先导入的 realmlabhub中的 public clientweb-app,redirect URI 为http://127.0.0.1:8161/callback。必须包含response_type=code、client_id、redirect_uri、scope=openid、state、code_challenge、code_challenge_method=S256。 - 保持 cookie jar,GET 该 URL,并将登录表单的
actionURL 保存到/root/pk/action.txt。它必须以http开头。 - POST 用户
dev1/ 密码Dev1!pass,从重定向响应的Location中提取授权码,保存到/root/pk/code.txt。长度必须至少为 20 个字符。 - 向令牌端点发送
grant_type=authorization_code、授权码、redirect_uri、client_id、code_verifier以获取令牌。/root/pk/tokens.json中必须包含access_token和refresh_token。 - 获取新的授权码,尝试使用错误的
code_verifier进行交换。/root/pk/pkce_fail.json中的error必须是invalid_grant。 /root/pk/state_check.py接收两个参数(发送的 state、收到的 state),相同则以退出码 0 结束,不同则以 1 结束。在/root/pk/state.out中将匹配与不匹配两种结果写为match=ok mismatch=rejected。- 使用刷新令牌进行刷新,生成
/root/pk/refresh.json。在/root/pk/rotation.txt中写入rotated=<true|false>。判断依据是新刷新令牌是否与旧令牌不同。
参考
- challenge 计算方式:
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=') - 保持 cookie:
curl -c /root/pk/cookies -b /root/pk/cookies ... - 若要查看 Location header,不应跟随重定向(使用
-i,不要使用-L)。 - 常见错误 1:将哈希结果转换成十六进制字符串后再进行 Base64。应编码二进制 digest。
- 常见错误 2:令牌交换中的
redirect_uri与授权请求时不同,导致invalid_grant。
创建 code_verifier 和 challenge
使用 /root/pk/pkce.py 生成 code_verifier(43~128 个字符,使用 URL 安全字符)和 code_challenge,分别以单行保存到 /root/pk/verifier.txt 和 /root/pk/challenge.txt。
规范规定了长度限制和字符集。不能把哈希结果直接进行普通 Base64;必须使用 URL 安全编码,并去掉 padding。
组装授权请求 URL
在 /root/pk/authz_url.txt 中写入一行授权请求 URL。目标是预先导入的 realm labhub 中的 public client web-app,redirect URI 为 http://127.0.0.1:8161/callback。必须包含 response_type=code、client_id、redirect_uri、scope=openid、state、code_challenge、code_challenge_method=S256。
有六个必填参数。缺少任意一个,认证服务器都会返回错误。
提取登录表单的 action
保持 cookie jar,GET 该 URL,并将登录表单的 action URL 保存到 /root/pk/action.txt。它必须以 http 开头。
GET 授权端点会返回 HTML。必须保持 cookie,下一步才能继续。
POST 凭据以获得授权码
POST 用户 dev1 / 密码 Dev1!pass,从重定向响应的 Location 中提取授权码,保存到 /root/pk/code.txt。长度必须至少为 20 个字符。
请从 HTML 中确认表单字段名称。成功后,重定向响应的位置 header 中会包含授权码。
将授权码交换为令牌
向令牌端点发送 grant_type=authorization_code、授权码、redirect_uri、client_id、code_verifier 以获取令牌。/root/pk/tokens.json 中必须包含 access_token 和 refresh_token。
同时发送授权码和原始 verifier。redirect URI 也必须与首次请求时完全相同。
使用错误 verifier 确认失败
获取新的授权码,尝试使用错误的 code_verifier 进行交换。/root/pk/pkce_fail.json 中的 error 必须是 invalid_grant。
这就是 PKCE 确实生效的证据。请记录错误码。
实现 state 不匹配验证
/root/pk/state_check.py 接收两个参数(发送的 state、收到的 state),相同则以退出码 0 结束,不同则以 1 结束。在 /root/pk/state.out 中将匹配与不匹配两种结果写为 match=ok mismatch=rejected。
比较授权请求中发送的值与回调返回的值。不同就必须拒绝。
确认刷新与轮换
使用刷新令牌进行刷新,生成 /root/pk/refresh.json。在 /root/pk/rotation.txt 中写入 rotated=<true|false>。判断依据是新刷新令牌是否与旧令牌不同。
刷新后会返回新的刷新令牌。它与旧令牌相同还是不同,决定是否发生轮换。