Spring Security OAuth 2 教程 - 4:PKCE 授权码模式

在 “Spring Security OAuth 2 教程 - 3:客户端凭证模式” 中,我们学习了如何通过客户端凭证模式获取访问令牌(access_token)。在本文中,我们将了解如何使用 PKCE 授权码模式。

PKCE 授权码模式

PKCE 授权码模式是 OpenId Connect Flow,主要用于保护本地、移动应用和单页应用 (SPA) 的安全。PKCE 是代码交换证明密钥(Proof Key for Code Exchange)的首字母缩写。

注意

PKCE 授权码模式也可用于保护服务器上运行的 Web 应用的安全。在这种情况下,PKCE 起到了额外保护层的作用。

创建 “public” 客户端

创建一个名为 messages-spa 的新客户端。

  • General Settings
    • Client type:OpenID Connect
    • Client ID:messages-spa
  • Capability config
    • Client authentication:Off
    • Authorization:Off
    • Authentication flow:选中 Standard flow,取消选中其余复选框
  • Login settings
    • Root URLhttp://localhost:3000
    • Home URLhttp://localhost:3000
    • Valid redirect URIshttp://localhost:3000/callback
    • Valid post logout redirect URIshttp://localhost:3000
    • Web originshttp://localhost:3000

使用上述配置创建客户端后,你将进入新创建的客户端 “Settings” 页面。单击 “Advanced” 选项卡,转到 “Advanced Settings” 部分,将 “Proof Key for Code Exchange Code Challenge Method” 值更新为 S256

PKCE 授权码模式获取 Access Token

在使用 PKCE 授权码模式时,首先通过前端(浏览器网址)上的重定向 URL 获取 authorization_code

首先,我们需要生成一个 code verifier,它是一个使用 A-Z、a-z、0-9 和标点符号 -._~(连字符、句号、下划线和斜线)的密码随机字符串,长度在 43 到 128 个字符之间。

生成代 code verifier 后,生成 code challenge,这是 code verifier SHA256 哈希值的 BASE64-URL 编码字符串。

你可以通过 https://www.oauth.com/playground/authorization-code-with-pkce.html 生成 code verifier 和 code challenge。

  • code verifier: M7Q3C-V-_BafRd251gvQLhHw5lYRUYuAlbtT7BCF8cnDiHSV
  • code challenge: gD2zOSHT__2UcU_BkXqqMld3b7TQ764LaPUOXMSDCMw

现在,在浏览器窗口中打开以下 URL:

http://localhost:9191/realms/sivalabs/protocol/openid-connect/auth?
  response_type=code
  &client_id=messages-spa
  &redirect_uri=http://localhost:3000/callback
  &state=randomstring
  &response_mode=query
  &scope=openid
  &code_challenge=gD2zOSHT__2UcU_BkXqqMld3b7TQ764LaPUOXMSDCMw
  &code_challenge_method=S256
  • 然后,你将被重定向到 Keycloak 的登录页面。
  • 使用用户凭证 siva/siva1234 登录。
  • 然后,你将被重定向到包含查询参数 code 的重定向 URI。
http://localhost:3000/callback?
  state=randomstring
  &session_state=d0da5382-4408-4548-af53-fe4fb948c18c
  &code=052aa7cf-a868-4b95-aa59-6a75eaff26d8.d0da5382-4408-4548-af53-fe4fb948c18c.63de5a66-e986-46ca-9b0f-3944529f3ad9

authorization_code052aa7cf-a868-4b95-aa59-6a75eaff26d8.d0da5382-4408-4548-af53-fe4fb948c18c.63de5a66-e986-46ca-9b0f-3944529f3ad9

有了 authorization_code,我们就可以通过 token_endpoint 获取 access_token 了,如下所示:

curl --location 'http://localhost:9191/realms/sivalabs/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'client_id=messages-spa' \
--data-urlencode 'redirect_uri=http://localhost:3000/callback' \
--data-urlencode 'code_verifier=M7Q3C-V-_BafRd251gvQLhHw5lYRUYuAlbtT7BCF8cnDiHSV' \
--data-urlencode 'code=052aa7cf-a868-4b95-aa59-6a75eaff26d8.d0da5382-4408-4548-af53-fe4fb948c18c.63de5a66-e986-46ca-9b0f-3944529f3ad9'

在这里,我们发送的原始 code_verifier 应与 authorization_code 请求中发送的 code_verifier 相匹配。

