1. 程式人生 > 程式設計 >Spring Security 解析(六) —— 基於JWT的單點登陸(SSO)開發及原理解析

Spring Security 解析(六) —— 基於JWT的單點登陸(SSO)開發及原理解析

Spring Security 解析(六) —— 基於JWT的單點登陸(SSO)開發及原理解析

  在學習Spring Cloud 時,遇到了授權服務oauth 相關內容時,總是一知半解,因此決定先把Spring Security 、Spring Security Oauth2 等許可權、認證相關的內容、原理及設計學習並整理一遍。本系列文章就是在學習的過程中加強印象和理解所撰寫的,如有侵權請告知。

專案環境:

  • JDK1.8
  • Spring boot 2.x
  • Spring Security 5.x

  單點登入(Single Sign On),簡稱為SSO,是目前比較流行的企業業務整合的解決方案之一。 SSO的定義是在多個應用系統中,使用者只需要登入一次就可以訪問所有相互信任的應用系統。 單點登陸本質上也是OAuth2的使用,所以其開發依賴於授權認證服務,如果不清楚的可以看我的上一篇文章。

一、 單點登陸 Demo開發

  從單點登陸的定義上來看就知道我們需要新建個應用程式,我把它命名為 security-sso-client。接下的開發就在這個應用程式上了。

一、Maven 依賴

  主要依賴 spring-boot-starter-security、spring-security-oauth2-autoconfigure、spring-security-oauth2 這3個。其中 spring-security-oauth2-autoconfigure 是Spring Boot 2.X 才有的。

<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

  單點的基礎配置引入是依賴 @EnableOAuth2Sso 實現的,在Spring Boot 2.x 及以上版本 的 @EnableOAuth2Sso 是在 spring-security-oauth2-autoconfigure 依賴裡的。我這裡簡單配置了一下:

@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 設定成無許可權訪問。

三、測試介面及頁面

測試介面
@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 的多環境配置實現。

application.yml 配置

   我們都知道單點實現本質就是Oauth2的授權碼模式,所以我們需要配置訪問授權伺服器的地址資訊,包括 :

  • security.oauth2.client.user-authorization-uri = /oauth/authorize 請求認證的地址,即獲取code 碼
  • security.oauth2.client.access-token-uri = /oauth/token 請求令牌的地址
  • security.oauth2.resource.jwt.key-uri = /oauth/token_key 解析jwt令牌所需要金鑰的地址,服務啟動時會呼叫 授權服務該介面獲取jwt key,所以務必保證授權服務正常
  • security.oauth2.client.client-id = client1 clientId 資訊
  • security.oauth2.client.client-secret = 123456 clientSecret 資訊

其中有幾個配置需要簡單解釋下:

  • security.oauth2.sso.login-path=/login OAuth2授權伺服器觸發重定向到客戶端的路徑 ,預設為 /login,這個路徑要與授權伺服器的回撥地址(域名)後的路徑一致
  • server.servlet.session.cookie.name = OAUTH2CLIENTSESSION 解決單機開發存在的問題,如果是非單機開發可忽略其配置
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-client1.yml 配置

   application-client2 和 application-client1是一樣的,只是埠號和client資訊不一樣而已,這裡就不再重複貼出了。

server:
port: 8091

security:
oauth2:
  client:
    client-id: client1
    client-secret: 123456
  
複製程式碼

五、單點測試

   效果如下:

https://user-gold-cdn.xitu.io/2019/9/17/16d3d5468781d4f8?w=480&h=242&f=gif&s=114834

  從效果圖中我們可以發現,當我們第一次訪問client2 的介面時,跳轉到了授權服務的登陸介面,完成登陸後成功跳轉回到了client2 的測試介面,並且展示了介面返回值。此時我們訪問client1 的 測試介面時直接返回(表面現象)了介面返回值。這就是單點登陸的效果,好奇心強的同學一定會在心裡問道:它是如何實現的? 那麼接下來我們就來揭開其面紗。

