Spring Security 架構簡介

  • 2019 年 11 月 5 日
  • 筆記

一、技術概述

1.1 Spring vs Spring Boot vs Spring Security

1.1.1 Spring Framework

Spring Framework 為開發 Java 應用程序提供了全面的基礎架構支持。它包含了一些不錯的功能,如 「依賴注入」,以及一些現成的模塊:

  • Spring JDBC
  • Spring MVC
  • Spring Security
  • Spring AOP
  • Spring ORM

這些模塊可以大大減少應用程序的開發時間。例如,在 Java Web 開發的早期,我們需要編寫大量樣板代碼以將記錄插入數據源。但是,通過使用 Spring JDBC 模塊的 JDBCTemplate,我們可以僅通過少量配置將其簡化為幾行代碼。

1.1.2 Spring Boot

Spring Boot 是基於 Spring Framework,它為你的 Spring 應用程序提供了自動裝配特性,它的設計目標是讓你儘可能快的上手應用程序的開發。以下是 Spring Boot 所擁有的一些特性:

  • 可以創建獨立的 Spring 應用程序,並且基於 Maven 或 Gradle 插件,可以創建可執行的 JARs 和 WARs;
  • 內嵌 Tomcat 或 Jetty 等 Servlet 容器;
  • 提供自動配置的 「starter」 項目對象模型(POMS)以簡化 Maven 配置;
  • 儘可能自動配置 Spring 容器;
  • 提供一些常見的功能、如監控、WEB容器,健康,安全等功能;
  • 絕對沒有代碼生成,也不需要 XML 配置。
1.1.3 Spring Security

Spring Security 是一個能夠為基於 Spring 的企業應用系統提供聲明式的安全訪問控制解決方案的安全框架。它提供了一組可以在 Spring 應用上下文中配置的 Bean,充分利用了 Spring IoC(Inversion of Control 控制反轉),DI(Dependency Injection 依賴注入)和 AOP(面向切面編程)功能,為應用系統提供聲明式的安全訪問控制功能,減少了為企業系統安全控制編寫大量重複代碼的工作。

Spring Security 擁有以下特性:

  • 對身份驗證和授權的全面且可擴展的支持
  • 防禦會話固定、點擊劫持,跨站請求偽造等攻擊
  • 支持 Servlet API 集成
  • 支持與 Spring Web MVC 集成

Spring、Spring Boot 和 Spring Security 三者的關係如下圖所示:

1.2 Spring Security 集成

目前 Spring Security 5 支持與以下技術進行集成:

  • HTTP basic access authentication
  • LDAP system
  • OpenID identity providers
  • JAAS API
  • CAS Server
  • ESB Platform
  • ……
  • Your own authentication system

在進入 Spring Security 正題之前,我們先來了解一下它的整體架構:

二、核心組件

2.1 SecurityContextHolder,SecurityContext 和 Authentication

最基本的對象是 SecurityContextHolder,它是我們存儲當前應用程序安全上下文的詳細信息,其中包括當前使用應用程序的主體的詳細信息。如當前操作的用戶是誰,該用戶是否已經被認證,他擁有哪些角色權限等。

默認情況下,SecurityContextHolder 使用 ThreadLocal 來存儲這些詳細信息,這意味着 Security Context 始終可用於同一執行線程中的方法,即使 Security Context 未作為這些方法的參數顯式傳遞。

獲取當前用戶的信息

因為身份信息與當前執行線程已綁定,所以可以使用以下代碼塊在應用程序中獲取當前已驗證用戶的用戶名:

Object principal = SecurityContextHolder.getContext()    .getAuthentication().getPrincipal();    if (principal instanceof UserDetails) {    String username = ((UserDetails)principal).getUsername();  } else {    String username = principal.toString();  }

調用 getContext() 返回的對象是 SecurityContext 接口的一個實例,對應 SecurityContext 接口定義如下:

// org/springframework/security/core/context/SecurityContext.java  public interface SecurityContext extends Serializable {  	Authentication getAuthentication();  	void setAuthentication(Authentication authentication);  }
Authentication

在 SecurityContext 接口中定義了 getAuthentication 和 setAuthentication 兩個抽象方法,當調用 getAuthentication 方法後會返回一個 Authentication 類型的對象,這裡的 Authentication 也是一個接口,它的定義如下:

// org/springframework/security/core/Authentication.java  public interface Authentication extends Principal, Serializable {    // 權限信息列表,默認是GrantedAuthority接口的一些實現類,通常是代表權限信息的一系列字符串。  	Collection<? extends GrantedAuthority> getAuthorities();    // 密碼信息,用戶輸入的密碼字符串,在認證過後通常會被移除,用於保障安全。  	Object getCredentials();  	Object getDetails();    // 最重要的身份信息,大部分情況下返回的是UserDetails接口的實現類,也是框架中的常用接口之一。  	Object getPrincipal();  	boolean isAuthenticated();  	void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;  }

