在已有OAuth2的Spring项目中添加Open ID Connect支持

本文在做什么:

你可能见过很多网站,允许你使用 github、微信 等登录,于是有一天,你想自己也提供一种登录方式,例如:

img.png

本文不是实现像上图一样的登录界面,而是实现类似于 github 一样的身份认证服务提供者,让你可以在 gitea 等一众开源软件上,使用已有的账户登录。

就像国外如日中天的 okta,国内各自为战的众模仿者一样。

声明在先

我不认可动辄就阅读源码、深入底层原理,书名可以这么起,人可以要求自己这么做,在满足以下等同条件的时候,他也可以要求别人这么做:
1. 他是 spring 的核心贡献者,你们在讨论如何修改社区上的 issue
2. 他一个人创造了 rust,或任意一个在开源社区上值得人们阅读源码或深入了解底层原理的项目,你希望加入他的项目贡献代码
3. 不懂源码确实解决不了业务问题的地步(而这一般都是问题驱动的)
否则,他不应该如此要求别人。阅读源码只是在构建求职壁垒以保护自己,又或者只是内卷的帮凶而已。

然而很遗憾,本文确实是一个问题驱动的产物,会贴不少源码:
1. 我在一个项目中需要实现一个统一的认证服务器,用于多个应用认证
2. 我之前实现过了oauth2认证,现在需要OIDC支持
3. 我已经忘记了如何将oauth2和redis、国内钟爱的手机短信验证、图片验证码、微软Authenticator等一系列结合起来从头做一个生产的项目,我不能再忘记OIDC,需要沉淀下来这块知识

大量的文档都是从0开始实现一个不能在生产中使用的示例,因此,这篇文章确实会有很多的源码,配置截图,以及DEBUG过程。

前提条件

本文假设你已经基于 SpringSecurity 实现了 OAuth2 登录。

如果你手头没有这样的项目,你可以在 github 上搜索 spring oauth2 demo,或者 spring 官网的示例项目。

你可以以国内开源的各种管理后台作为入门,比如若依,pig,不过它们一般都进行了深度的定制,作为不了解认证模块的你很有可能在一堆可能过度设计的代码中晕头转向,但是熟练的技术工人用它们作为脚手架很适合。

这篇文章,将基于 pig 的 springboot 单体版本

如果你不了解 OAuth2 的一些基本概念,必须先看本文 附录 部分,该部分将会介绍什么是RO,RS,AS,四种角色和四种授权模式。

本文将演示如何在此基础上,将授权服务器改造成一个 OpenID Provider(OP,就像 GitHub 那样),以便让更多开源软件,例如gitea,discourse,或我们自己开发的独立软件,使用一套用户资源实现统一认证。

从身份信息的消费者向生产者转变,一朝翻身做主人,做中国的okta,让阿里、腾讯无地自容:

本文以密码模式为例,在其基础上添加 OIDC 支持,当然,添加OIDC支持,跟你使用何种模式无关,毕竟AS可以同时支持多种模式。

原有的密码模式

username: some_user
password: some_password
clientId: some_app

发出的请求

curl --location --request POST 'http://127.0.0.1:8080/oauth2/token' \
--header 'User-Agent: Apifox/1.0.0 (https://apifox.com)' \
## client id 在此处传入。由于实现不同,你可能直接在 url 中拼接,此处是加密的 client_id=my_client
--header 'Authorization: Basic Z2FtZWJveGVsZWN0cm9uOmdhbWVib3hlbGVjdHJvbg==' \
--header 'Accept: */*' \
--header 'Host: 127.0.0.1:9999' \
--header 'Connection: keep-alive' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'username=some_user' \
## 密码实际上是加密的,此处简化了
--data-urlencode 'password=some_password' \
--data-urlencode 'grant_type=password' \
--data-urlencode 'scope=server'

返回的响应

