1. 程式人生 > 其它 >實戰!基於Security+JWT的單點登陸開發及原理解析

實戰!基於Security+JWT的單點登陸開發及原理解析

在學習 Spring Cloud 時,遇到了授權服務 oauth 相關內容時,總是一知半解,因此決定先把 Spring Security 、Spring Security Oauth2 等許可權、認證相關的內容、原理及設計學習並整理一遍。

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

五、單點測試

效果如下:

【略,圖傳不上來。。。,請看原文吧】

從效果圖中我們可以發現,當我們第一次訪問 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 看下:

整個 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 個單點客戶端。

三、 個人總結

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

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

PS:防止找不到本篇文章,可以收藏點贊,方便翻閱查詢哦