使用 Spring Authorization Server 演示 OAuth 2 授权码许可过程

973 阅读6分钟

一、Spring 对 OAuth 2 的支持计划

作为广泛应用的授权协议,Spring 也早已有过支持,并在 2018 年2019 年分别发布了支持计划,并于 2020 年宣布 Spring Authorization Server 的诞生

Spring Authorization Server 是一个提供 OAuth 2.1(还未正式发布)和 OpenID Connect 1.0 规范及其他相关规范实现的框架。它构建于 Spring Security 之上,为构建 OpenID Connect 1.0 身份提供程序和 OAuth2 授权服务器产品提供了一个安全、轻量级和可定制的基础。

由 Spring Security 主导,由 Spring 社区驱动的项目,通过官方的发文可见社区的力量是强大的,决定立项 Spring Authorization Server 并重写 Spring Security OAuth 的原因是:

  • 最初的 OAuth 支持是在很早的时候完成的,不可能预见到需要使用它的所有不同方式。这促使许多 Spring 项目提供自己的 OAuth 支持。通过重写,能够满足整个 Spring 产品组合的需求,并提供一个统一的 OAuth 库
  • 在最初编写 OAuth 项目时,它包括对 OAuth 1.0 和 OAuth 2.0 的支持。此后,OAuth 2.0 正式取代了 OAuth 1.0。因此,新的支持只关注 OAuth 2.0 就可以了,这可以大大简化代码
  • 原始项目提供了自己所有的库支持。现在,有许多库可供选择,官方通过提供一个名为 Nimbus(connect2id.com/products/ni… 的现有库的抽象,将其集成到 Spring Security 中。通过在 Nimbus 的基础上进行构建,能够更快地移动并添加更多功能,例如更好地支持 JWT、OIDC、反应式编程等。

随着 Spring Security OAuth 的停止维护,Spring Authorization Server 正式接管 OAuth 2 协议中授权服务开发的大旗。

而没有将授权服务也集成到 Spring Security 中是因为自 Spring Security OAuth 项目创建以来,可供选择的授权服务器数量大幅增加。此外,创建授权服务器的场景并不常见但 Spring 生态系统需要授权服务器的支持。Spring Security 团队决定不再正式支持创建授权服务器。

二、Spring Authorization Server 集成

Spring Authorization Server 的入门文档 入门文档对它的集成做了清晰描述,我们则从 demo-authorizationserver 的源码示例 中直接演示使用效果。

1、环境准备

  • IntelliJ IDEA 2023.2.1 (Community Edition)
  • Postman 10.17.3
  • Gradle 7.4.2
  • JDK 17(Spring Boot 3 最低版本要求)
  • Spring Boot 3.1.0
  • Spring Security 6.1.0
  • Spring Authorization Server 1.1.2(如果不从源码示例入手则直接引入这个依赖)

2、Gradle 依赖

示例程序运行的依赖项除了前端模板引擎外,还依赖 Spring Security。

implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-thymeleaf"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-oauth2-client"
implementation "org.springframework.boot:spring-boot-starter-jdbc"
implementation project(":spring-security-oauth2-authorization-server")
implementation "org.webjars:webjars-locator-core"
implementation "org.webjars:bootstrap:5.2.3"
implementation "org.webjars:jquery:3.6.4"
runtimeOnly "com.h2database:h2"

如果不从源码示例入手则直接引入这一个依赖:

implementation "org.springframework.boot:spring-boot-starter-oauth2-authorization-server"

3、默认注册客户端

按照约定,AuthorizationServerConfig 提供一个使用默认配置的客户端。

  • clientId:messaging-client

  • clientSecret:secret

  • 授权许可类型:authorization_code

  • 授权码回调地址:http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc

  • 授权范围:openid,profile,message.read 和 message.write

  • 默认授权服务器使用内存数据库,不做持久化存储

@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
    RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
            .clientId("messaging-client")
            .clientSecret("{noop}secret")
            .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
            .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
            .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
            .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
            .redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
            .redirectUri("http://127.0.0.1:8080/authorized")
            .postLogoutRedirectUri("http://127.0.0.1:8080/logged-out")
            .scope(OidcScopes.OPENID)
            .scope(OidcScopes.PROFILE)
            .scope("message.read")
            .scope("message.write")
            .clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
            .build();

    // ...

    JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(jdbcTemplate);
    registeredClientRepository.save(registeredClient);
    
    // ...

    return registeredClientRepository;
}

如果不直接使用源码示例则要添加这些配置项,效果同上:

server:
  port: 9000

logging:
  level:
    org.springframework.security: trace