{
    "sub": "some_user",
    "clientId": "some_app",
    "iss": "https://nobody.com",
    "token_type": "Bearer",
    "access_token": "bZkzkshj_fEUEspwIfttnqu-Zz-wdPU_kPds9fKOeHuJ_Ymvvl919nR9hlpqYaGHS6iKVneKqJoNIJfEvduz-PJJ7QVkSp7vvbz3jDdJYbmlsj8ThDcFH2G3siCIh_SM",
    "refresh_token": "NRMmnZPRX4f1kxVM-Btr9ZpBZYO0Tq45Su9eu1YhKAFogXKJ8NIkF0cbbBd8cWdasimYMZsD8NAvd9cHjXoocgpG2ebouHd9HFiJf3ufz0HNVwIXDypSVqnullsJpMfe",
    "aud": [
        "some_app"
    ],
    "license": "https://nobody.com",
    "nbf": "2024-09-02T03:03:06.772697Z",
    "user_info": {
        "password": null,
        "username": "some_user",
        "authorities": [],
        "accountNonExpired": true,
        "accountNonLocked": true,
        "credentialsNonExpired": true,
        "enabled": true,
        "attributes": {},
        "id": "1807571269624586241",
        "deptId": null,
        "phone": null,
        "name": ""
    },
    "user_id": "1807571269624586241",
    "scope": [
        "server"
    ],
    "exp": "2024-09-02T15:03:06.772697Z",
    "expires_in": "43199",
    "iat": "2024-09-02T03:03:06.772697Z",
    "jti": "9b7f2da7-31bc-4744-8984-cc0eeea7ea34",
    "username": "some_user"
}

支持 OpenID Connect,以下简称 OIDC

图片来自 https://www.cnblogs.com/zlt2000/p/15346177.html

img.png

TODO

附录

OAuth2 的四个角色

张三登录微博举例:

  • resource owner(简称RO):

存在数据库中的用户信息(protected resource)不属于你,属于的是每个用户,这些用户就是 resource owner,他有权、有能力决定授权这些资源。

也就是张三

  • resource server(简称RS):

包含各种 api 接口,但最重要的是能够接收 access_token,如果 token 正确,返回 protected resource。

也就是你登录微博后能看到的那些东西所在的服务器

  • client:

代表用户 resource owner 请求这些信息的程序

也就是手机里的微博APP

  • authorization server(简称AS):

用户认证后,发放 access_token 给客户端

也就是微博用户授权模块

通用流程

     +--------+                               +---------------+
     |        |--(A)---需要访问加密资源,请登录-->|   Resource    |
     |        |                               |     Owner     |
     |        |<-(B)--------- 同意 -----------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(C)---- 跳转授权服务器登录 ---->| Authorization |
     | Client |                               |     Server    |
     |        |<-(D)------- 返回令牌 ----------|               |
     |        |                               +---------------+
     |        |
     |        |                               +---------------+
     |        |--(E)-- 拿到令牌了,请求资源 ---->|    Resource   |
     |        |                               |     Server    |
     |        |<-(F)---------给你-------------|               |
     +--------+                               +---------------+

                     图表 1: 抽象流程
  • 在 B C 这两步,上面的图是抽象的,抽象的意思,就是它只定义了框架,但是没有细节。
  • D 步返回的令牌是什么?是授权码,还是access_token?

是access_token,因为这个图是抽象的,不涉及具体的实现模式。它只是告诉你,在这一步 client 和 Authorization Server 交互的最终目的,是为了拿到 access_token。

而所谓的授权码,是在 B C 两步之间的中间产物。

关于 B C 两步的实现,有大家耳熟能详的四种grant_type,见下文

细分 OAuth2 的四种模式的流程

Authorization Code(response_type=code)

张三登录 IDEA为例(如果你不使用JetBrains的产品,你可以类比VSCode中登录GitHub或微软账号。注意他们可能使用的是 Implicit 模式,但是不用在意这些细节,对于我们理解授权码模式没有影响),IDEA(Client)会打开一个网页(Authorization Server),让张三登录。

  1. 步骤A: 在本例中,IDEA没有直接问张三要密码,而是打开 JetBrains 的网站要张三登录。

网站的地址形如:

