應用場景
我們假設你有一個“雲筆記”產品,並提供了“雲筆記服務”和“雲相冊服務”,此時用戶需要在不同的設備(PC、Android、iPhone、TV、Watch)上去訪問這些“資源”(筆記,圖片)
那么用戶如何才能訪問屬於自己的那部分資源呢?此時傳統的做法就是提供自己的賬號和密碼給我們的“雲筆記”,登錄成功后就可以獲取資源了。但這樣的做法會有以下幾個問題:
- “雲筆記服務”和“雲相冊服務”會分別部署,難道我們要分別登錄嗎?
- 如果有第三方應用程序想要接入我們的“雲筆記”,難道需要用戶提供賬號和密碼給第三方應用程序,讓他記錄后再訪問我們的資源嗎?
- 用戶如何限制第三方應用程序在我們“雲筆記”的授權范圍和使用期限?難道把所有資料都永久暴露給它嗎?
- 如果用戶修改了密碼收回了權限,那么所有第三方應用程序會全部失效。
- 只要有一個接入的第三方應用程序遭到破解,那么用戶的密碼就會泄露,后果不堪設想。
為了解決如上問題,oAuth 應用而生。
名詞解釋
- 第三方應用程序(Third-party application): 又稱之為客戶端(client),比如上節中提到的設備(PC、Android、iPhone、TV、Watch),我們會在這些設備中安裝我們自己研發的 APP。又比如我們的產品想要使用 QQ、微信等第三方登錄。對我們的產品來說,QQ、微信登錄是第三方登錄系統。我們又需要第三方登錄系統的資源(頭像、昵稱等)。對於 QQ、微信等系統我們又是第三方應用程序。
- HTTP 服務提供商(HTTP service): 我們的雲筆記產品以及 QQ、微信等都可以稱之為“服務提供商”。
- 資源所有者(Resource Owner): 又稱之為用戶(user)。
- 用戶代理(User Agent): 比如瀏覽器,代替用戶去訪問這些資源。
- 認證服務器(Authorization server): 即服務提供商專門用來處理認證的服務器,簡單點說就是登錄功能(驗證用戶的賬號密碼是否正確以及分配相應的權限)
- 資源服務器(Resource server): 即服務提供商存放用戶生成的資源的服務器。它與認證服務器,可以是同一台服務器,也可以是不同的服務器。簡單點說就是資源的訪問入口,比如上節中提到的“雲筆記服務”和“雲相冊服務”都可以稱之為資源服務器。
交互過程
舉個例子來說吧,你使用qq號登錄知乎,肯定不能告訴知乎你的密碼,那么怎么做呢?知乎返回授權頁,用戶授權知乎,然后知乎向qq申請令牌,知乎通過令牌去訪問用戶qq相關的資源,這樣用戶的密碼不會向知乎暴露,知乎也訪問了用戶相關的qq信息。
客戶端授權模式
客戶端必須得到用戶的授權(authorization grant),才能獲得令牌(access token)。oAuth 2.0 定義了四種授權方式。
- implicit:簡化模式,不推薦使用
- authorization code:授權碼模式
- resource owner password credentials:密碼模式
- client credentials:客戶端模式
1、簡化模式
簡化模式適用於純靜態頁面應用。所謂純靜態頁面應用,也就是應用沒有在服務器上執行代碼的權限(通常是把代碼托管在別人的服務器上),只有前端 JS 代碼的控制權。
這種場景下,應用是沒有持久化存儲的能力的。因此,按照 oAuth2.0 的規定,這種應用是拿不到 Refresh Token 的。其整個授權流程如下:
2、授權碼模式
授權碼模式適用於有自己的服務器的應用,它是一個一次性的臨時憑證,用來換取 access_token
和 refresh_token
。認證服務器提供了一個類似這樣的接口:
https://www.baidu.com/exchange?code=&client_id=&client_secret=
需要傳入 code
、client_id
以及 client_secret
。驗證通過后,返回 access_token
和 refresh_token
。一旦換取成功,code
立即作廢,不能再使用第二次。流程圖如下:
這個 code 的作用是保護 token 的安全性。上一節說到,簡單模式下,token 是不安全的。這是因為在第 4 步當中直接把 token 返回給應用。而這一步容易被攔截、竊聽。引入了 code 之后,即使攻擊者能夠竊取到 code,但是由於他無法獲得應用保存在服務器的 client_secret
,因此也無法通過 code 換取 token。而第 5 步,為什么不容易被攔截、竊聽呢?這是因為,首先,這是一個從服務器到服務器的訪問,黑客比較難捕捉到;其次,這個請求通常要求是 https 的實現。即使能竊聽到數據包也無法解析出內容。
3、密碼模式-----本文后續基於此種方式
密碼模式中,用戶向客戶端提供自己的用戶名和密碼。客戶端使用這些信息,向 "服務商提供商" 索要授權。在這種模式中,用戶必須把自己的密碼給客戶端,但是客戶端不得儲存密碼。這通常用在用戶對客戶端高度信任的情況下,比如客戶端是操作系統的一部分。
一個典型的例子是同一個企業內部的不同產品要使用本企業的 oAuth2.0 體系。在有些情況下,產品希望能夠定制化授權頁面。由於是同個企業,不需要向用戶展示“xxx將獲取以下權限”等字樣並詢問用戶的授權意向,而只需進行用戶的身份認證即可。這個時候,由具體的產品團隊開發定制化的授權界面,接收用戶輸入賬號密碼,並直接傳遞給鑒權服務器進行授權即可。
4、客戶端模式
如果信任關系再進一步,或者調用者是一個后端的模塊,沒有用戶界面的時候,可以使用客戶端模式。鑒權服務器直接對客戶端進行身份驗證,驗證通過后,返回 token。
代碼模塊
表結構
oauth_client_details-----客戶端相關數據
CREATE TABLE `oauth_client_details` ( `client_id` varchar(128) 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;
client_secret:一定要為BycrPassWord后的串,因為oauth2會拿明文密碼通過bycr加密后與數據庫中數據進行比對。
authorized_grant_types:授權方式,本文以password為例
access_token_validity:token有效期
后台代碼
1、pom.xml
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.1.6.RELEASE</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>com.ty</groupId> <artifactId>auth</artifactId> <version>0.0.1-SNAPSHOT</version> <name>auth</name> <description>Demo project for Spring Boot</description> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> <version>2.1.3.RELEASE</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.16</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> <version>2.1.6.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>2.1.6.RELEASE</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>
2、application.yml
spring: application: name: auth-server security: user: # 賬號 name: taoyong # 密碼 password: 123456 datasource: url: jdbc:mysql://localhost:3306/mysql?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false driverClassName: com.mysql.jdbc.Driver username: alimayun password: ty123456 redis: host: 127.0.0.1 port: 6379 password: server: port: 8080
3、AuthorizationServerConfiguration
package com.ty.auth.config.auth; import com.ty.auth.exception.handler.CustomWebResponseExceptionTranslator; import com.ty.auth.store.CustomRedisToken; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; import org.springframework.security.oauth2.provider.ClientDetailsService; import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService; import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator; import org.springframework.security.oauth2.provider.token.TokenStore; import org.springframework.security.oauth2.provider.token.store.JdbcTokenStore; import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore; import javax.sql.DataSource; @Configuration @EnableAuthorizationServer public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter { @Autowired public DataSource dataSource; //使用password模式必須要此bean @Autowired private AuthenticationManager authenticationManager; @Autowired private RedisConnectionFactory redisConnectionFactory; @Bean public TokenStore tokenStore() { // 基於redis實現,令牌保存到redis,並且可以實現redis刷新的功能 return new CustomRedisToken(redisConnectionFactory, jdbcClientDetails()); } @Bean public ClientDetailsService jdbcClientDetails() { // 基於 JDBC 實現,需要事先在數據庫配置客戶端信息 return new JdbcClientDetailsService(dataSource); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { // 設置令牌 endpoints.tokenStore(tokenStore()); endpoints.authenticationManager(authenticationManager); endpoints.exceptionTranslator(new CustomWebResponseExceptionTranslator()); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { // 讀取客戶端配置 clients.withClientDetails(jdbcClientDetails()); } }
4、WebSecurityConfiguration
package com.ty.auth.config.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true) public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { @Bean public BCryptPasswordEncoder passwordEncoder() { // 設置默認的加密方式 return new BCryptPasswordEncoder(); } //password模式必須需要 @Override @Bean public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { //這里可以在數據庫中做。主要就是實現UserDetailsService接口,自定義loadUserByUsername方法 //auth.userDetailsService(xxx) auth.inMemoryAuthentication() // 在內存中創建用戶並為密碼加密 .withUser("alimayun").password(passwordEncoder().encode("123456")).roles("USER") .and() .withUser("ty").password(passwordEncoder().encode("123456")).roles("ADMIN"); } }
5、ResourceServerConfigurer
package com.ty.auth.config.resource; import com.ty.auth.exception.handler.CustomAccessDeniedHandler; import com.ty.auth.exception.handler.MyAuthenticationEntryPoint; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; import org.springframework.security.oauth2.provider.token.TokenStore; //為了方便,直接把認證服務器也當做是一個資源服務器 @Configuration @EnableResourceServer public class ResourceServerConfigurer extends ResourceServerConfigurerAdapter { @Autowired private TokenStore tokenStore; @Override public void configure(ResourceServerSecurityConfigurer resources) throws Exception { resources.tokenStore(tokenStore).authenticationEntryPoint(new MyAuthenticationEntryPoint()) .accessDeniedHandler(new CustomAccessDeniedHandler()); } }
6、異常類
CustomAccessDeniedHandler
package com.ty.auth.exception.handler; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Service; import org.springframework.security.web.access.AccessDeniedHandler; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Date; import java.util.HashMap; import java.util.Map; public class CustomAccessDeniedHandler implements AccessDeniedHandler { @Autowired private ObjectMapper objectMapper; //權限不足異常處理類 @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { response.setContentType("application/json;charset=UTF-8"); Map<String, Object> map = new HashMap<>(); map.put("resultCode", "400"); map.put("resultMsg", accessDeniedException.getMessage()); map.put("path", request.getServletPath()); map.put("timestamp", String.valueOf(new Date().getTime())); response.setContentType("application/json"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write(objectMapper.writeValueAsString(map)); } }
MyAuthenticationEntryPoint
package com.ty.auth.exception.handler; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Service; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.Date; import java.util.HashMap; import java.util.Map; public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { //認證無效,例如token無效等等 @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { Map<String, Object> map = new HashMap<>(); //通過自定義異常可以按照自己的意願去返回這些異常信息,因為大部分企業級應用都是前后分離,對前端友好很重要! map.put("resultCode", "401"); map.put("resultMsg", authException.getMessage()); map.put("path", request.getServletPath()); map.put("timestamp", String.valueOf(new Date().getTime())); response.setContentType("application/json"); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); try { ObjectMapper mapper = new ObjectMapper(); mapper.writeValue(response.getOutputStream(), map); } catch (Exception e) { throw new ServletException(); } } }
CustomWebResponseExceptionTranslator
package com.ty.auth.exception.handler; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.oauth2.common.exceptions.InvalidGrantException; import org.springframework.security.oauth2.common.exceptions.InvalidTokenException; import org.springframework.security.oauth2.common.exceptions.OAuth2Exception; import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator; import org.springframework.stereotype.Service; import javax.xml.transform.Result; import java.util.HashMap; import java.util.Map; //這是獲取token階段出現異常部分 public class CustomWebResponseExceptionTranslator implements WebResponseExceptionTranslator { public ResponseEntity translate(Exception e) throws Exception { if (e instanceof InternalAuthenticationServiceException) { Map<String, Object> result = new HashMap<>(); result.put("resultCode", "401"); result.put("resultMsg", "用戶不存在"); return ResponseEntity.ok(result); } if (e instanceof InvalidGrantException) { Map<String, Object> result = new HashMap<>(); result.put("resultCode", "401"); result.put("resultMsg", "密碼錯誤"); return ResponseEntity.ok(result); } if (e instanceof InvalidTokenException) { Map<String, Object> result = new HashMap<>(); result.put("resultCode", "401"); result.put("resultMsg", "token未識別"); return ResponseEntity.ok(result); } throw e; } }
7、CustomRedisToken
package com.ty.auth.store; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken; import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.provider.ClientDetails; import org.springframework.security.oauth2.provider.ClientDetailsService; import org.springframework.security.oauth2.provider.OAuth2Authentication; import org.springframework.security.oauth2.provider.OAuth2Request; import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore; import java.util.Date; public class CustomRedisToken extends RedisTokenStore { private ClientDetailsService clientDetailsService; public CustomRedisToken(RedisConnectionFactory connectionFactory, ClientDetailsService clientDetailsService) { super(connectionFactory); this.clientDetailsService = clientDetailsService; } //為什么需要刷新token的時間,比如默認1個小時,客戶一直在操作,到了1個小時,讓其登錄,這種體驗很差,應該是客戶啥時候不請求服務器了,隔多長時間 //認為其token失效 // 其實這塊可以看下源碼,在客戶端請求過來的時候,首先到達的是org.springframework.security.oauth2.provider.authentication. // OAuth2AuthenticationProcessingFilter。然后在請求校驗完token有效之后,以當前時間刷新token,具體時間配置在數據庫中~~~ @Override public OAuth2Authentication readAuthentication(OAuth2AccessToken token) { OAuth2Authentication result = readAuthentication(token.getValue()); if (result != null) { // 如果token沒有失效 更新AccessToken過期時間 DefaultOAuth2AccessToken oAuth2AccessToken = (DefaultOAuth2AccessToken) token; //重新設置過期時間 int validitySeconds = getAccessTokenValiditySeconds(result.getOAuth2Request()); if (validitySeconds > 0) { oAuth2AccessToken.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L))); } //將重新設置過的過期時間重新存入redis, 此時會覆蓋redis中原本的過期時間 storeAccessToken(token, result); } return result; } protected int getAccessTokenValiditySeconds(OAuth2Request clientAuth) { if (clientDetailsService != null) { ClientDetails client = clientDetailsService.loadClientByClientId(clientAuth.getClientId()); Integer validity = client.getAccessTokenValiditySeconds(); if (validity != null) { return validity; } } // default 12 hours. int accessTokenValiditySeconds = 60 * 60 * 12; return accessTokenValiditySeconds; } }
測試
首先編寫一個測試controller
package com.ty.auth.controller; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class TestController { @PostMapping("/hello") public String hello() { return "hello"; } }
1、打開postman,直接訪問
提示401,沒有認證
2、請求token
點擊preview request,變成下面這樣:
3、拿着token值訪問/hello
這就是一個簡單的認證過程。token我設置默認是1800s過期,隨着我不斷請求,token有效期也會自動順延
1733秒過期,過一會兒我再訪問/hello,刷新token