# oauth2-sample **Repository Path**: mqb98/oauth2-sample ## Basic Information - **Project Name**: oauth2-sample - **Description**: oauth2 学习记录。 https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzI1NDY0MTkzNQ==&action=getalbum&album_id=1319833457266163712&scene=173&from_msgid=2247488209&from_itemidx=2&count=3&nolastread=1#wechat_redirect - **Primary Language**: Java - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 1 - **Created**: 2021-04-08 - **Last Updated**: 2023-10-20 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # oauth2-sample oauth2 学习记录。 https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzI1NDY0MTkzNQ==&action=getalbum&album_id=1319833457266163712&scene=173&from_msgid=2247488209&from_itemidx=2&count=3&nolastread=1#wechat_redirect ## OAuth2 > Open Authorization [.ɔθərɪ'zeɪʃ(ə)n] ### 1. 应用场景 OAuth 不是一个框架,而是一个关于授权(authorization)的开放网络标准。 举一个例子来理解 OAuth的适用场景。 有一个 "云冲印" 的网站,可以将用户存储在 Google 的照片,冲印出来。用户为了使用该服务,必须让 "云冲印"读取自己存储在 Google 上的照片。 问题是只有得到用户的授权,Google 才会同意 "云冲印" 读取这些照片。那么,"云冲印" 怎样获得用户的授权呢? 传统方法是,用户将自己的 Google 用户名和密码告诉 "云冲印",后者就可以读取用户的照片了。这样的做法有以下几个严重的缺点。 1. "云冲印" 为了后续的服务,会保存用户的密码,这样很不安全。 2. Google 得部署 用户名/密码 登录,但是,单纯的 用户名/密码 的登录并不安全。 3. "云冲印" 拥有了获取用户存储在 Google 的所有资料的权力,用户无法限制 "云冲印" 获得的授权范围和有效期。 4. 用户只有修改密码,才能收回赋予 "云冲印" 的权力。但是这样做,会使得其他所有获得授权的第三方应用程序全部失效。 5. 只要有一个第三方应用程序被破解,就会导致用户名/密码泄露。 OAuth 就是为了解决上面的这些问题而诞生的。 ### 2. 名词定义 在详细了解 OAuth2 之前,需要了解几个专用名词。 >1. Third-part application:第三方应用程序。在本文中又称客户端(client),即上面例子中的 "云冲印" >2. HTTP service:HTTP服务的提供商,本文中简称服务提供商,即上面例子中的 Google >3. Resource Owner:资源所有者,本文中又称为用户(user) >4. User Agent:用户代理,本文中值浏览器 >5. Authorization server:认证服务器,即服务提供商专门用于处理认证的服务器。 >6. Resource server:资源服务器,即服务提供商存放用户生成的资源的服务器。他与认证服务器可以是同一台服务器,也可以是不同的服务器。 在了解了上面这些名词之后,就不难理解,OAuth的作用就是让 "客户端" 安全可控的获得"用户" 授权,与"服务提供商"进行互动。 ### 3. OAuth的思路 OAuth在 客户端 与服务提供商 之间,设置了一个授权层(authorization layer)。 客户端不能直接登录服务提供商,只能登录授权层,以此将用户和客户端区分开来。客户端登录授权层所用的令牌(token),与用户的密码不同,用户可以在登录时,指定授权层令牌的权限范围和有效期。 客户端登录授权层以后,服务提供商根据令牌的权限范围和有效期,向客户端开放用户存储的资料。 ### 4. 运行流程 OAuth2 的运行流程如下: ![OAuth运行流程](README.assets/bg2014051203.png) (A) 用户打开客户端(云冲印)以后,客户端要求用户给予授权。 (B) 用户同意给予客户端授权。 (C) 客户端使用上一步获得的授权,向认证服务器申请令牌。 (D) 认证服务器对客户端进行认证以后,确认无误,同意发放令牌。 (E) 客户端使用令牌,向资源服务器申请获取资源。 (F) 资源服务确认令牌无误,同意向客户端开放资源。 不难看出,上面六个步骤之中,B是关键,即用户怎样才能给客户端授权。有了这个授权之后,客户端就可以获取令牌,进而凭令牌获取资源。 ### 5. 客户端授权模式 > http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html 客户端必须得到用户的授权(authorization grant),才能获得令牌(access token)。OAuth2 定义了四种授权方式。 - 授权码模式(authorization code) - 简化模式(implicit) - 密码模式(resource owner password credentials) - 客户端模式(client credentials) ### 6. 授权码模式 授权码模式(authorization code) 是功能最完整,流程最严密的授权模式。它的特点就是通过客户端的后台服务器,与 服务提供商 的认证服务器 进行互动。 ![授权码模式](README.assets/bg2014051204.png) 步骤如下: (A) 用户访问客户端,后者将前者导向认证服务器。(我打开云冲印页面,选择Google登录,跳转到Goole登录授权界面) (B)用户选择是否给客户端授权。 (C)假设用户给予授权,认证服务器将用户导向客户端事先指定的 "重定向URI"(Redirection URI),同时附上一个授权码。 (D)客户端收到授权码,附上早先的 "重定向URI",向认证服务器申请令牌。这一步是在客户端后台的服务器上完成的,对用户不可见。 (E)认证服务器核对了授权码和重定向URI,确认无误后,向客户端发送访问令牌(access token)和更新令牌(refresh token) 下面是上面这些步骤所需要的参数。 A 步骤中,客户端申请认证的 URI,包含以下参数 - response_type:表示授权类型,必选项,此处的值固定为 "code" - client_id:表示客户端的ID,必选项 - redirect_uri:表示重定向URI,可选项 - scope:表示申请的授权范围,可选项 - state:表示客户端的当前状态,可以指定任意值,认证服务器会原封不动 示例如下 ```http GET /authorize?response_type=code&client_id=s6BhdRkqt3&state=xyz &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb HTTP/1.1 Host: server.example.com ``` C 步骤中,服务器回应客户端的URI,包含以下参数: - code:表示授权码,必选项,该码的有效期应该很短,通常设为10分钟,客户端只能使用该码一次,否则会被授权服务器拒绝。该码与客户端ID和重定向URI,是一一对应的关系。 - state:如果客户端的请求中包含这个参数,认证服务器的回应也必须一模一样包含这个参数。 示例如下: ```http HTTP/1.1 302 Found Location: https://client.example.com/cb?code=SplxlOBeZQQYbYS6WxSbIA &state=xyz ``` D 步骤中,客户端向认证服务器申请令牌的 HTTP 请求,包含以下参数: - grant_type:表示使用的授权模式,必须选,此处的值固定为 "authorization_code" - code:表示上一步获得的授权码,必须项 - redirect_uri:表示重定向URI,必选项,且必须与A步骤中的该参数值保持一致 - client_id:表示客户端ID,必选项 示例如下: ```http POST /token HTTP/1.1 Host: server.example.com Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA &redirect_uri=https%3A%2F%2Fclient%2Eexample%2Ecom%2Fcb ``` E 步骤中,认证服务器发送的HTTP回复,包含以下参数: - access_token:表示访问令牌,必须项 - token_type:表示令牌类型,该值大小写不敏感,必选项 - expires_in:表示过期时间,单位为秒。如果省略该参数,必须使用其他方式设置过期时间。 - refresh_token:表示更新令牌,用来获取下一次的访问令牌,可选项 - scope:表示授权范围,如果客户端申请的范围一致,此项可省略 示例如下: ```http HTTP/1.1 200 OK Content-Type: application/json;charset=UTF-8 Cache-Control: no-store Pragma: no-cache { "access_token":"2YotnFZFEjr1zCsicMWpAA", "token_type":"example", "expires_in":3600, "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", "example_parameter":"example_value" } ``` 可以看到,相关参数使用 JSON 格式发送(Content-Type: application/json)。此外,HTTP头信息中明确指定不得缓存。 ### 7. 简化模式 简化模式(implicit grant type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了授权码的步骤,因此得名。因为所有步骤都在浏览器完成,令牌对访问者是可见的,且客户端不需要认证。 ![简化模式](README.assets/bg2014051205.png) ## 授权码模式demo ### 1. 案例架构 授权码模式的这个demo,有如下几个角色 - 第三方应用 - 授权服务器 - 资源服务器 - 用户 | 项目 | 端口 | 备注 | | --------------- | ---- | ---------- | | auth-server | 8080 | 授权服务器 | | resource-server | 8081 | 资源服务器 | | client-app | 8082 | 第三方应用 | 下面开始搭建项目 ### 2. 授权服务器 创建一个名为 auth-server 的 springboot 项目,选择如下依赖: - web - spring cloud security - spring cloud OAuth2 图片 创建完成之后,首先提供一个 SpringSecurity 的基本配置 ```java @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.inMemoryAuthentication() .withUser("spencer") .password(new BCryptPasswordEncoder().encode("123")) .roles("admin") .and() .withUser("maqb") .password(new BCryptPasswordEncoder().encode("123")) .roles("user"); } ``` 为了代码简洁,此处就直接将用户配置在了内存之中。 这里配置的目的,实际上就是配置用户。当我们使用 Google 登录云冲印时,首先要登录 Google,这里配置的相当于就是登录Google要的用户名和密码。 接下来配置授权服务器,重点。 ```java @Configuration public class AccessTokenConfig { @Bean TokenStore tokenStore(){ return new InMemoryTokenStore(); } } @Configuration @EnableAuthorizationServer public class AuthorizationServer extends AuthorizationServerConfigurerAdapter { @Autowired TokenStore tokenStore; @Autowired ClientDetailsService clientDetailsService; @Bean AuthorizationServerTokenServices tokenServices() { DefaultTokenServices services = new DefaultTokenServices(); services.setClientDetailsService(clientDetailsService); services.setSupportRefreshToken(true); services.setTokenStore(tokenStore); services.setAccessTokenValiditySeconds(60 * 60 * 2); services.setRefreshTokenValiditySeconds(60 * 60 * 24 * 3); return services; } @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.checkTokenAccess("permitAll()") .allowFormAuthenticationForClients(); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("myClient") .secret(new BCryptPasswordEncoder().encode("123")) .resourceIds("res1") .authorizedGrantTypes("authorization_code", "refresh_token") .scopes("all") .redirectUris("http://localhost:8082/index.html"); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.authorizationCodeServices(authorizationCodeServices()) .tokenServices(tokenServices()); } @Bean AuthorizationCodeServices authorizationCodeServices(){ return new InMemoryAuthorizationCodeServices(); } } ``` 来挨个解释下 1. 首先我们提供了一个 `TokenStore` 的实例,这个是指定你生成的 Token 要往哪里存储,我们可以存储在 Redis 中,也可以存储在内存中,也可以结合 JWT 等等,这里就先把它存在内存中了,所以提供一个 `InMemoryTokenStore` 2. 接下来创建 `AuthorizationServer` 类继承自 `AuthorizationServerConfigurerAdapter`,来对授权服务器做进一步的详细配置。注意这个类得加上 `@EnableAuthorizationServer` 注解,表示开启授权服务器的自动化配置。 3. 在 `AuthorizationServer` 中,主要就是重写了三个 `configure` 方法。 4. `AuthorizationServerSecurityConfigurer` 用来配置令牌端点的安全约束,也就是这个端点谁可以访问,谁不能访问。checkAccess 是指一个 Token 校验的端点,这个端点我们设置为可以直接访问(在后面,当资源服务器收到 Token 后,需要校验 Token 的合法性,就放访问这个端点) 5. `ClientDetailsServiceConfigurer ` 用来配置客户端的详细信息。 授权服务器要进行两方面的校验,一方面是校验客户端,另一方面是校验用户。校验用户这个我们前面在`SecurityConfig` 以及配置了,这里配置的是校验客户端。客户端的信息我们可以存储在数据库中,和用户信息保存到数据库类似。这里为了简单就直接配置在了内存中。 这里分别配置了 id、secret、资源id、授权类型、授权范围以及重定向 uri。授权类型分为4种, refresh_token 不在其中,但是实际操作种,refresh_token 也被算作一种。 6. `AuthorizationServerEndpointsConfigurer` 用来配置令牌的访问端点和令牌服务。authorizationCodeServices 用来配置授权码的存储,这里配置的存储在内存中。tokenServices 用来配置令牌的存储,即 access_token 的存储位置,这里也先配置在内存中。 授权码和令牌区别?授权码是用来获取令牌的,使用一次就失效,令牌则是用来获取资源的。 7. tokenServices 这个 Bean 主要用来配置 Token 的一些基本信息,例如 Token 是否支持刷新、Token 的存储位置、Token 的有效期 以及刷新 token 的有效期等等。Token 有效期这个好理解,刷新 Token 的有效期我说一下,当 Token 快要过期的时候,我们需要获取一个新的 Token,在获取新的 Token 时候,需要有一个凭证信息,这个凭证信息不是旧的 Token,而是另外一个 refresh_token,这个 refresh_token 也是有有效期的。 ### 3. 资源服务器搭建 接下来搭建一个资源服务器。网上的 demo 一般是将授权服务器和资源服务器放在一起,如果项目比较小的话,这样做没有问题,但是如果项目比较大,则就需要分开了。 资源服务器就是用来存放用户的资源的,例如在 Goolge 种存储的照片、微信上的图片、openid等信息。用户从授权服务器上拿到 access_token 之后,接下来就可以通过 access_token 来资源服务器请求数据。 创建一个新的 Springboot 项目,名为 resource-server,作为资源服务器,创建时添加如下依赖 图片 项目创建成功后,添加如下配置: ```java @Configuration @EnableResourceServer public class ResourceServerConfig extends ResourceServerConfigurerAdapter { @Bean RemoteTokenServices tokenServices(){ RemoteTokenServices services = new RemoteTokenServices(); services.setCheckTokenEndpointUrl("http://localhost:8080/oauth/check_token"); services.setClientId("myClient"); services.setClientSecret("123"); return services; } @Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { resources.resourceId("res1").tokenServices(tokenServices()); } @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/admin/**").hasRole("admin") .anyRequest().authenticated(); } } ``` 简单描述下: 1. tokenServices 我们配置了一个 `RemoteTokenServices` 的实例,这是因为资源服务器和授权服务器是分开的,资源服务器和授权服务器如果是放在一起的,就不需要配置 RemoteTokenServices 了 2. RemoteTokenServices 中配置了 access_token 的校验地址、client_id、client_secret 这三个信息,当用户来资源服务器请求资源时,会携带上一个 access_token,通过这里的配置,就能校验 token 是否正确。 3. 最后配置下拦截规则,就是 SpringSecurity 的写法配置。 接下来再配置两个测试接口: ```java @RestController public class HelloController { @GetMapping("/hello") public String hello() { return "hello"; } @GetMapping("/admin/hello") public String admin() { return "admin"; } } ``` ### 4. 第三方应用搭建 第三方应用其实不是必须的,也可以使用 POSTMAN 来进行测试 第三方应用就是一个普通的 Springboot 项目,创建时加入 Thymeleaf 和 Web 依赖 图片 在 resources/template目录下,创建 index.html ```html 你好,maqb! 第三方登录