返回的 JSON 响应应如下所示:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJMeVVPTDg4LVBGM3BYQzFpN3BIeGdFZTJwaWZJY3RyTXJiNklHOElmRTlVIn0.eyJleHAiOjE2OTU1NzAyNjcsImlhdCI6MTY5NTU2OTk2NywiYXV0aF90aW1lIjoxNjk1NTY5OTQxLCJqdGkiOiJkNzNjOTk4MS1jYzhmLTQ2NzItODcwYy03YzI2OTQ3ZDY3ZTkiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkxOTEvcmVhbG1zL3NpdmFsYWJzIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImNhMWEyZjM0LTE2MTQtNDVkZC04NmMxLTVlYWZmZjA4NWQ4YSIsInR5cCI6IkJlYXJlciIsImF6cCI6Im1lc3NhZ2VzLXNwYSIsInNlc3Npb25fc3RhdGUiOiJmZDU1YTdmZi0xNTI2LTQ4MjYtODM1OS00MTFkZTU0ZmJhMWYiLCJhY3IiOiIxIiwiYWxsb3dlZC1vcmlnaW5zIjpbImh0dHA6Ly9sb2NhbGhvc3Q6MzAwMCJdLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiZGVmYXVsdC1yb2xlcy1zaXZhbGFicyIsIm9mZmxpbmVfYWNjZXNzIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6Im9wZW5pZCBlbWFpbCBwcm9maWxlIiwic2lkIjoiZmQ1NWE3ZmYtMTUyNi00ODI2LTgzNTktNDExZGU1NGZiYTFmIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJTaXZhIEthdGFtcmVkZHkiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzaXZhIiwiZ2l2ZW5fbmFtZSI6IlNpdmEiLCJmYW1pbHlfbmFtZSI6IkthdGFtcmVkZHkiLCJlbWFpbCI6InNpdmFAZ21haWwuY29tIn0.f4Ic6ggLBteou71NHzBWaCdcxKaaXnCSM38Glg3Xs1ruQz7uMtMJKewJOpZmCwrvodg0xCF0TZEl-wKWz3CnWEFFE92nIGGp3BIL42Coc4f8_aB_M0YvH3hUQznswYgZLcqvpN3k3e4yA70NfU9LWbNJudBkaLBYCDgPv62_t5620TPyg4cYxjcf2HHoCG4pU3Ms5DX-Zu6tc-aa3RT0uXp7CNzgQF3yqP0kmyu9SY8hkhLV05hdtsc5Szj0mH0e8T55IajM8Z_eYCi20VFaBehohM_Zr6FsP_S69numxhHLBy06JgQd9zUV8DpMPhOC0R4oTla4RpmPZo0B5ApFmw",
  "expires_in": 300,
  "refresh_expires_in": 1774,
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI5N2E1NTU3Ni01MThlLTQ1MDItOWQyNi1jNzVmYjZhNGRhZWEifQ.eyJleHAiOjE2OTU1NzE3NDEsImlhdCI6MTY5NTU2OTk2NywianRpIjoiNDYwMzlmNGQtMjBjZi00OWUxLTk2ZWEtNjI4NmI0MjdiOWQ2IiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo5MTkxL3JlYWxtcy9zaXZhbGFicyIsImF1ZCI6Imh0dHA6Ly9sb2NhbGhvc3Q6OTE5MS9yZWFsbXMvc2l2YWxhYnMiLCJzdWIiOiJjYTFhMmYzNC0xNjE0LTQ1ZGQtODZjMS01ZWFmZmYwODVkOGEiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoibWVzc2FnZXMtc3BhIiwic2Vzc2lvbl9zdGF0ZSI6ImZkNTVhN2ZmLTE1MjYtNDgyNi04MzU5LTQxMWRlNTRmYmExZiIsInNjb3BlIjoib3BlbmlkIGVtYWlsIHByb2ZpbGUiLCJzaWQiOiJmZDU1YTdmZi0xNTI2LTQ4MjYtODM1OS00MTFkZTU0ZmJhMWYifQ.QGgw00X_biM0kMLFDEG14UhjCaDBSuk1K8euqTxQpWk",
  "token_type": "Bearer",
  "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJMeVVPTDg4LVBGM3BYQzFpN3BIeGdFZTJwaWZJY3RyTXJiNklHOElmRTlVIn0.eyJleHAiOjE2OTU1NzAyNjcsImlhdCI6MTY5NTU2OTk2NywiYXV0aF90aW1lIjoxNjk1NTY5OTQxLCJqdGkiOiJjYjViMTQ0Ni0zOGQxLTQwN2ItYjIwYy03ZjQ0Mjg1MmZhYmYiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjkxOTEvcmVhbG1zL3NpdmFsYWJzIiwiYXVkIjoibWVzc2FnZXMtc3BhIiwic3ViIjoiY2ExYTJmMzQtMTYxNC00NWRkLTg2YzEtNWVhZmZmMDg1ZDhhIiwidHlwIjoiSUQiLCJhenAiOiJtZXNzYWdlcy1zcGEiLCJzZXNzaW9uX3N0YXRlIjoiZmQ1NWE3ZmYtMTUyNi00ODI2LTgzNTktNDExZGU1NGZiYTFmIiwiYXRfaGFzaCI6ImRETGgycU1RdU5FT3F4MkRSYVR3cGciLCJhY3IiOiIxIiwic2lkIjoiZmQ1NWE3ZmYtMTUyNi00ODI2LTgzNTktNDExZGU1NGZiYTFmIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsIm5hbWUiOiJTaXZhIEthdGFtcmVkZHkiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzaXZhIiwiZ2l2ZW5fbmFtZSI6IlNpdmEiLCJmYW1pbHlfbmFtZSI6IkthdGFtcmVkZHkiLCJlbWFpbCI6InNpdmFAZ21haWwuY29tIn0.BTuFqcyrjB99R9kBfeLbVRSL5EMtgwS-BSgue77eGmFZxoBMV6UXM2qjWCoF9Wdozy2xmOQcqehvNGpnvR34YHfdrCxymuIZ7jIp7LvNW8sf5ZzsqnCq2OLCKPH4pyWFPqAGfvBYXqzhammNjPITKTKHH11nIcGnDGt7bhfDf-iDj3saDf0CZNkyOmh1Udi3Bkl3O4OBERWbg9xpsNGVks0SDONkDtLSL0IO1ODfgJRq1ZASRdn2i67EMDh5kpLnpKHPj7a1rMZyIUTdhbF4_sODWj3D6STFZxS_FjhcXdjYHLvtLdQBIOmHDjTqEXpwNp5OrVBtEvjgMj4ERNyoMw",
  "not-before-policy": 0,
  "session_state": "fd55a7ff-1526-4826-8359-411de54fba1f",
  "scope": "openid email profile"
}

