15.5 授权服务器与资源服务器
前面的 GitHub 授权登录主要向大家展示了 OAuth2 中客户端的工作模式。对于大部分的开发者而言,日常接触到的 OAuth2 都是开发客户端,例如接入 QQ 登录、接入微信登录等。不过也有少量场景,可能需要开发者提供授权服务器与资源服务器,接下来我们就通过一个完整的案例向大家演示如何搭建授权服务器与资源服务器。
搭建授权服务器,我们可以选择一些现成的开源项目,直接运行即可,例如:
- Keycloak:RedHat 公司提供的开源工具,提供了很多实用功能,例如单点登录、支持 OpenID、可视化后台管理等。
- Apache Oltu:Apache 上的开源项目,最近几年没怎么维护了。
这里随便举出两例,类似的开源项目很多(这也是 Spring Security 官方一开始说不提供授权服务器的原因之一)。企业应用中,建议使用成熟的开源项目搭建授权服务器。
当然我们也可以使用 Spring Security 最新发布的 Spring Authorization Server 来搭建授权服务器。截至本书写作时,spring-authorization-server 发布了 0.0.1 版,但是这个版本功能较少而且问题较多,因此这里依然使用较早的 spring-security-oauth2 来搭建授权服务器,可能有一些类过期了,不过这不影响的大家理解授权服务器的功能。
接下来我们将搭建一个包含授权服务器、资源服务器以及客户端在内的 OAuth2 案例。
15.5.1 项目规划
首先把项目分为三部分:
- 授权服务器:采用较早的 spring-security-oauth2 来搭建授权服务器。
- 资源服务器:采用最新的 Spring Security 5.x 搭建资源服务器。
- 客户端:采用最新的 Spring Security 5.x 搭建客户端。
同时为了避免测试时互相影响,我们需要修改电脑的 hosts 文件,在 hosts 文件中增加如下解析规则:
127.0.0.1 auth.javaboy.org 127.0.0.1 res.javaboy.org 127.0.0.1 client.javaboy.org
- auth.javaboy.org:表示授权服务器域名。
- res.javaboy.org:表示资源服务器域名。
- client.javaboy.org:表示客户端域名。
为了完整地演示 OAuth2 案例,我们一共需要三个项目,其中:
- 授权服务器端口为 8881。
- 资源服务器端口为 8882。
- 客户端端口为 8883。
项目规划完成。
15.5.2 项目搭建
15.5.2.1 授权服务器搭建
创建一个名为 auth_server 的 Spring Boot 项目,引入 Web 依赖和 spring-security-oauth2 依赖,代码如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.5.0.RELEASE</version> </dependency>
接下来提供一个 Spring Security 的基本配置:
为了方便起见,这里的用户直接创建在内存中,一共两个用户 javaboy/123 和 sang/123,角色分别是 ADMIN 和 USER。这里配置的用户就是我们项目的用户,例如用 GitHub 登录第三方网站,在这个过程中,需要先从 GitHub 获取授权,登录 GitHub 需要用户名/密码信息,这里配置的用户相当于 GitHub 的用户。
另外,由于我们希望让这个授权服务器同时支持授权码模式、简化模式、密码模式以及客户端模式,在支持密码模式时,需要用到 AuthenticationManager 实例,所以在这里暴露出一个 AuthenticationManager 实例。
基本的用户信息配置完成后,接下来我们来配置授权服务器:
这段配置比较长,我们来逐个解释一下:
- 注释 1 中配置了一个 TokenStore 的实例,这是配置生成的 Access Token 要保存到哪里,可以存在内存中,也可以存在 Redis 中,如果用到了 JWT,就不需要保存了。这里我们配置的实例是 InMemoryTokenStore,即生成的令牌存在内存中。
- 注释 2 创建了一个 AuthorizationServer 类继承自 AuthorizationServerConfigurerAdapter,用来对授权服务器做进一步的详细配置,配置类上通过 @EnableAuthorizationServer 注解开启授权服务器的自动化配置。在该配置类中,主要重写三个 configure 方法。
- 注释 3 配置向 Spring 容器中注册了一个 AuthorizationServerTokenServices 实例,该实例主要配置了生成的 Access Token 的一些基本信息:例如 Access Token 是否支持刷新、Access Token 的存储位置、Access Token 的有效期以及 Refresh Token 的有效期等。
- 注释 4 配置了令牌端点的安全约束,这里设置了 checkTokenAccess 端点可以自由访问。该端点的作用是当资源服务器收到 Access Token 之后,需要去授权服务器校验 Access Token 的合法性,就会访问这个端点。
- 注释 5 配置客户端的详细信息,需要提前在这里配置好客户端信息,这就类似于 GitHub 第三方登录时,我们需要提前在 GitHub 上注册我们的应用信息。客户端的信息可以存在数据库中,也可以存在内存中,这里保存在内存中,分别配置了客户端的 id、secret、授权类型、授权范围以及重定向 uri。OAuth2 四种授权类型不包含 refresh_token 这种类型,但是在 Spring Security 实现中,refresh_token 也被算作一种。
- 注释 6 配置授权码服务和令牌服务。authorizationCodeServices 用来配置授权码(code)的存储,tokenServices 用来配置令牌的存储。
最后将该项目的端口修改为 8881。至此,我们的授权服务器就算搭建成功了。
15.5.2.2 资源服务器搭建
在资源服务器搭建之前,我们需要了解 Access Token 令牌,它可以分为两种:
- 透明令牌,如 JWT。
- 不透明令牌。
透明令牌是指令牌本身就携带了用户信息,不透明则是指令牌本身是一个无意义的字符串。如果是透明令牌,如 JWT,那么资源服务器在收到令牌之后,可以自行解析并校验;如果是不透明令牌,那么资源服务器在收到令牌之后,就只能调用授权服务器的端口去校验令牌是否合法。由于前面搭建的授权服务器使用的是不透明令牌,所以这里资源服务器中对令牌的处理也按不透明令牌来处理。
接下来开始搭建资源服务器,我们采用目前最新的方案来搭建。
首先创建一个名为 res_server 的项目,添加 Web、Spring Security 以及 OAuth2 Resource 依赖,最终的 pom.xml 文件内容如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.nimbusds</groupId> <artifactId>oauth2-oidc-sdk</artifactId> <version>6.23</version> </dependency>
项目创建成功后,在 application.yml 文件中配置令牌解析路径以及客户端 id 和 secret:
spring: security: oauth2: resourceserver: opaque: introspection-uri: http://auth.javaboy.org:8881/oauth/check_token introspection-client-id: my_client introspection-client-secret: 123 server: port: 8882
introspection-uri 属性配置的就是令牌校验地址,客户端从授权服务器上申请到令牌之后,拿着令牌来资源服务器读取数据,资源服务器收到令牌后,调用该地址去校验令牌是否合法。
接下来配置资源服务器:
@Configuration public class OAuth2ResourceServerSecurityConfiguration extends WebSecurityConfigurerAdapter { @Value("${spring.security.oauth2.resourceserver.opaque.introspection-uri}") String introspectionUri; @Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-id}" ) String clientId; @Value("${spring.security.oauth2.resourceserver.opaque.introspection-client-s ecret}") String clientSecret; @Override protected void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .anyRequest().authenticated() .and() .oauth2ResourceServer() .opaqueToken().introspectionUri(introspectionUri) .introspectionClientCredentials(clientId, clientSecret); } }
将 application.yml 中配置的三个属性注入进来,然后在 configure(HttpSecurity) 方法中开启不透明令牌的配置,传入三个相关的参数即可。
最后再定义一个测试接口,代码如下:
@RestController public class HelloController { @GetMapping("/hello") public String hello() { return "hello res server"; } }
至此,我们的资源服务器就算配置成功了。
15.5.2.3 客户端应用搭建
客户端应用搭建和我们前面 GitHub 授权登录比较像。
创建一个名为 client01 的 Spring Boot 项目,引入 Web、Spring Security、Thymeleaf 以及 OAuth2 Client 依赖,在之前旧的 OAuth2 Client 中,负责发送网络请求的是 OAuth2RestTemplate,但是在目前的最新方案中,OAuth2RestTemplate 被 WebClient 所替代,所以我们还需要在项目中引入 WebFlux,最终的 pom.xml 文件内容如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webflux</artifactId> </dependency> <dependency> <groupId>io.projectreactor.netty</groupId> <artifactId>reactor-netty</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity5</artifactId> </dependency>
由于我们要在这个项目中同时演示授权码模式、客户端模式以及密码模式,所以接下来在 application.yml 中对三种授权模式所需要的参数分别进行配置:
server: port: 8883 spring: security: oauth2: client: registration: auth-code: provider: javaboy client-id: my_client client-secret: 123 authorization-grant-type: authorization_code redirect-uri:http://client.javaboy.org:8883/login/oauth2/code/javaboy scope: read:msg client-creds: provider: javaboy client-id: my_client client-secret: 123 authorization-grant-type: client_credentials scope: read:msg password: provider: javaboy client-id: my_client client-secret: 123 authorization-grant-type: password scope: read:msg provider: javaboy: authorization-uri: http://auth.javaboy.org:8881/oauth/authorize token-uri: http://auth.javaboy.org:8881/oauth/token
这里提供了三个客户端,名字分别是 auth-code(授权码模式)、client-creds(客户端模式)以及 password(密码模式),三个客户端中指定了各自的参数,大家可以对照 15.2 节去理解这些参数,这里不再赘述。
另外还提供了一个名为 javaboy 的 provider,并配置了授权服务器的认证地址以及令牌获取地址。
接下来我们需要提供一个 WebClient 实例,利用 WebClient 可以方便地发起认证请求,这也是最新的 OAuth2 Client 推荐的方式。如果不用 WebClient,那在发起请求时需要开发者自己去拼接各种参数,比较麻烦。
这里主要提供了两个 Bean:WebClient 和 OAuth2AuthorizedClientManager。前者用来发起网络请求,在 WebClient 配置时,需要用到 OAuth2AuthorizedClientManager 实例。
OAuth2AuthorizedClientManager 主要用来管理授权的客户端,它的职责是通过 OAuth2AuthorizedClientProvider 对不同的客户端进行授权。不同的授权模式会对应不同的 OAuth2AuthorizedClientProvider 实例,例如:
- 授权码模式对应 AuthorizationCodeOAuth2AuthorizedClientProvider。
- 密码模式对应 PasswordOAuth2AuthorizedClientProvider。
- 客户端模式对应 ClientCredentialsOAuth2AuthorizedClientProvider。
- 刷新令牌对象 RefreshTokenOAuth2AuthorizedClientProvider。
通过 OAuth2AuthorizedClientProviderBuilder 来构建所需要的 OAuth2AuthorizedClient Provider 实例,并添加到 OAuth2AuthorizedClientManager 对象中。
另外,由于密码模式还需要用到用户输入的用户名/密码,所以这里通过 contextAttributes Mapper 将请求中的用户名/密码提取出来存入 contextAttributes 中。
接下来提供 SecurityConfig,代码如下:
@Configuration public class SecurityConfig extends WebSecurityConfigurerAdapter { @Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .anyRequest().authenticated() .and() .formLogin() .loginPage("/login.html") .defaultSuccessUrl("/index") .permitAll() .and() .csrf().disable() .oauth2Client(); } @Bean public UserDetailsService users() { return new InMemoryUserDetailsManager(User .withUsername("javaboy") .password("{noop}123").roles("USER").build()); } }
这段配置大家应该都很熟悉了,这里不再赘述。登录页面比较简单,这里给出登录表单,代码如下:
登录成功后跳转到 index 页面,该页面是由 Thymeleaf 渲染的,代码如下:
- 注释 1,这是一个授权码模式的超链接,单击该超链接会触发授权码模式进行校验。
- 注释 2,这是一个客户端模式的超链接,单击该超链接会触发客户端模式进行校验。
- 注释 3,这是密码模式,这种模式需要用到用户名/密码,在下方的输入框中输入用户名/密码,然后单击“授权”按钮,触发密码模式进行校验。
- 注释 4,在授权成功后,会返回一个 msg,在当前页面渲染出来。
无论是哪种授权模式,都调用了/authorize 接口,只是参数不同而已,因此我们还需要提供/authorize 接口,代码如下:
@Controller public class HelloController { @Autowired WebClient webClient; private String helloUri="http://res.javaboy.org:8882/hello"; //1 @GetMapping(value = "/authorize",params = "grant_type=authorization_code") public String authorization_code_grant(Model model) { String msg = retrieveMessages("auth-code"); model.addAttribute("msg", msg); return "index"; } //2 @GetMapping(value = "/authorize",params = "grant_type=client_credentials") public String client_credentials_grant(Model model) { String msg = retrieveMessages("client-creds"); model.addAttribute("msg", msg); return "index"; } //3 @PostMapping(value = "/authorize", params = "grant_type=password") public String password_grant(Model model) { String msg = retrieveMessages("password"); model.addAttribute("msg", msg); return "index"; } //4 private String retrieveMessages(String clientRegistrationId) { return webClient .get() .uri(helloUri) .attributes(clientRegistrationId(clientRegistrationId)) .retrieve() .bodyToMono(String.class) .block(); } //5 @GetMapping("/") public String root() { return "redirect:/index"; } @GetMapping("/index") public String index() { return "index"; } }
- 注释 1,处理授权码模式的接口,调用 retrieveMessages 方法去请求 helloUri 地址,将获取结果存入 Model 中。
- 注释 2 与注释 3,功能类似,不再赘述。
- 注释 4,通过 WebClient 发起请求。
- 注释 5,两个页面映射。
最后将项目端口改为 8883。
至此,我们的整个工程就搭建完成了。
15.5.3 测试
首先在浏览器中访问 http://client.javaboy.org:8883/index 地址,由于用户未登录,所以会重定向到 http://client.javaboy.org:8883/login.html 页面进行登录,如图 15-12 所示。
图 15-12 客户端登录页面
用户在该页面上完成登录,注意这个时候是登录客户端而不是授权服务器,客户端登录成功后,就可以看到项目首页了,如图 15-13 所示。
图 15-13 客户端首页
在客户端首页,用户可以选择任何一种授权模式。例如单击授权码模式,此时就会访问到客户端的/authorize 接口,然后 WebClient 向 http://res.javaboy.org:8882/hello 接口发起请求,但是由于当前客户端还没有在授权服务器上进行认证,所以又会跳转到授权服务器的登录页面,如图 15-14 所示。
图 15-14 授权服务器的登录页面
在该页面输入用户名/密码进行登录(注意此时是登录授权服务器),登录成功后,就会看到一个授权页面,如图 15-15 所示。
图 15-15 授权页面
选择 Approve 然后单击下方的“授权”按钮,表示批准本次授权。这里也可以自动批准,在授权服务器中配置客户端信息时,通过调用.autoApprove(true) 方法可以设置自动批准,这样在登录完成后就不会看到该页面了。
至此,整个授权过程完成,再次来到 http://client.javaboy.org:8883/authorize 地址,此时页面底部就可以看到接口数据了,如图 15-16 所示。
图 15-16 授权成功后获取到接口数据
客户端模式和密码模式测试方式类似,这里不再赘述。
15.5.4 原理分析
先来看资源服务器。
当我们在资源服务器中配置了.oauth2ResourceServer().opaqueToken() 之后,实际上向 Spring Security 过滤器链中添加了一个 BearerTokenAuthenticationFilter 过滤器,在该过滤器中将完成令牌的解析与校验,我们来看一下它的 doFilterInternal 方法:
- 注释 1,从当前请求中解析出 Access Token,Access Token 默认放在请求头中,从请求头中取出 Authorization 字段即可。
- 注释 2,如果第一步获取到的令牌为空,就说明用户没有传递 Access Token,此时继续执行后面的过滤器,当前请求将在最后的 FilterSecurityInterceptor 中被检查出权限不足而抛出异常。
- 注释 3,构造一个 BearerTokenAuthenticationToken 对象并传入 Access Token 令牌,然后调用 authenticate 方法完成令牌校验。最终承担校验任务的是 OpaqueTokenAuthentication Provider#authenticate 方法,在该方法中会调用授权服务器的/oauth/check_token 接口,令牌校验成功后,该接口会返回该令牌对应的用户信息。
- 注释 4,将获取到的 authenticationResult 对象存入 SecurityContext 中,完成登录。
这就是资源服务器的大致工作流程,还是比较简单的。
从这个流程中大家也可以看到,客户端每次从资源服务器中请求数据时,资源服务器都会调用授权服务器的接口去校验令牌的合法性,这无形中增大了授权服务器的压力,后面我们将通过 JWT 来解决这一问题。
客户端的工作原理就要简单很多了。
客户端的请求是由 WebClient 发起的,底层真正发起 HTTP 请求的依然是 RestTemplate。这里涉及的核心类就是 DefaultOAuth2AuthorizedClientManager 和 OAuth2AuthorizedClient Provider,这两者的关系类似于我们之前讲的 ProviderManager 和 AuthenticationProvider 的关系。
DefaultOAuth2AuthorizedClientManager 类的核心方法是 authorize,由 WebClient 发起的请求会在这里被拦截下来,我们来看一下该方法:
- 注释 1,authorizeRequest 是一个包含 clientRegistrationId 标志的客户端请求,从中可以提取出 clientRegistrationId、客户端对象、已经登录用户信息以及原始的 HttpServletRequest 与 HttpServletResponse。
- 注释 2,构造 OAuth2AuthorizationContext 对象,用来保存授权请求时所需要的一些必要信息,构造该对象需要用到客户端对象。对于已经认证过的 authorizedClient 可以从 authorizedClientRepository 中直接获取,而没有认证过的 authorizedClient 则只能从 clientRegistrationRepository 中获取客户端信息,然后构造 OAuth2AuthorizationContext 并配置已登录用户对象以及额外附加的用户信息,额外附加的用户信息主要是指密码模式中跟随原始请求一起传来的用户名/密码。
- 注释 3,通过 authorizedClientProvider.authorize 方法进行授权,最终会调用到不同的 OAuth2AuthorizedClientProvider 实例。如果客户端同时支持多种不同的授权模式,则多个 OAuth2AuthorizedClientProvider 实例会被 DelegatingOAuth2AuthorizedClientProvider 对象代理,在代理对象中再去遍历不同的 OAuth2AuthorizedClientProvider 实例,选择合适的 OAuth2AuthorizedClientProvider 实例进行处理,最终调用的请求发送工具依然是 RestTemplate。
- 注释 4,在 OAuth2AuthorizedClientProvider 中认证成功后,会返回认证成功后的 authorizedClient 对象,该对象中就包含了 Access Token 和 Refresh Token。如果该客户端已经认证过了,并且 Access Token 还没有过期,则返回的 authorizedClient 为 null,此时直接从 authorizationContext 取出旧的 authorizedClient 返回即可。
这就是客户端的一个大致工作流程,OAuth2AuthorizedClientProvider 中的实现细节都比较容易,我们就不一一讲解了。
15.5.5 自定义请求
前面的案例中,我们通过 WebClient 来发送认证请求,整个授权过程包括参数的拼接都是由框架帮我们完成的。
有时候,我们可能需要拿到令牌 Access Token,然后自己调用资源服务器的接口来获取数据,这在前后端分离中非常有用。在客户端中获取令牌 Access Token 的方式很简单,我们在 client01 项目的 HelloController 中添加如下接口:
@GetMapping("/token") @ResponseBody public String token(@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient) { OAuth2AccessToken accessToken = authorizedClient.getAccessToken(); return accessToken.getTokenValue(); }
注入 OAuth2AuthorizedClient 对象,然后就可以从该对象中提取出 Access Token 以及 Refresh Token。如果当前客户端只支持一种授权模式,则直接按照上面的写法来;如果当前客户端支持多种授权模式,则需要在 @RegisteredOAuth2AuthorizedClient 注解中指明 registrationId,代码如下:
@GetMapping("/token") @ResponseBody public String token(@RegisteredOAuth2AuthorizedClient("auth-code") OAuth2AuthorizedClient authorizedClient) { OAuth2AccessToken accessToken = authorizedClient.getAccessToken(); return accessToken.getTokenValue(); }
有了 Access Token,开发者就可以利用 Access Token 来请求资源服务器的其他接口了。HTTP 请求工具可以利用 Spring 提供的 RestTemplate,也可以使用自己擅长的其他 HTTP 请求工具,如 HttpClient、OkHttp 等,这个过程就比较简单了,这里不再赘述。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论