``` 这是一段 Thymeleaf 模板,点击超链接就可以实现第三方登录,超链接参数如下: - client_id 客户端ID,根据授权服务器中的配置填写 - response_type 表示响应类型,这里是code表示一个授权码 - redirect_uri 表示授权成功后的重定向地址,这里表示回第三方应用的首页 - scope 表示授权范围 h1 标签中的数据是从资源服务器来的,当授权服务器通过之后,我们拿着 access_token 去资源服务器加载数据,加载到的数据在 h1 标签中显示出来。 接下来在定义一个 HelloController ```java @Controller public class HelloController { @Autowired private RestTemplate restTemplate; @GetMapping("/index.html") public String index(String code, Model model){ if (code != null) { MultiValueMap map = new LinkedMultiValueMap<>(); map.add("code", code); map.add("client_id", "myClient"); map.add("client_secret", "123"); map.add("redirect_uri", "http://localhost:8082/index.html"); map.add("grant_type", "authorization_code"); Map resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class); String access_token = resp.get("access_token"); System.out.println(access_token); HttpHeaders headers = new HttpHeaders(); headers.add("Authorization", "Bearer " + access_token); HttpEntity httpEntity = new HttpEntity<>(headers); ResponseEntity entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, httpEntity, String.class); model.addAttribute("msg", entity.getBody()); } return "index"; } } ``` 在这个 HelloController 中,定义了一个 `/hello.html` 接口。 1. 如果 `code == null`,则这个请求是一个请求页面的请求 2. 如果 `code != null`,则这个请求是通过授权服务器重定向回来的,这样的话我们要进行两个操作 - 根据拿到的 code,去请求 `http://localhost:8080/oauth/token` 地址获取 token,返回的数据结构如下 ```json { "access_token": "e7f223c4-7543-43c0-b5a6-5011743b5af4", "token_type": "bearer", "refresh_token": "aafc167b-a112-456e-bbd8-58cb56d915dd", "expires_in": 7199, "scope": "all" } ``` `assess_token` 就是我们请求数据所需要的令牌,refresh_token 则是我们刷新 token 所需的令牌,expires_in 表示 token 有效期还剩多久。 - 接下来,根据拿到的 access_token,去请求资源服务器,**注意 access_token 通过请求头传递**,最后将资源服务器返回的数据放在 model 中 > 这里只是举一个简单的例子,目的只是为了走通流程,正常来说 access_token 可能需要一个定时任务去维护,不用每次请求页面都去获取,定时去获取最新的 token 即可,后续完善 ### 5. 测试 启动三个模块,浏览器访问 `localhost:8082/index.html`,就是访问第三方 app ![image-20210419140131610](README.assets/image-20210419140131610.png) 点击第三方登录,会跳转到 授权服务器的登录界面 image-20210419140356099 输入在授权服务器中配置的用户信息 `spencer/123` 来登录,登录成功后会看到如下页面 ![image-20210430134247720](README.assets/image-20210430134247720.png) 选择 Approve(批准),点击 Authorize,会根据 `redirect_uri` 跳转回 `localhost:8082/index.html`,即回到第三方应用 ![image-20210419171729312](README.assets/image-20210419171729312.png) 注意,这次返回的 url 中有一个 code 参数,这就是授权服务器给的授权码,可以拿着这个授权码去请求 access_token,授权码使用一次后就失效。 页面下方多出了一个 admin,这个 admin 就是从资源服务器获取的资源(访问了 `localhost:8081/admin/hello`,这个步骤在 java 后台完成) 注意,若是适用 mqb/123 这个用户登录,则会报错,因为这个用户没有 admin 的角色无法访问这个资源 ![图片](README.assets/640-1618824114732.webp) ## 简化模式demo 在授权码模式的基础上进行修改。 首先对于 auth-server 授权服务器进行改动 `AuthorizationServer.java` 这里在 `authorizedGrantTypes` 中将 authorization_code 改为了 implicit,表示使用简化模式。 ```java @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("myClient") .secret(new BCryptPasswordEncoder().encode("123")) .resourceIds("res1") .authorizedGrantTypes("refresh_token", "implicit") .scopes("all") .redirectUris("http://localhost:8082/index.html"); } ``` 然后修改资源服务器 resource-server 因为简化模式第三方应用没有服务端,所以我们只能在第三方应用的前端中使用 js 来请求资源服务器上的数据,所以资源服务器需要支持跨域,修改如下两个地方来支持跨域 ```java @RestController @CrossOrigin(value = "*") public class HelloController { @GetMapping("/hello") public String hello() { return "hello"; } @GetMapping("/admin/hello") public String admin() { return "admin"; } } ``` 首先在 controller 上添加注解 `@CrossOrigin` 注解使之支持跨域,然后配置 SpringSecurity 使之支持跨域 ```java @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/admin/**").hasRole("admin") .anyRequest().authenticated() .and() .cors(); } ``` 接下来修改第三方应用:client-app ```html 你好,maqb! 第三方登录(简化模式)
``` 还是之前的超链接不变,但是将 response_type 改为 token,表示直接返回授权码。其他参数不变。 这样,当用户登录成功之后,会自动重定向到 `http://localhost:8082/index.html`,并且添加了一个锚点参数,类似下面这样: ```http http://localhost:8082/index.html#access_token=87f4d2ce-bb0e-4923-87e0-1fe6638c4a47&token_type=bearer&expires_in=7199 ``` 所以接下来,我们就在 js 中提取出 `#` 后面的参数,并进一步解析出 access_token 的值。 拿着 access_token 的值,我们去发送一个 Ajax 请求,将 access_token 放在请求头中,请求成功后,将请求到的数据放在 div 中。 配置完成后,启动3个服务,访问 `http://localhost:8082/index.html` 页面进行测试,用户授权之后,会自动重定向到该页面,显示效果如下: ![image-20210430135313781](README.assets/image-20210430135313781.png) ## 密码模式 ## 客户端模式 ## 刷新token 刷新token,是4种模式共有的功能。 以授权码模式为例,当启动 `auth-server` 授权服务器之后,可以在 idea 中看到项目暴露出来的接口 ![image-20210430094227283](README.assets/image-20210430094227283.png) | 端点 | 含义 | | --------------------- | ------------------------------------------------------------ | | /oauth/authorize | 这是授权的端点 | | /oauth/token | 这是用来获取令牌的端点 | | /oauth/confirm_access | 用户确认授权提交的端点(就是 auth-server 询问用户是否授权那个页面的提交地址) | | /oauth/error | 授权出错的端点 | | /oauth/check_token | 校验 access_token 的端点 | | /oauth/token_key | 提供公钥的端点 | ```tex client-app 用户打开第三方app(client-app),选择点击第三方登录。 这个第三方登录的链接中会包含如下参数: { client_id, response_type, scope, redirect_uri } 携带这些参数,去授权服务器访问 /oauth/authorize 接口 auth-server /oauth/authorize 这个接口是由 org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint 提供的 这个接口会先对请求的参数做一些判断,如 client_id 这个参数不能为null...等等。 然后重定向到一个登录页 /login 在登录成功之后,又会重定向回 /oauth/authorize,这次是返回一个选择是否授权的页面,选择 Approve 后,再点击 Authorize 确认授权 此时会发送一个 POST /oauth/authorize 然后会重定向到请求中的 redirect_uri,并且携带一个 code 参数,这个是授权服务器给的授权码 获得这个授权码后,在 client-app 内部,会携带这个授权码code 再去访问 授权服务器的 /oauth/token 接口, 通过这个授权码,加上 client_id、client_secret 等参数(这个对于QQ的话要去QQ申请对应的 appid 和 appkey) 最终会返回一个 access_token,这个就是访问令牌。 携带这个令牌,可以去资源服务器获取资源。 但是注意,这个令牌使用一次就会失效 ``` `/oauth/token` 端点除了颁发令牌,还可以用来刷新令牌,在我们获取到令牌时,除了会获得一个 access_token 之外,还有一个 refresh_token,这个 refresh_token 就是用来刷新令牌的。 如下使用 postman 来做一个简单的刷新令牌请求 ![图片](README.assets/640-1619751909769.webp) 注意,刷新的时候需要携带上 refresh_token 参数,刷新完成之后,之前旧的 access_token 就会失效。 ## 令牌存入redis ### 1.令牌往哪里存? 在配置授权码模式时候,有两个东西配置在了内存中。一个是令牌、一个是授权码 - ```java @Bean TokenStore tokenStore(){ return new InMemoryTokenStore(); } ``` - ```java @Bean AuthorizationCodeServices authorizationCodeServices(){ return new InMemoryAuthorizationCodeServices(); } ``` 授权码使用过一次就会失效,存在内存中没什么问题,但是授权码,我们还有其他存储方式。 `InMemoryTokenStore` 实现了 `TokenStore` 接口,来看下这个接口的其他实现类 ![](README.assets/TokenStore.png) 可以看到,有多种方式来存储 access_token 1. `InMemoryTokenStore`:这就是我们之前使用的方式,也是系统默认的方式,就是讲 access_token 存到内存中。单机环境下这个也可以使用,但是分布式环境不推荐。 2. `JdbcTokenStore`:看名字就是存储在数据库中,方便和其他应用共享令牌信息。 3. `JwtTokenStore`:这个其实不是存储,因为使用了 jwt 之后,在生成的 jwt 中就有用户的所有信息,服务端就不需要保存,这也就是无状态登录。 4. `RedisTokenStore`:将 access_token 存储到 redis。 5. `JwkTokenStore`:将 access_token 存储到 JSON Web Key 中 虽然这里支持的方案有许多,但是实际上我们常用的主要是两个:`RedisTokenStore` 和 `JwtTokenStore`。 jwt 的方式比较复杂,此处演示 redis 的方案 首先我们要启动一个 redis 服务,然后在 `auth-server` 中添加 redis 的依赖 ```xml org.springframework.boot spring-boot-starter-data-redis ``` 然后在配置文件中添加 redis 相关的配置 ```YML spring: redis: host: 127.0.0.1 database: 1 ``` 再然后修改 TokenStore 这个 bean 的配置 ```java @Configuration public class AccessTokenConfig { @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean TokenStore tokenStore(){ return new RedisTokenStore(redisConnectionFactory); } } ``` 修改完成之后,启动三个服务,再走一遍第三方登录的流程,可以看到 access_token 存入了 redis中,且 access_token 的 key 在 redis 中的有效期就是令牌的有效期 ![image-20210511172241397](README.assets/image-20210511172241397.png) ### 2. 客户端信息入库 在之前的 demo 中,客户端信息也都是存储在内存中的: ```java @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("myClient") .secret(new BCryptPasswordEncoder().encode("123")) .resourceIds("res1") .authorizedGrantTypes("authorization_code", "refresh_token") .scopes("all") .redirectUris("http://localhost:8082/index.html"); } ``` 但是这种方式是不可取,一来所有客户端信息都写死在代码中,以后不好维护。二来客户端信息量可能非常大,想象下有多少第三方应用接入QQ登录。 所以我们要把客户端的信息存入数据库中。 客户端信息入库涉及到的接口主要是 `ClientDetailsService`,这个接口有两个实现类,如下: ![](README.assets/ClientDetailsService.png) 顾名思义,`JdbcClientDetailsService` 就是将客户端信息存入数据库的。点击查看这个类的源码,可以看到有非常多的 SQL 语句,对其进行整理,可以分析出数据库的结构 ```sql DROP TABLE IF EXISTS `oauth_client_details`; CREATE TABLE `oauth_client_details` ( `client_id` varchar(48) NOT NULL, `resource_ids` varchar(256) DEFAULT NULL, `client_secret` varchar(256) DEFAULT NULL, `scope` varchar(256) DEFAULT NULL, `authorized_grant_types` varchar(256) DEFAULT NULL, `web_server_redirect_uri` varchar(256) DEFAULT NULL, `authorities` varchar(256) DEFAULT NULL, `access_token_validity` int(11) DEFAULT NULL, `refresh_token_validity` int(11) DEFAULT NULL, `additional_information` varchar(4096) DEFAULT NULL, `autoapprove` varchar(256) DEFAULT NULL, PRIMARY KEY (`client_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; ``` 接下来将一开始定义的客户端信息存入数据库中 image-20210511174717158 既然用到了数据库,当然也要给 auth-server 服务添加数据库的依赖 ```xml org.springframework.boot spring-boot-starter-jdbc mysql mysql-connector-java ``` 然后在 yaml 中配置数据库相关信息 ```yaml spring: datasource: url: jdbc:mysql://localhost:3306/oauth2?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai username: root password: admin driver-class-name: com.mysql.cj.jdbc.Driver main: allow-bean-definition-overriding: true ``` 这里的配置多了最后一条。这是因为我们一会要创建自己的 ClientDetailsService,而系统已经创建了 ClientDetailsService,加了最后一条就允许我们自己的实例覆盖系统默认的实例。当然我们也可以修改自定义的 ClientDetailsService 的 beanName 来避免覆盖。 接下来配置自己的 `ClientDetailsService ` ```java @Autowired private DataSource dataSource; @Bean ClientDetailsService clientDetailsService() { return new JdbcClientDetailsService(dataSource); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.withClientDetails(clientDetailsService()); } ``` 配置完成之后再次重启三个服务,走一遍第三方登录流程,也没有问题。 我们也可以将令牌有效期配置在数据库中,这样就不用在代码中配置了,修改后的数据库如下: image-20210511181314588 对应修改的 `AuthorizationServerTokenServices ` 如下: ```java @Bean AuthorizationServerTokenServices tokenServices() { DefaultTokenServices services = new DefaultTokenServices(); services.setClientDetailsService(clientDetailsService()); services.setSupportRefreshToken(true); services.setTokenStore(tokenStore); return services; } ``` ### 3. 第三方应用优化 将原本 Hello Controller 中的操作步骤抽取出来。 专门定义一个类 `TokenTask` 来解决 Token 管理问题 ```java @Component @SessionScope // 不是单例,每个会话创建一个bean public class TokenTask { @Autowired private RestTemplate restTemplate; private String access_token = ""; private String refresh_token = ""; public String getData(String code) { if ("".equals(access_token) && code != null) { MultiValueMap map = new LinkedMultiValueMap<>(); map.add("code", code); map.add("client_id", "myClient"); map.add("client_secret", "123"); map.add("redirect_uri", "http://localhost:8082/index.html"); map.add("grant_type", "authorization_code"); Map resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class); access_token = resp.get("access_token"); refresh_token = resp.get("refresh_token"); return loadDataFromResServer(); } else { return loadDataFromResServer(); } } private String loadDataFromResServer() { try { HttpHeaders headers = new HttpHeaders(); headers.add("Authorization", "Bearer " + access_token); HttpEntity httpEntity = new HttpEntity<>(headers); ResponseEntity entity = restTemplate.exchange("http://localhost:8081/admin/hello", HttpMethod.GET, httpEntity, String.class); return entity.getBody(); } catch (RestClientException e) { return "加载失败"; } } //@Scheduled(cron = "0 55 0/1 * * ?") @Scheduled(fixedRate = 115 * 60 * 1000L) public void tokenTask() { MultiValueMap map = new LinkedMultiValueMap<>(); map.add("client_id", "myClient"); map.add("client_secret", "123"); map.add("refresh_token", refresh_token); map.add("grant_type", "authorization_code"); Map resp = restTemplate.postForObject("http://localhost:8080/oauth/token", map, Map.class); access_token = resp.get("access_token"); refresh_token = resp.get("refresh_token"); } } ``` 稍微解释下这段代码 1. 首先在 getDate 方法中,如果 access_token 为空字符串,且 code 不为 null,表示此时是刚刚拿到授权码的时候,准备去申请令牌了。令牌拿到后,将 access_token 和 refesh_token 分别复制给成员变量 **(要不要加锁保证线程安全?)** 然后调用 loadDataFromResServer 方法去资源服务器加载资源。 2. 还有一个定时任务 tokenTask ,每隔 115 分钟去刷新以下 access_token(access_token 有效期是 120 分钟)。 最后再修改下 HelloController ```java @Controller public class HelloController { @Autowired TokenTask tokenTask; @GetMapping("/index.html") public String index(String code, Model model) { model.addAttribute("msg", tokenTask.getData(code)); return "index"; } } ```