以上的 Authentication 接口是 spring-security-core jar 包中的接口,直接繼承自 Principal 類,而 Principal 是位於 java.security 包中,由此可知 Authentication 是 spring security 中核心的接口。通過這個 Authentication 接口的實現類,我們可以得到用戶擁有的權限信息列表,密碼,用戶細節信息,用戶身份信息,認證信息等。

2.2 小結

下面我們來簡單總結一下 SecurityContextHolder,SecurityContext 和 Authentication 這個三個對象之間的關係,SecurityContextHolder 用來保存 SecurityContext (安全上下文對象),通過調用 SecurityContext 對象中的方法,如 getAuthentication 方法,我們可以方便地獲取 Authentication 對象,利用該對象我們可以進一步獲取已認證用戶的詳細信息。

SecurityContextHolder,SecurityContext 和 Authentication 的詳細定義如下所示:

三、身份驗證

3.1 Spring Security 中的身份驗證是什麼?

讓我們考慮一個每個人都熟悉的標準身份驗證方案:

  • 系統會提示用戶使用用戶名和密碼登錄。
  • 系統驗證用戶名和密碼是否正確。
  • 若驗證通過則獲取該用戶的上下文信息(如權限列表)。
  • 為用戶建立安全上下文。
  • 用戶繼續進行,可能執行某些操作,該操作可能受訪問控制機制的保護,該訪問控制機制根據當前安全上下文信息檢查操作所需的權限。

前三項構成了身份驗證進程,因此我們將在 Spring Security 中查看這些內容。

  • 獲取用戶名和密碼並將其組合到 UsernamePasswordAuthenticationToken 的實例中(我們之前看到的Authentication 接口的實例)。
  • 令牌將傳遞給 AuthenticationManager 的實例以進行驗證。
  • AuthenticationManager 在成功驗證時返回完全填充的 Authentication 實例。
  • SecurityContext 對象是通過調用 SecurityContextHolder.getContext().setAuthentication(…) 創建的,傳入返回的身份驗證 Authentication 對象。

3.2 Spring Security 身份驗證流程示例

了解完上述的身份驗證流程,我們來看一個簡單的示例:

AuthenticationManager 接口:

public interface AuthenticationManager {    // 對傳入的authentication對象進行認證  	Authentication authenticate(Authentication authentication)  			throws AuthenticationException;  }

SampleAuthenticationManager 類:

class SampleAuthenticationManager implements AuthenticationManager {    static final List<GrantedAuthority> AUTHORITIES = new ArrayList<GrantedAuthority>();      static {      AUTHORITIES.add(new SimpleGrantedAuthority("ROLE_USER"));    }      public Authentication authenticate(Authentication auth) throws AuthenticationException {      // 判斷用戶名和密碼是否相等,僅當相等時才認證通過      if (auth.getName().equals(auth.getCredentials())) {        return new UsernamePasswordAuthenticationToken(auth.getName(),            auth.getCredentials(), AUTHORITIES);      }      throw new BadCredentialsException("Bad Credentials");     }  }

AuthenticationExample 類:

public class AuthenticationExample {    private static AuthenticationManager am = new SampleAuthenticationManager();      public static void main(String[] args) throws Exception {      BufferedReader in = new BufferedReader(new InputStreamReader(System.in));        while(true) {        System.out.println("Please enter your username:");        String name = in.readLine();        System.out.println("Please enter your password:");        String password = in.readLine();        try {          // 使用用戶輸入的name和password創建request對象,這裡的UsernamePasswordAuthenticationToken          // 是前面提到的Authentication接口的實現類          Authentication request = new UsernamePasswordAuthenticationToken(name, password);          // 使用SampleAuthenticationManager實例,對request進行認證操作          Authentication result = am.authenticate(request);          // 若認證成功,則保存返回的認證信息,包括已認證用戶的授權信息          SecurityContextHolder.getContext().setAuthentication(result);          break;        } catch(AuthenticationException e) {          System.out.println("Authentication failed: " + e.getMessage());        }      }      System.out.println("Successfully authenticated. Security context contains: " +        SecurityContextHolder.getContext().getAuthentication());    }  }

在以上代碼中,我們實現的 AuthenticationManager 將驗證用戶名和密碼相同的任何用戶。它為每個用戶分配一個角色。上面代碼的驗證過程是這樣的:

Please enter your username:  semlinker  Please enter your password:  12345  Authentication failed: Bad Credentials  Please enter your username:  semlinker  Please enter your password:  semlinker  Successfully authenticated. Security context contains: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@441d0230: Principal: semlinker; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_USER

四、 核心服務

4.1 AuthenticationManager,ProviderManager 和 AuthenticationProvider

AuthenticationManager(接口)是認證相關的核心接口,也是發起認證的出發點,因為在實際需求中,我們可能會允許用戶使用用戶名 + 密碼登錄,同時允許用戶使用郵箱 + 密碼,手機號碼 + 密碼登錄,甚至,可能允許用戶使用指紋登錄,所以要求認證系統要支持多種認證方式。

Spring Security 中 AuthenticationManager 接口的默認實現是 ProviderManager,但它本身並不直接處理身份驗證請求,它會委託給已配置的 AuthenticationProvider 列表,每個列表依次被查詢以查看它是否可以執行身份驗證。每個 Provider 驗證程序將拋出異常或返回一個完全填充的 Authentication 對象。

也就是說,Spring Security 中核心的認證入口始終只有一個:AuthenticationManager,不同的認證方式:用戶名 + 密碼(UsernamePasswordAuthenticationToken),郵箱 + 密碼,手機號碼 + 密碼登錄則對應了三個 AuthenticationProvider。

下面我們來看一下 ProviderManager 的核心源碼:

// spring-security-core-5.2.0.RELEASE-sources.jar  // org/springframework/security/authentication/ProviderManager.java  public class ProviderManager implements AuthenticationManager, MessageSourceAware,  		InitializingBean {  	// 維護一個AuthenticationProvider列表    private List<AuthenticationProvider> providers = Collections.emptyList();    	public Authentication authenticate(Authentication authentication)  			throws AuthenticationException {  		Class<? extends Authentication> toTest = authentication.getClass();  		AuthenticationException lastException = null;  		AuthenticationException parentException = null;  		Authentication result = null;  		Authentication parentResult = null;  		boolean debug = logger.isDebugEnabled();        // 遍歷providers列表,判斷是否支持當前authentication對象的認證方式  		for (AuthenticationProvider provider : getProviders()) {  			if (!provider.supports(toTest)) {  				continue;  			}    			try {          // 執行provider的認證方式並獲取返回結果  				result = provider.authenticate(authentication);    				if (result != null) {  					copyDetails(authentication, result);  					break;  				}  			}  			catch (AccountStatusException | InternalAuthenticationServiceException e) {  				prepareException(e, authentication);  				throw e;  			} catch (AuthenticationException e) {  				lastException = e;  			}  		}        // 若當前ProviderManager無法完成認證操作,且其包含父級認證器,則允許轉交給父級認證器嘗試進行認證  		if (result == null && parent != null) {  			try {  				result = parentResult = parent.authenticate(authentication);  			}  			catch (ProviderNotFoundException e) {  			}  			catch (AuthenticationException e) {  				lastException = parentException = e;  			}  		}      		if (result != null) {  			if (eraseCredentialsAfterAuthentication  					&& (result instanceof CredentialsContainer)) {          // 完成認證,從authentication對象中移除私密數據  				((CredentialsContainer) result).eraseCredentials();  			}          // 若父級AuthenticationManager認證成功,則派發AuthenticationSuccessEvent事件  			if (parentResult == null) {  				eventPublisher.publishAuthenticationSuccess(result);  			}  			return result;  		}        // 未認證成功,拋出ProviderNotFoundException異常  		if (lastException == null) {  			lastException = new ProviderNotFoundException(messages.getMessage(  					"ProviderManager.providerNotFound",  					new Object[] { toTest.getName() },  					"No AuthenticationProvider found for {0}"));  		}    		if (parentException == null) {  			prepareException(lastException, authentication);  		}    		throw lastException;  	}  }

在 ProviderManager 進行認證的過程中,會遍歷 providers 列表,判斷是否支持當前 authentication 對象的認證方式,若支持該認證方式時,就會調用所匹配 provider(AuthenticationProvider)對象的 authenticate 方法進行認證操作。若認證失敗則返回 null,下一個 AuthenticationProvider 會繼續嘗試認證,如果所有認證器都無法認證成功,則 ProviderManager 會拋出一個 ProviderNotFoundException 異常。

4.2 DaoAuthenticationProvider

在 Spring Security 中較常用的 AuthenticationProvider 是 DaoAuthenticationProvider,這也是 Spring Security 最早支持的 AuthenticationProvider 之一。顧名思義,Dao 正是數據訪問層的縮寫,也暗示了這個身份認證器的實現思路。DaoAuthenticationProvider 類的內部結構如下:

在實際項目中,最常見的認證方式是使用用戶名和密碼。用戶在登錄表單中提交了用戶名和密碼,而對於已註冊的用戶,在數據庫中已保存了正確的用戶名和密碼,認證便是負責比對同一個用戶名,提交的密碼和數據庫中所保存的密碼是否相同便是了。

在 Spring Security 中,對於使用用戶名和密碼進行認證的場景,用戶在登錄表單中提交的用戶名和密碼,被封裝成了 UsernamePasswordAuthenticationToken,而根據用戶名加載用戶的任務則是交給了 UserDetailsService,在 DaoAuthenticationProvider 中,對應的方法就是 retrieveUser,雖然有兩個參數,但是 retrieveUser 只有第一個參數起主要作用,返回一個 UserDetails。retrieveUser 方法的具體實現如下:

// spring-security-core-5.2.0.RELEASE-sources.jar  // org/springframework/security/authentication/dao/DaoAuthenticationProvider.java  protected final UserDetails retrieveUser(String username,  			UsernamePasswordAuthenticationToken authentication)  			throws AuthenticationException {  		prepareTimingAttackProtection();  		try {  			UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);  			if (loadedUser == null) {  				throw new InternalAuthenticationServiceException(  						"UserDetailsService returned null, which is an interface contract violation");  			}  			return loadedUser;  		}  		catch (UsernameNotFoundException ex) {  			mitigateAgainstTimingAttack(authentication);  			throw ex;  		}  		catch (InternalAuthenticationServiceException ex) {  			throw ex;  		}  		catch (Exception ex) {  			throw new InternalAuthenticationServiceException(ex.getMessage(), ex);  		}  }

在 DaoAuthenticationProvider 類的 retrieveUser 方法中,會以傳入的 username 作為參數,調用 UserDetailsService 對象的 loadUserByUsername 方法加載用戶。

4.3 UserDetails 與 UserDetailsService

4.3.1 UserDetails 接口

在 DaoAuthenticationProvider 類中 retrieveUser 方法簽名是這樣的:

protected final UserDetails retrieveUser(String username,  	UsernamePasswordAuthenticationToken authentication)    throws AuthenticationException {  }

該方法返回 UserDetails 對象,這裡的 UserDetails 也是一個接口,它的定義如下:

public interface UserDetails extends Serializable {  	Collection<? extends GrantedAuthority> getAuthorities();  	String getPassword();  	String getUsername();  	boolean isAccountNonExpired();  	boolean isAccountNonLocked();  	boolean isCredentialsNonExpired();  	boolean isEnabled();  }

顧名思義,UserDetails 表示詳細的用戶信息,這個接口涵蓋了一些必要的用戶信息字段,具體的實現類對它進行了擴展。前面我們也介紹了一個 Authentication 接口,它與 UserDetails 接口的定義如下:

雖然 Authentication 與 UserDetails 很類似,但它們之間是有區別的。Authentication 的 getCredentials() 與 UserDetails 中的 getPassword() 需要被區分對待,前者是用戶提交的密碼憑證,後者是用戶正確的密碼,認證器其實就是對這兩者進行比對。

此外 Authentication 中的 getAuthorities() 實際是由 UserDetails 的 getAuthorities() 傳遞而形成的。還記得 Authentication 接口中的 getUserDetails() 方法嗎?其中的 UserDetails 用戶詳細信息就是經過了 provider (AuthenticationProvider) 認證之後被填充的。

4.3.2 UserDetailsService 接口

大多數身份驗證提供程序都利用了 UserDetailsUserDetailsService 接口。UserDetailsService 接口的定義如下:

public interface UserDetailsService {  	UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;  }

在 UserDetailsService 接口中,只有一個 loadUserByUsername 方法,用於通過 username 來加載匹配的用戶。當找不到 username 對應用戶時,會拋出 UsernameNotFoundException 異常。UserDetailsService 和 AuthenticationProvider 兩者的職責常常被人們搞混,記住一點即可,UserDetailsService 只負責從特定的地方(通常是數據庫)加載用戶信息,僅此而已。

UserDetailsService 常見的實現類有 JdbcDaoImpl,InMemoryUserDetailsManager,前者從數據庫加載用戶,後者從內存中加載用戶,當然你也可以自己實現 UserDetailsService。

4.4 Spring Security Architecture

前面我們已經介紹了 Spring Security 的核心組件(SecurityContextHolder,SecurityContext 和 Authentication)和核心服務(AuthenticationManager,ProviderManager 和 AuthenticationProvider),最後我們再來回顧一下 Spring Security 整體架構:

五、參考資源

  • Docs4dev – Spring Security 中文文檔
  • Slideshare – spring-security-5
  • 徐靖峰 – Spring Security(一)–Architecture Overview