spring:
  security:
    oauth2:
      authorizationserver:
        client:
          oidc-client:
            registration:
              client-id: "oidc-client"
              client-secret: "{noop}secret"
              client-authentication-methods:
                - "client_secret_basic"
              authorization-grant-types:
                - "authorization_code"
                - "refresh_token"
              redirect-uris:
                - "http://127.0.0.1:8080/login/oauth2/code/oidc-client"
              post-logout-redirect-uris:
                - "http://127.0.0.1:8080/"
              scopes:
                - "openid"
                - "profile"
            require-authorization-consent: true

4、默认用户

DefaultSecurityConfig 提供一个使用默认密码编码器的用户,密码不做设置也是默认 password 的。

  • 用户名:user1
  • 密码:password
  • 同样使用内存数据库存储用户信息
@Bean
public UserDetailsService users() {
    UserDetails user = User.withDefaultPasswordEncoder()
            .username("user1")
            .password("password")
            .roles("USER")
            .build();
    return new InMemoryUserDetailsManager(user);
}

三、OAuth 2 授权码许可类型示例演示

按照上述步骤(Spring Authorization Server 的 demo-authorizationserver 源码示例)运行程序,就可以快速直观感受到授权码的工作流程。

1、申请授权码

  • 携带客户端 ID,重定向 URI,申请授权范围等参数
  • 模拟客户端通过前端信道访问授权服务的授权端点

构造的 GET 访问请求如下:http://127.0.0.1:9000/oauth2/authorize?client_id=messaging-client&response_type=code&scope=message.read&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc

申请授权 授权服务需要校验申请授权的用户状态,也就是需要保持用户在线,因为是首次访问没有登录状态,则直接重定向到登录页面:http://127.0.0.1:9000/login 。查看请求状态我们也可以看到 Http 状态码为 302。

这个页面是示例程序提供的,支持联合 Google 和 Github 登录。这里用上面预设的用户名密码(user1/password)登录。

2、资源拥有者授权

登录完成后,会再次重定向到授权页面,这里依然是前端信道通信,只不过请求方变成了授权服务,接收方为资源拥有者,或者是用户代理。

  • 客户端生成并携带 state,用于防御 CSRF,这个参数授权服务再重定向返回 code 时会原样返回
  • 客户端携带申请授权范围、state、客户端 ID 等参数通过前端信道访问授权服务的用户授权端点
  • 资源拥有者与这个页面交互给与客户端授权,权限范围是 message.read
  • 提交后,授权服务即认可客户端具备访问 message.read 的权限

确认提交的地址是:http://127.0.0.1:9000/oauth2/consent?scope=message.read&client_id=messaging-client&state=kcENQ2GH8LKEeoBF7L70nqWbzg42fwaBtcbJCuPI4-Q%3D

资源拥有者授权

3、重定向获取授权码

  • 经过授权服务内部的一系列校验后,授权服务会重定向到第一步客户端携带的重定向 URI 上,并携带 code

  • 这也是通过前端信道完成的,授权服务访问客户端的接收授权码的端点

  • 8080 模拟了客户端站点的端口

重定向的访问地址是:http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc?code=wnAnWYbev9qdxN95mZg_N-IeRVvV0LDJO7QwohiSveaHj1raNrQ81j7lTpCg0FO77K93gUNWojnD7IDTq1XHHeFyGhmJ8Y-i_r89gAqbFpTC7GxpO4M-oB7oHXr2W4t0 。这个 code 值就是我们想要的授权码。

重定向获取授权码

一般会在这个页面会做成一个交互友好的提示页面比如授权成功之类的,这里的示例程序做了简化,不影响整体流程。

4、获得授权码换取访问令牌

模拟客户端使用后端信道访问授权服务颁发令牌的端点:http://127.0.0.1:9000/oauth2/token

因为是后端信道通信,可以使用 POST 表单请求。

  • Authorization 使用 Basic Auth 模式,也就是客户端的用户名密码(messaging-client/secret)
  • Body 选择 form-data,参数包括授权许可类型、重定向 URI 和授权码等

授权码换取访问令牌

最后,就拿到了访问令牌 access_token,从授权服务一起返回的还有刷新令牌、令牌类型(OAuth 2 默认都是 Bearer)、过期时间等。后面访问受保护资源就可以借助访问令牌通过了。

Spring Authorization Server 提供的示例程序使用的令牌可以通过 JWT 解析,请求头是这样的:

{
  "kid": "d85c6087-8df3-4198-a4da-7a868a55ddca",
  "alg": "RS256"
}

请求体是这样的:

{
  "sub": "user1",
  "aud": "messaging-client",
  "nbf": 1693204082,
  "scope": [
    "message.read"
  ],
  "iss": "http://127.0.0.1:9000",
  "exp": 1693204382,
  "iat": 1693204082
}