Spring Security 02 — Basic Introduction Part II
Spring Security 5.1.4 RELEASE
書接上文,在上一篇中介紹了Spring Security的基本元件以及基本的認證流程,此篇介紹一下Spring Security的一些核心服務。
2 核心服務
Spring Security中還有許多十分重要的介面,特別是 AuthenticationManager
, UserDetailsService
和 AccessDecisionManager
,Spring Security提供了一些實現,使用者亦可自己實現定製的認證授權機制。下面我們來具體看一下幾個介面及Spring Security提供的實現,從而有助於瞭解在認證授權環節中它們的具體作用,以及如何使用。
2.1 AuthenticationManager, ProviderManager and AuthenticationProvider
AuthenticationManger
是一個介面,用來完成認證的邏輯,開發者可按照自己的專案設計需求進行實現。如果,我們希望能夠組合使用多種認證服務,比如基於資料庫和LDAP伺服器的認證服務,Spring Security也是支援的。
ProviderManager
是Spring Security提供的一個實現,但是它本身不處理認證請求,而是將任務委託給一個配置好的 AuthenticationProvider
的列表,其中每一個 AuthenticationProvider
按序確認能否完成認證,每個provider如果認證失敗,會丟擲一個異常,如果認證通過,則會返回一個 Authentication
物件。
AuthenticationManager
AuthenticationManger
是一個介面,其中只有一個方法 authenticate
,用來嘗試對傳入的 Authentication
物件進行認證。
這裡保留了原始碼中的一大段註釋,其中包括了該介面的作用,以及在實現時應當注意的一些問題,這裡不多做敘述,我們目前只需要瞭解這個介面的作用即可,感興趣的同學可以自行閱讀原始碼中的註釋。
/** * Processes an {@link Authentication} request. * * @author Ben Alex */ public interface AuthenticationManager { // ~ Methods // ======================================================================================================== /** * Attempts to authenticate the passed {@link Authentication} object, returning a * fully populated <code>Authentication</code> object (including granted authorities) * if successful. * <p> * An <code>AuthenticationManager</code> must honour the following contract concerning * exceptions: * <ul> * <li>A {@link DisabledException} must be thrown if an account is disabled and the * <code>AuthenticationManager</code> can test for this state.</li> * <li>A {@link LockedException} must be thrown if an account is locked and the * <code>AuthenticationManager</code> can test for account locking.</li> * <li>A {@link BadCredentialsException} must be thrown if incorrect credentials are * presented. Whilst the above exceptions are optional, an * <code>AuthenticationManager</code> must <B>always</B> test credentials.</li> * </ul> * Exceptions should be tested for and if applicable thrown in the order expressed * above (i.e. if an account is disabled or locked, the authentication request is * immediately rejected and the credentials testing process is not performed). This * prevents credentials being tested against disabled or locked accounts. * * @param authentication the authentication request object * * @return a fully authenticated object including credentials * * @throws AuthenticationException if authentication fails */ Authentication authenticate(Authentication authentication) throws AuthenticationException; }
ProviderManager
ProviderManager
是 Authentication
的一個實現,並將具體的認證操作委託給一系列的 AuthenticationProvider
來完成,從而可以實現支援多種認證方式。為了幫助閱讀和理解原始碼具體做了什麼,這裡刪除了原來的一部分註釋,並對重要的部分進行了註釋說明。
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean { private static final Log logger = LogFactory.getLog(ProviderManager.class); private AuthenticationEventPublisher eventPublisher = new NullEventPublisher(); private List<AuthenticationProvider> providers = Collections.emptyList(); protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); private AuthenticationManager parent; private boolean eraseCredentialsAfterAuthentication = true; /** * 用List<AuthenticationProvider>初始化一個ProviderManager * 也就是上文提到的,ProviderManager將具體的認證委託給不同的provider,從而支援不同的認證方式 */ public ProviderManager(List<AuthenticationProvider> providers) { this(providers, null); } /** * 也可以為其設定一個父類 */ public ProviderManager(List<AuthenticationProvider> providers, AuthenticationManager parent) { Assert.notNull(providers, "providers list cannot be null"); this.providers = providers; this.parent = parent; checkState(); } public void afterPropertiesSet() throws Exception { checkState(); } private void checkState() { if (parent == null && providers.isEmpty()) { throw new IllegalArgumentException( "A parent AuthenticationManager or a list " + "of AuthenticationProviders is required"); } } /** * ProviderManager的核心方法,authentication方法嘗試對傳入的Authentication物件進行認證,傳入的Authentication是 * 以使用者的提交的認證資訊,比如使用者名稱和密碼,建立的一個Authentication物件。 * * 會依次詢問各個AuthenticationProvider,當provider支援對傳入的Authentication認證, * 便會嘗試使用該provider進行認證。如果有多個provider都支援認證傳入的Authentication物件, * 則只會使用第一個支援的provider進行認證。 * * 一旦有一個provider認證成功了,便會忽略之前任何provider丟擲的異常,之後的provider也不會再 * 繼續認證的嘗試。 * * 如果所有provider都認證失敗,方法則會丟擲最後一個provider丟擲的異常。 */ 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(); // 依次使用各個provider嘗試進行認證 for (AuthenticationProvider provider : getProviders()) { // 如果provider不支援對傳入的Authentication進行認證,則跳過。 if (!provider.supports(toTest)) { continue; } if (debug) { logger.debug("Authentication attempt using " + provider.getClass().getName()); } try { // 呼叫provider的authenticate方法進行認證 result = provider.authenticate(authentication); // 如果認證成功,則將authentication中使用者的細節資訊複製到result中 // 然後跳出迴圈,不再嘗試後面其他的provider if (result != null) { copyDetails(authentication, result); break; } } catch (AccountStatusException e) { prepareException(e, authentication); // 如果待認證的賬號資訊無誤,但是賬號本身異常,比如賬號停用了,則丟擲AccountStatusException異常, // 並通過prepareException方法,釋出一個AbstractAuthenticationFailureEvent,避免繼續嘗試其他provider進行認證 throw e; } catch (InternalAuthenticationServiceException e) { prepareException(e, authentication); throw e; } catch (AuthenticationException e) { // 如果該provider認證失敗,捕獲異常AuthenticationException後不丟擲,繼續嘗試下一個provider // lastException會記錄下最後一個認證失敗的provider丟擲的AuthenticationException異常。 lastException = e; } } if (result == null && parent != null) { // 如果所有provider都沒能認證成功,則交給父類嘗試認證 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(); } // 如果父類認證成功,則會發佈一個AuthenticationSuccessEvent, // 這一步檢查,防止子類重複釋出 if (parentResult == null) { eventPublisher.publishAuthenticationSuccess(result); } //返回的result為一個Authentication,其中包含了已認證使用者的資訊 return result; } if (lastException == null) { lastException = new ProviderNotFoundException(messages.getMessage( "ProviderManager.providerNotFound", new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}")); } // 如果父類認證失敗,會發佈一個AbstractAuthenticationFailureEvent // 這一步檢查,防止子類重複釋出 if (parentException == null) { prepareException(lastException, authentication); } throw lastException; } @SuppressWarnings("deprecation") private void prepareException(AuthenticationException ex, Authentication auth) { eventPublisher.publishAuthenticationFailure(ex, auth); } /** * 從source中複製使用者的資訊到dest * * Copies the authentication details from a source Authentication object to a * destination one, provided the latter does not already have one set. * * @param source source authentication * @param dest the destination authentication object */ private void copyDetails(Authentication source, Authentication dest) { if ((dest instanceof AbstractAuthenticationToken) && (dest.getDetails() == null)) { AbstractAuthenticationToken token = (AbstractAuthenticationToken) dest; token.setDetails(source.getDetails()); } } public List<AuthenticationProvider> getProviders() { return providers; } public void setMessageSource(MessageSource messageSource) { this.messages = new MessageSourceAccessor(messageSource); } public void setAuthenticationEventPublisher( AuthenticationEventPublisher eventPublisher) { Assert.notNull(eventPublisher, "AuthenticationEventPublisher cannot be null"); this.eventPublisher = eventPublisher; } /** * 設定是否要在認證完成後,讓Authentication呼叫自己的eraseCredentials方法來清除密碼資訊。 * * If set to, a resulting {@code Authentication} which implements the * {@code CredentialsContainer} interface will have its * {@link CredentialsContainer#eraseCredentials() eraseCredentials} method called * before it is returned from the {@code authenticate()} method. * * @param eraseSecretData set to {@literal false} to retain the credentials data in * memory. Defaults to {@literal true}. */ public void setEraseCredentialsAfterAuthentication(boolean eraseSecretData) { this.eraseCredentialsAfterAuthentication = eraseSecretData; } public boolean isEraseCredentialsAfterAuthentication() { return eraseCredentialsAfterAuthentication; } private static final class NullEventPublisher implements AuthenticationEventPublisher { public void publishAuthenticationFailure(AuthenticationException exception, Authentication authentication) { } public void publishAuthenticationSuccess(Authentication authentication) { } } }
至此可以看到,ProviderManager的認證邏輯還是很簡單清晰的,我們也可以比較清楚理解 AuthenticationManager
, ProviderManager
和 AuthenticationProvider
的關係了。
AuthenticationProvider
AuthenticationProvider
也是一個介面,用來完成具體的認證邏輯。不同的認證方式有不同的實現,Spring Security中提供了多種實現,包括 DaoAuthenticationProvider
, AnonymousAuthenticationProvider
和 LdapAuthenticationProvider
等。其中最簡單的 DaoAuthenticationProvider
會在後面介紹,首先來看一下 AuthenticationProvider
的原始碼。同樣的,保留了原始碼中的註釋,感興趣的同學可以細讀,這裡只做簡單的介紹。
可以看到, AuthenticationProvider
中只有2個方法:
-
authenticate
完成具體的認證邏輯,如果認證失敗,丟擲AuthenticationException異常 -
supports
判斷是否支援傳入的Authentication認證資訊
/** * Indicates a class can process a specific * {@link org.springframework.security.core.Authentication} implementation. * * @author Ben Alex */ public interface AuthenticationProvider { // ~ Methods // ======================================================================================================== /** * Performs authentication with the same contract as * {@link org.springframework.security.authentication.AuthenticationManager#authenticate(Authentication)} * . * * @param authentication the authentication request object. * * @return a fully authenticated object including credentials. May return * <code>null</code> if the <code>AuthenticationProvider</code> is unable to support * authentication of the passed <code>Authentication</code> object. In such a case, * the next <code>AuthenticationProvider</code> that supports the presented * <code>Authentication</code> class will be tried. * * @throws AuthenticationException if authentication fails. */ Authentication authenticate(Authentication authentication) throws AuthenticationException; /** * Returns <code>true</code> if this <Code>AuthenticationProvider</code> supports the * indicated <Code>Authentication</code> object. * <p> * Returning <code>true</code> does not guarantee an * <code>AuthenticationProvider</code> will be able to authenticate the presented * instance of the <code>Authentication</code> class. It simply indicates it can * support closer evaluation of it. An <code>AuthenticationProvider</code> can still * return <code>null</code> from the {@link #authenticate(Authentication)} method to * indicate another <code>AuthenticationProvider</code> should be tried. * </p> * <p> * Selection of an <code>AuthenticationProvider</code> capable of performing * authentication is conducted at runtime the <code>ProviderManager</code>. * </p> * * @param authentication * * @return <code>true</code> if the implementation can more closely evaluate the * <code>Authentication</code> class presented */ boolean supports(Class<?> authentication); }
DaoAuthenticationProvider
DaoAuthenticationProvider
是Spring Security提供的最簡單的一個 AuthenticationProvider
的實現,也是框架中最早支援的。它使用 UserDetailsService
作為一個DAO來查詢使用者名稱、密碼以及使用者的許可權 GrantedAuthority
。它認證使用者的方式就是簡單的比較 UsernamePasswordAuthenticationToken
中由使用者提交的密碼和通過 UserDetailsService
查詢獲得的密碼是否一致。
下面我們來看一下 DaoAuthenticationProvider
的原始碼,對於原始碼的說明也寫在了註釋中。
DaoAuthenticationProvider
繼承了 AbstractUserDetailsAuthenticationProvider
,而後者實現了 AuthenticationProvider
介面。
public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { /** * The plaintext password used to perform * PasswordEncoder#matches(CharSequence, String)}on when the user is * not found to avoid SEC-2056. */ private static final String USER_NOT_FOUND_PASSWORD = "userNotFoundPassword"; private PasswordEncoder passwordEncoder; /** * The password used to perform * {@link PasswordEncoder#matches(CharSequence, String)} on when the user is * not found to avoid SEC-2056. This is necessary, because some * {@link PasswordEncoder} implementations will short circuit if the password is not * in a valid format. */ private volatile String userNotFoundEncodedPassword; private UserDetailsService userDetailsService; private UserDetailsPasswordService userDetailsPasswordService; public DaoAuthenticationProvider() { setPasswordEncoder(PasswordEncoderFactories.createDelegatingPasswordEncoder()); } @SuppressWarnings("deprecation") protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { // 使用者未提交密碼,丟擲異常BadCredentialsException if (authentication.getCredentials() == null) { logger.debug("Authentication failed: no credentials provided"); throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } // 從傳入了Authentication物件中獲取使用者提交的密碼 String presentedPassword = authentication.getCredentials().toString(); // 用passwordEncoder的matches方法,比較使用者提交的密碼和userDetails中查詢到的正確密碼。 // 由於使用者密碼的存放一般都是hash後保密的,因此userDetails獲取到的密碼一般是一個hash值,而使用者提交 // 的是一個明文密碼,因此需要對使用者提交的密碼進行同樣的hash計算後再進行比較。 if (!passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { logger.debug("Authentication failed: password does not match stored value"); throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } } protected void doAfterPropertiesSet() throws Exception { Assert.notNull(this.userDetailsService, "A UserDetailsService must be set"); } 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); } } @Override protected Authentication createSuccessAuthentication(Object principal, Authentication authentication, UserDetails user) { boolean upgradeEncoding = this.userDetailsPasswordService != null && this.passwordEncoder.upgradeEncoding(user.getPassword()); if (upgradeEncoding) { String presentedPassword = authentication.getCredentials().toString(); String newPassword = this.passwordEncoder.encode(presentedPassword); user = this.userDetailsPasswordService.updatePassword(user, newPassword); } return super.createSuccessAuthentication(principal, authentication, user); } private void prepareTimingAttackProtection() { if (this.userNotFoundEncodedPassword == null) { this.userNotFoundEncodedPassword = this.passwordEncoder.encode(USER_NOT_FOUND_PASSWORD); } } private void mitigateAgainstTimingAttack(UsernamePasswordAuthenticationToken authentication) { if (authentication.getCredentials() != null) { String presentedPassword = authentication.getCredentials().toString(); this.passwordEncoder.matches(presentedPassword, this.userNotFoundEncodedPassword); } } /** * Sets the PasswordEncoder instance to be used to encode and validate passwords. If * not set, the password will be compared using {@link PasswordEncoderFactories#createDelegatingPasswordEncoder()} * * @param passwordEncoder must be an instance of one of the {@code PasswordEncoder} * types. */ public void setPasswordEncoder(PasswordEncoder passwordEncoder) { Assert.notNull(passwordEncoder, "passwordEncoder cannot be null"); this.passwordEncoder = passwordEncoder; this.userNotFoundEncodedPassword = null; } protected PasswordEncoder getPasswordEncoder() { return passwordEncoder; } public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } protected UserDetailsService getUserDetailsService() { return userDetailsService; } public void setUserDetailsPasswordService( UserDetailsPasswordService userDetailsPasswordService) { this.userDetailsPasswordService = userDetailsPasswordService; } }
AbstractUserDetailsAuthenticationProvider
可以看到 DaoAuthenticationProvider
繼承自 AbstractUserDetailsAuthenticationProvider
, 而一個provider最核心的 authenticate
方法,便寫在了 AbstractUserDetailsAuthenticationProvider
中,下面我們只關注一下 authenticate
這個方法的原始碼。
public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> messages.getMessage( "AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); // 從傳入的Authentication物件中獲取使用者名稱 String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); // 根據使用者名稱,從快取中獲取使用者的UserDetails boolean cacheWasUsed = true; UserDetails user = this.userCache.getUserFromCache(username); if (user == null) { cacheWasUsed = false; // 如果從快取中沒有獲取到使用者,則通過方法retrieveUser來獲取使用者資訊 // retrieve方法為一個抽象方法,不同的子類中有不同的實現,而在子類中,一般又會通過UserDetailService來獲取使用者資訊,返回UserDetails try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } catch (UsernameNotFoundException notFound) { logger.debug("User '" + username + "' not found"); if (hideUserNotFoundExceptions) { throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } else { throw notFound; } } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); } try { preAuthenticationChecks.check(user); // additionalAuthenticationChecks為具體的認證邏輯,是一個抽象方法,在子類中實現。 // 比如前文中DaoAuthenticationProvider中,便是比較使用者提交的密碼和UserDetails中的密碼 additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } catch (AuthenticationException exception) { if (cacheWasUsed) { // There was a problem, so try again after checking // we're using latest data (i.e. not from the cache) cacheWasUsed = false; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } else { throw exception; } } postAuthenticationChecks.check(user); if (!cacheWasUsed) { this.userCache.putUserInCache(user); } Object principalToReturn = user; if (forcePrincipalAsString) { principalToReturn = user.getUsername(); } return createSuccessAuthentication(principalToReturn, authentication, user); }
可以看到在 DaoAuthenticationProvider
中還用到 UserDetailsService
來查詢使用者的密碼許可權資訊,幷包裝為 UserDetails
返回,然後與使用者提交的使用者名稱密碼資訊進行比較來完成認證。 UserDetailsService
和 UserDetails
在不同的provider中都會被用到,關於這兩個介面的說明,在下一篇文章中介紹。
總結
最後我們總結一下這幾個介面和類
名稱 | 型別 | 說明 |
---|---|---|
AuthenticationManager | 介面 | 完成認證 |
ProviderManager | 類 | 實現AuthenticationManager介面,將認證任務委託給不同的AuthenticationProvider |
AuthenticationProvider | 介面 | 具體認證邏輯的介面 |
AbstractUserDetailsAuthenticationProvider | 抽象類 | 實現了AuthenticationProvider介面,獲取使用者資訊並完成認證的抽象類 |
DaoAuthenticationProvider | 類 | 繼承AbstractUserDetailsAuthenticationProvider,實現具體的認證邏輯 |
這些類和介面之間的關係,大致可以用下圖進行表示