https://auth_server.com/oauth2/authorize?client_id=客户端ID&redirect_uri=客户端回调地址&response_type=code&scope=权限范围&state=一串随机字符串
一个 RS 的 SecurityFilterChain 很有可能写了这些代码:
java
http.authorizeHttpRequests(authorizeRequests -> ...)
.oauth2ResourceServer(...)
.exceptionHandling((e) -> e.defaultAuthenticationEntryPointFor(new LoginUrlRedirectBuilder("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))

这段代码在请求资源但是其实没登录的时候,会将你重定向到登录界面。这个 LoginUrlRedirectBuilder 是我重写的,用于实现动态获取 client_id,你可以使用官网文档中的 LoginUrlAuthenticationEntryPoint

  1. 步骤B+步骤C:张三成功登录,并且因为张三只在Authorization Server输入了密码,Client感知不到,因此是安全的。

  2. 仍然是步骤C: 登录成功后,Authorization Server会给张三的 IDEA 发送一个授权码,所以这叫授权码模式。如果注册的客户端要求用户必须点同意授权才可以跳转,则会进入确认授权界面:
    代码来源:OAuth2AuthorizationCodeRequestAuthenticationProvider.java
    “`java
    OAuth2AuthorizationConsent currentAuthorizationConsent = this.authorizationConsentService // 一般会自己实现一个将用户同意信息放入 redis 的 authorizationConsentService
    .findById(registeredClient.getId(), principal.getName());
    if (currentAuthorizationConsent != null) {
    authenticationContextBuilder.authorizationConsent(currentAuthorizationConsent);
    }

    if (this.authorizationConsentRequired.test(authenticationContextBuilder.build())) {  // 判断是否需要显示同意授权界面
        if (promptValues.contains(OidcPrompts.NONE)) { // OIDC 无需同意授权
            // Return an error instead of displaying the consent page
            throwError("consent_required", "prompt", authorizationCodeRequestAuthentication, registeredClient);
        }
    

    “`
    而同意授权界面的访问地址在你SecurityFilterChain中定义,如:

java
// @formatter:off
http.with(authorizationServerConfigurer
.authorizationEndpoint(authorizationEndpoint -> authorizationEndpoint.consentPage("/authorize_confirm")), Customizer.withDefaults())
// @formatter:on

  1. 仍然是步骤C:IDEA 拿到授权码,发送 rest 请求,带着授权码向 Authorization Server 请求令牌,具体来讲,是access_token。

  2. 此时才到步骤 D,返回令牌。

此时的流程变更为:

“`text
+——–+ +—————+
| |–(A)—-要用该功能,需要登录—->| Resource |
| | | Owner |
| |<-(B)——– 去登录 ———–| |
| | +—————+
| |
| | +—————+
| |–(B)—- 跳转授权服务器 —–>| Authorization | b.com/oauth2/authorize…redirect_uri=a.com/callback
| Client |<-(C)—–登录成功返回授权码—- | Server | a.com/callback&code=xyz
| |–(C)—- 拿授权码获取令牌 —->| |
| |<-(D)——- 返回令牌 ——— +—————+
| |
| | +—————+
| |–(E)– 拿到令牌了,请求资源 —->| Resource |
| | | Server |
| |<-(F)———给你————-| |
+——–+ +—————+

                 图表 2: 授权码的实现流程

“`

请求 url 的构成

client_id 会被用来在 Authorization Server 验证,是否是合法的客户端:你也不想你开发了一个认证服务器,所有人都可以用吧?你会选择只允许你自己心爱的 app 来用,不是吗?

至于如何验证是否是合法的客户端,这个随意。你可以在管理后台或数据库、application.yml、动态配置等中写好白名单,其它一概不允许访问。

scope,就是本次客户端请求的权限。scope 容易和另外一个概念搞混,claim。这里先简单写一下,具体要到代码中见真章,只需要记住:

scope 一般长这样:”read:order”(允许读取订单), “openid”(OIDC预留), “only_allow_read_A”, “”only_allow_read_B” 等等,而 claim 会作为结果以键值对存在:

"name": "Evan Zhao",
"nickname": "tb",
"updated_at": "2042-03-30T15:13:40.474Z",
"email": "this_is_not_my@email.com",

如果开发过 client,对接过 github 服务的同学,还会知道,你应该在 github 的开发者设置中,事先注册好自己的 client(OAuth App),除了生成 client_id,还要填写好这个 client 的回调地址。

img_1.png

细心的同学可能会发现了,既然 client 发往 AS 的请求带着回调地址,为什么在 github 注册应用的时候,还要我提供回调地址呢?AS不能根据 client 提供的回调地址直接发授权码吗?

你看支付宝付款,它虽然也提到了回调地址,但是不填也没事啊?支付宝可以不填回调地址,一是因为收款结果你自己不要那随便你,二是它也提供了主动请求接口去查询支付结果。但是授权码可不一样。

为什么要填两次回调地址

看段代码:

if (!isLoopbackAddress(requestedRedirect.getHost())) {
    if (!registeredClient.getRedirectUris().contains(requestedRedirectUri)) {
        throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI,
                authorizationCodeRequestAuthentication, registeredClient);
    }

这段代码节选自spring-projects/spring-authorization-server

看起来,在发放授权码之前,authorization-server在核对以下两者:

  1. 客户端请求中的回调地址
  2. 事先在authorization-server中注册客户端的时候填的回调地址

并且,它是用注册的地址.contains(传过来的回调地址)这种形式来判断。

那么为什么要核对这个地址呢,以及为什么必须要用这种方式核对?

这块在下面的三个链接中都有描述。

如何攻击Authorization Server以及Authorization Server必须有哪些默认实现
授权码服务器该如何校验回调地址以及为什么
必须用完全匹配校验回调地址

这里面讲到,攻击者可以是A1,A2,A3,A4,A5种等级,等级越高,能控制的资源、角色(上面说到的四个角色)越多,越能更多地参与到整个认证流程中来。

A1 控制一些服务器、浏览器、开发了一些网站、程序等,用户会访问它们,攻击者借此诱导用户;有些人也会揣测授权码的规律,然后自己去兑换令牌

A2 会监听、篡改、仿冒、阻止任意请求,但是破解不了 tls 传输的内容

高端玩家进场,(代理服务器、公共热点、电信运营商,国家自助的黑客等,此时 tls 传输已经无效了,因为用户的证书可能是他们提供的):

A3 攻击者可以读但是不能修改authorization response,比如注册成同样 URI 的手机应用,等等,这就是我们这一节的答案,授权码会被传给任意回调地址的话,那么他就可能传给了攻击者。

A4 攻击者可以读但是不能修改authorization request。

A5 攻击者可以获取到令牌,他们可能已经黑进了RS,或者通过社会工程控制了RO的信息、设备

A3 A4 A5 通常伴随着 A1 和 A2 的行为。比如一个攻击者诱导用户跳转到恶意网站发起了一个请求:

GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=9ad67f13
     &redirect_uri=https%3A%2F%2Fattacker.example%2F.somesite.example
     HTTP/1.1
Host: server.somesite.example

他此时是 A1. 但是他在attacker.example接收到授权码后,将会变成A3.

所以 AS 不仅要确保核对回调地址,还要精准校验回调地址,确保授权码不会泄露。所以,要判断注册到AS中的客户端地址是否包含请求中的地址,而不是反过来。

那么为什么不直接判断两个字符串是否equals呢?

因为请求中的地址,可能需要包含一些随机值,比如state,它用来防止csrf攻击:

它由client传给AS,AS再原样返回回来,关联起本次请求、RO和授权码,确保了你授权给了你自己,而不是别人。

这就是为什么只能用包含,而不能完全精准匹配。

Implicit(response_type=token)

我没有使用过 Implicit 模式,以下内容来自 rfc6749,请谨慎识别。

对于没有后端服务可以与 AS 交互的应用,如纯前端浏览器项目,会略过授权码,在用户同意后,AS 会直接向 client 发放令牌。

这种模式下,AS 不会校验客户端,因为是浏览器应用,令牌也可能会在 URI 的锚点中(redirect.com/callback#token=xxx) 中传输,会被暴露给 RO 或其它有权限访问 user agent 的程序,注意,这里的 user agent 不仅仅指那些浏览器,它可以是任意可以发起请求的程序。

你可能会想,暴露给RO也就是用户本人有什么问题?记住用户本人也可能是攻击者,按照规范,access_token(和其它任何token)必须保密存储。

另外,客户端应该只请求最小权限(scope)的 token,AS 根据请求的客户端(可以通过redirect_uri地址来判断),来拒绝一部分 scope 的请求,只返回给它需要的那些。

resource owner password(response_type=password)

这个是大部分应用最常见的模式,拿用户名密码换取令牌。

客户端发起 rest 请求:

https://authorization_server.com/oauth2/token?
  grant_type=password&
  username=USERNAME&
  password=PASSWORD&
  client_id=CLIENT_ID

服务端直接返回 access_token,非常简单。

用户必须充分信任客户端,才能将用户名和密码交给客户端。

client credentials(response_type=client_credentials)

这种模式我也没有使用过。

它适用于服务端平台和平台之间的认证,并且粒度是应用级别,并不是用户级别。

客户端只需要传递 client_id 和 client_secret,AS将会直接发放 access_token。