在學習Spring Cloud 時,遇到了受權服務oauth 相關內容時,老是隻知其一;不知其二,所以決定先把Spring Security 、Spring Security Oauth2 等權限、認證相關的內容、原理及設計學習並整理一遍。本系列文章就是在學習的過程當中增強印象和理解所撰寫的,若有侵權請告知。
項目環境:html
- JDK1.8
- Spring boot 2.x
- Spring Security 5.x
單點登陸(Single Sign On),簡稱爲SSO,是目前比較流行的企業業務整合的解決方案之一。 SSO的定義是在多個應用系統中,用戶只須要登陸一次就能夠訪問全部相互信任的應用系統。
單點登錄本質上也是OAuth2的使用,因此其開發依賴於受權認證服務,若是不清楚的能夠看個人上一篇文章。java
從單點登錄的定義上來看就知道咱們須要新建個應用程序,我把它命名爲 security-sso-client。接下的開發就在這個應用程序上了。git
主要依賴 spring-boot-starter-security、spring-security-oauth2-autoconfigure、spring-security-oauth2 這3個。其中 spring-security-oauth2-autoconfigure 是Spring Boot 2.X 纔有的。github
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!--@EnableOAuth2Sso 引入,Spring Boot 2.x 將這個註解移到該依賴包--> <dependency> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> <exclusions> <exclusion> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> </exclusion> </exclusions> <version>2.1.7.RELEASE</version> </dependency> <!-- 不是starter,手動配置 --> <dependency> <groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <!--請注意下 spring-authorization-oauth2 的版本 務必高於 2.3.2.RELEASE,這是官方的一個bug: java.lang.NoSuchMethodError: org.springframework.data.redis.connection.RedisConnection.set([B[B)V 要求必須大於2.3.5 版本,官方解釋:https://github.com/BUG9/spring-security/network/alert/pom.xml/org.springframework.security.oauth:spring-security-oauth2/open --> <version>2.3.5.RELEASE</version> </dependency>
單點的基礎配置引入是依賴 @EnableOAuth2Sso 實現的,在Spring Boot 2.x 及以上版本 的 @EnableOAuth2Sso 是在 spring-security-oauth2-autoconfigure 依賴裏的。我這裏簡單配置了一下:redis
@Configuration @EnableOAuth2Sso public class ClientSecurityConfig extends WebSecurityConfigurerAdapter { @Override public void configure(HttpSecurity http) throws Exception { http.authorizeRequests() .antMatchers("/","/error","/login").permitAll() .anyRequest().authenticated() .and() .csrf().disable(); } }
由於單點期間可能存在某些問題,會重定向到 /error ,因此咱們把 /error 設置成無權限訪問。spring
@RestController @Slf4j public class TestController { @GetMapping("/client/{clientId}") public String getClient(@PathVariable String clientId) { return clientId; } }
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>OSS-client</title> </head> <body> <h1>OSS-client</h1> <a href="http://localhost:8091/client/1">跳轉到OSS-client-1</a> <a href="http://localhost:8092/client/2">跳轉到OSS-client-2</a> </body> </html>
因爲咱們要測試多應用間的單點,因此咱們至少須要2個單點客戶端,我這邊經過Spring Boot 的多環境配置實現。segmentfault
咱們都知道單點實現本質就是Oauth2的受權碼模式,因此咱們須要配置訪問受權服務器的地址信息,包括 :瀏覽器
其中有幾個配置須要簡單解釋下:服務器
auth-server: http://localhost:9090 # authorization服務地址 security: oauth2: client: user-authorization-uri: ${auth-server}/oauth/authorize #請求認證的地址 access-token-uri: ${auth-server}/oauth/token #請求令牌的地址 resource: jwt: key-uri: ${auth-server}/oauth/token_key #解析jwt令牌所須要密鑰的地址,服務啓動時會調用 受權服務該接口獲取jwt key,因此務必保證受權服務正常 sso: login-path: /login #指向登陸頁面的路徑,即OAuth2受權服務器觸發重定向到客戶端的路徑 ,默認爲 /login server: servlet: session: cookie: name: OAUTH2CLIENTSESSION # 解決 Possible CSRF detected - state parameter was required but no state could be found 問題 spring: profiles: active: client1
application-client2 和 application-client1是同樣的,只是端口號和client信息不同而已,這裏就再也不重複貼出了。微信
server: port: 8091 security: oauth2: client: client-id: client1 client-secret: 123456
效果以下:
從效果圖中咱們能夠發現,當咱們第一次訪問client2 的接口時,跳轉到了受權服務的登錄界面,完成登錄後成功跳轉回到了client2 的測試接口,而且展現了接口返回值。此時咱們訪問client1 的 測試接口時直接返回(表面現象)了接口返回值。這就是單點登錄的效果,好奇心強的同窗必定會在內心問道:它是如何實現的? 那麼接下來咱們就來揭開其面紗。
咱們都知道 @EnableOAuth2Sso 是實現單點登錄的最核心配置註解,那麼咱們來看下 @EnableOAuth2Sso 的源碼:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Documented @EnableOAuth2Client @EnableConfigurationProperties(OAuth2SsoProperties.class) @Import({ OAuth2SsoDefaultConfiguration.class, OAuth2SsoCustomConfiguration.class, ResourceServerTokenServicesConfiguration.class }) public @interface EnableOAuth2Sso { }
其中咱們關注4個配置文件的引用: ResourceServerTokenServicesConfiguration 、OAuth2SsoDefaultConfiguration 、 OAuth2SsoProperties 和 @EnableOAuth2Client:
OAuth2ClientContextFilter 過濾器相似於 ExceptionTranslationFilter , 它自己沒有作任何過濾處理,只要當 chain.doFilter() 出現異常後 作出一個重定向處理。 但別小看這個重定向處理,它但是實現單點登錄的第一步,還記得第一次單點時會跳轉到受權服務器的登錄頁面麼?而這個功能就是 OAuth2ClientContextFilter 實現的。咱們來看下其源碼:
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; request.setAttribute(CURRENT_URI, calculateCurrentUri(request)); // 一、記錄當前地址(currentUri)到HttpServletRequest try { chain.doFilter(servletRequest, servletResponse); } catch (IOException ex) { throw ex; } catch (Exception ex) { // Try to extract a SpringSecurityException from the stacktrace Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex); UserRedirectRequiredException redirect = (UserRedirectRequiredException) throwableAnalyzer .getFirstThrowableOfType( UserRedirectRequiredException.class, causeChain); if (redirect != null) { // 二、判斷當前異常 UserRedirectRequiredException 對象 是否爲空 redirectUser(redirect, request, response); // 三、重定向訪問 受權服務 /oauth/authorize } else { if (ex instanceof ServletException) { throw (ServletException) ex; } if (ex instanceof RuntimeException) { throw (RuntimeException) ex; } throw new NestedServletException("Unhandled exception", ex); } } }
Debug看下:
整個 filter 分三步:
OAuth2ClientContextFilter 過濾器 其要完成的工做就是 經過獲取到的code碼調用 受權服務 /oauth/token 接口獲取 token 信息,並將獲取到的token 信息解析成 OAuth2Authentication 認證對象。起源以下:
@Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException { OAuth2AccessToken accessToken; try { accessToken = restTemplate.getAccessToken(); //一、 調用受權服務獲取token } catch (OAuth2Exception e) { BadCredentialsException bad = new BadCredentialsException("Could not obtain access token", e); publish(new OAuth2AuthenticationFailureEvent(bad)); throw bad; } try { OAuth2Authentication result = tokenServices.loadAuthentication(accessToken.getValue()); // 二、 解析token信息爲 OAuth2Authentication 認證對象並返回 if (authenticationDetailsSource!=null) { request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_VALUE, accessToken.getValue()); request.setAttribute(OAuth2AuthenticationDetails.ACCESS_TOKEN_TYPE, accessToken.getTokenType()); result.setDetails(authenticationDetailsSource.buildDetails(request)); } publish(new AuthenticationSuccessEvent(result)); return result; } catch (InvalidTokenException e) { BadCredentialsException bad = new BadCredentialsException("Could not obtain user details from token", e); publish(new OAuth2AuthenticationFailureEvent(bad)); throw bad; } }
整個 filter 2點功能:
完成上面步驟後就是一個正常的security受權認證過程,這裏就再也不講述,有不清楚的同窗能夠看下我寫的相關文章。
在講述 OAuth2ClientContextFilter 時有一點沒講,那就是 UserRedirectRequiredException 是 誰拋出來的。 在講述 OAuth2ClientAuthenticationProcessingFilter 也有一點沒講到,那就是它是如何判斷出 當前 /login 是屬於 須要獲取code碼的步驟仍是去獲取 token 的步驟( 固然是判斷/login 是否帶有code 參數,這裏主要講明是誰來判斷的)。 這2個點都設計到了 AuthorizationCodeAccessTokenProvider 這個類。這個類是什麼時候被調用的?
其實 OAuth2ClientAuthenticationProcessingFilter 隱藏在 restTemplate.getAccessToken(); 這個方法內部 調用的 accessTokenProvider.obtainAccessToken() 這裏。 咱們來看下OAuth2ClientAuthenticationProcessingFilter 的 obtainAccessToken() 方法內部源碼:
public OAuth2AccessToken obtainAccessToken(OAuth2ProtectedResourceDetails details, AccessTokenRequest request) throws UserRedirectRequiredException, UserApprovalRequiredException, AccessDeniedException, OAuth2AccessDeniedException { AuthorizationCodeResourceDetails resource = (AuthorizationCodeResourceDetails) details; if (request.getAuthorizationCode() == null) { //一、 判斷當前參數是否包含code碼 if (request.getStateKey() == null) { throw getRedirectForAuthorization(resource, request); //二、 不包含則拋出 UserRedirectRequiredException 異常 } obtainAuthorizationCode(resource, request); } return retrieveToken(request, resource, getParametersForTokenRequest(resource, request), getHeadersForTokenRequest(request)); // 3 、 包含則調用獲取token }
整個方法內部分3步:
最後可能有同窗會問,爲何第一個客戶端單點要跳轉到受權服務登錄頁面去登錄, 而當問第二個客戶端卻沒有,其實 2次 客戶端單點的流程都是同樣的,都是受權碼模式,但爲何客戶端2 卻不須要登錄呢? 實際上是由於Cookies/Session的緣由,由於咱們訪問同2個客戶端基本上都是在同一個瀏覽器中進行的。 不信的同窗能夠試試2個瀏覽器分別訪問2個單點客戶端。
單點登錄本質上就是受權碼模式,因此理解起來仍是很容易的,若是非要給個流程圖,仍是那張受權碼流程圖:
本文介紹 基於JWT的單點登錄(SSO)開發及原理解析 開發的代碼能夠訪問代碼倉庫 ,項目的github 地址 : https://github.com/BUG9/sprin...
若是您對這些感興趣,歡迎star、follow、收藏、轉發給予支持!