二、 單點登陸原理解析

一、@EnableOAuth2Sso

   我們都知道 @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:

  • OAuth2SsoDefaultConfiguration 單點登陸的核心配置,內部建立了 SsoSecurityConfigurer 物件, SsoSecurityConfigurer 內部 主要是配置 OAuth2ClientAuthenticationProcessingFilter 這個單點登陸核心過濾器之一。

  • ResourceServerTokenServicesConfiguration 內部讀取了我們在 yml 中配置的資訊

  • OAuth2SsoProperties 配置了回撥地址url ,這個就是 security.oauth2.sso.login-path=/login 匹配的

  • @EnableOAuth2Client 標明單點客戶端,其內部 主要 配置了 OAuth2ClientContextFilter 這個單點登陸核心過濾器之一

二、 OAuth2ClientContextFilter

   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)); // 1、記錄當前地址(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) {  // 2、判斷當前異常 UserRedirectRequiredException 物件 是否為空
				redirectUser(redirect,request,response); // 3、重定向訪問 授權服務 /oauth/authorize 
			} else {
				if (ex instanceof ServletException) {
					throw (ServletException) ex;
				}
				if (ex instanceof RuntimeException) {
					throw (RuntimeException) ex;
				}
				throw new NestedServletException("Unhandled exception",ex);
			}
		}
	}
複製程式碼

   Debug看下:

微信圖片_20190916173425.png

  整個 filter 分三步:

  • 1、記錄當前地址(currentUri)到HttpServletRequest
  • 2、判斷當前異常 UserRedirectRequiredException 物件 是否為空
  • 3、重定向訪問 授權服務 /oauth/authorize

三、 OAuth2ClientAuthenticationProcessingFilter

   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(); //1、  呼叫授權服務獲取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()); // 2、  解析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點功能:

  • restTemplate.getAccessToken(); //1、 呼叫授權服務獲取token
  • tokenServices.loadAuthentication(accessToken.getValue()); // 2、 解析token資訊為 OAuth2Authentication 認證物件並返回

  完成上面步驟後就是一個正常的security授權認證過程,這裡就不再講述,有不清楚的同學可以看下我寫的相關文章。

四、 AuthorizationCodeAccessTokenProvider

   在講述 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) {  //1、 判斷當前引數是否包含code碼 
			if (request.getStateKey() == null) {
				throw getRedirectForAuthorization(resource,request); //2、 不包含則丟擲 UserRedirectRequiredException 異常
			}
			obtainAuthorizationCode(resource,request);
		}
		return retrieveToken(request,resource,getParametersForTokenRequest(resource,request),getHeadersForTokenRequest(request)); // 3 、 包含則呼叫獲取token 

	}
複製程式碼

整個方法內部分3步:

  • 1、 判斷當前引數是否包含code碼
  • 2、 不包含則丟擲 UserRedirectRequiredException 異常
  • 3、 包含繼續獲取token

   最後可能有同學會問,為什麼第一個客戶端單點要跳轉到授權服務登陸頁面去登陸, 而當問第二個客戶端卻沒有,其實 2次 客戶端單點的流程都是一樣的,都是授權碼模式,但為什麼客戶端2 卻不需要登陸呢? 其實是因為Cookies/Session的原因,因為我們訪問同2個客戶端基本上都是在同一個瀏覽器中進行的。 不信的同學可以試試2個瀏覽器分別訪問2個單點客戶端。

三、 個人總結

  單點登陸本質上就是授權碼模式,所以理解起來還是很容易的,如果非要給個流程圖,還是那張授權碼流程圖:

https://user-gold-cdn.xitu.io/2019/9/16/16d39ae2361d9d48?w=948&h=468&f=jpeg&s=172612

   本文介紹 基於JWT的單點登陸(SSO)開發及原理解析 開發的程式碼可以訪問程式碼倉庫 ,專案的github 地址 : github.com/BUG9/spring…

         如果您對這些感興趣,歡迎star、follow、收藏、轉發給予支援!