有了 access_token,资源服务器就可以调用另一个资源服务器的受保护的 API 端点。

PKCE 如何保证安全?

当我第一次看到 PKCE 模式时,我想知道 PKCE 究竟是如何保证安全的?任何人都可以创建 code verifier 和 code challenge,而且不需要知道 client_secret。

你可以参阅 “PKCE 如何保证安全?” 这个问题的答案,以及“PKCE 究竟在保护什么?

使用 Postman

我们可以使用 Postman,通过 “PKCE 授权码模式” 取 access_token,如下所示:

  • 在 Postman 中打开新请求选项卡
  • 转到 Authorization 选项卡,Type 选择 OAuth 2.0
  • Configure New Token 部分:
    • Grant Type:Authorization Code (With PKCE)
    • Callback URLhttp://localhost:3000/callback
    • Auth URLhttp://localhost:9191/realms/sivalabs/protocol/openid-connect/auth
    • Access Token URLhttp://localhost:9191/realms/sivalabs/protocol/openid-connect/token
    • Client ID:messages-spa
    • Client Secret:""(留空)
    • Code Challenge Method: S256
    • Code Verifier: M7Q3C-V-_BafRd251gvQLhHw5lYRUYuAlbtT7BCF8cnDiHSV
    • Scope: profile
    • State: randomstring
    • Client Authentication: Send as Basic Auth header
  • 点击 Get New Access Token 按钮
  • Postman 会在弹出窗口中显示 Keycloak 的登录页面
  • 使用凭证 siva/siva1234 登录
  • 现在你应该可以看到带有 Token 详细信息的响应了

Postman 配置 PKCE授权码模式

如果将 scope 指定为 openid profile,那么也会得到 id_token

总结

在本文中,我们学习了如何通过 PKCE 授权码模式获取 access_token,以及如何使用 Postman 完成同样的操作。

下一章节,我们将了解 “OAuth 2.0 隐式模式和资源所有者密码模式” 的工作原理。


参考:https://www.sivalabs.in/spring-security-oauth2-tutorial-authorization-code-flow-with-pkce/