Spring Securtiy 認證流程(原始碼分析)
當用 Spring Security 框架進行認證時,你可能會遇到這樣的問題:
你輸入的使用者名稱或密碼不管是空還是錯誤,它的錯誤資訊都是 Bad credentials。
那麼如果你想根據不同的情況給出相應的錯誤提示該怎麼辦呢?
這個時候我們只有瞭解 Spring Securiy 認證的流程才能知道如何修改程式碼。
好啦,來看下面的例子,大部分人的 WebSecurityConfig 的 configure 程式碼都類似於下:
@Override protected void configure(HttpSecurity http) throws Exception { // TODO Auto-generated method stub http .authorizeRequests() .anyRequest().permitAll() .and() .formLogin().loginPage("/signin") .usernameParameter("username") .passwordParameter("password") .loginProcessingUrl("/signin") .and() .csrf().disable(); }
相信以上程式碼大家都知道什麼意思:任何請求資訊都允許,也就是不需要身份認證。
登入頁面請求為 /signin,使用者名稱和密碼引數的name屬性分別是 username,password。登入頁面 form 的 action 請求為 /signin。
當然這個 action 不必和登入頁面請求一樣。最後的那個是禁止跨站請求偽造。
這段程式碼和登入認證聯絡較大的應該是從 loginPage() 到 loginProcessingUrl() 裡的方法。
咱先從 loginPage 看起,滑鼠左鍵拖動覆蓋 loginPage,然後右鍵 Open Declaration 就進入到了 FormLoginConfigurer 類。
這個類裡值得注意的方法有兩個:構造方法和 loginPage 方法。
public FormLoginConfigurer() { super(new UsernamePasswordAuthenticationFilter(), null); usernameParameter("username"); passwordParameter("password"); } public FormLoginConfigurer<H> loginPage(String loginPage) { return super.loginPage(loginPage); }
構造方法中使用了一個使用者名稱密碼認證過濾器類,這一看就和認證有關係。
loginPage 方法大家可以自行按照這個步驟檢視,現在直接看 UsernamePasswordAuthenticationFilter 類。
public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username"; public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password"; private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY; private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY; private boolean postOnly = true; public UsernamePasswordAuthenticationFilter() { super(new AntPathRequestMatcher("/login", "POST")); } public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { if (postOnly && !request.getMethod().equals("POST")) { throw new AuthenticationServiceException( "Authentication method not supported: " + request.getMethod()); } String username = obtainUsername(request); String password = obtainPassword(request); if (username == null) { username = ""; } if (password == null) { password = ""; } username = username.trim(); UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
setDetails(request, authRequest); return this.getAuthenticationManager().authenticate(authRequest); }
這只是其中一部分程式碼,其他的可以自己看。該類中定義的兩個字串和構造方法定義了預設的登入方式。
登入 action 請求為以 POST 方式的 /login,使用者名稱及密碼分別以 username,password 屬性值獲取。
該類的父類的父類 GenericFilterBean 實現了 InitializingBean 介面,也就是會初始化為一個 Bean。
當看到 attemptAuthentication 時,就知道他是認證的方法啦。
這裡咱直接看到 new UsernamePasswordAuthenticationToken(username, password);
public UsernamePasswordAuthenticationToken(Object principal, Object credentials) { super(null); this.principal = principal; this.credentials = credentials; setAuthenticated(false); }
從這裡可以知道,它把使用者名稱和密碼分別存在了 principal,credentials 裡。
現在我們只需要記住登入資訊存在了 authRequest 裡。現在來看下setDetails,雖然我不感興趣。
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) { authRequest.setDetails(authenticationDetailsSource.buildDetails(request)); }
它呼叫了一個 buildDetails 方法,實際上是呼叫的:(追根溯源可以看到)
/**
* Records the remote address and will also set the session Id if a session already
* exists (it won't create one).
*
* @param request that the authentication request was received from
*/
public WebAuthenticationDetails(HttpServletRequest request) { this.remoteAddress = request.getRemoteAddr(); HttpSession session = request.getSession(false); this.sessionId = (session != null) ? session.getId() : null; }
從原始碼註釋可以看到,它是記錄遠端地址並且會設定一個會話 ID,這裡我們不管它了。
直接看這一句:return this.getAuthenticationManager().authenticate(authRequest);
它呼叫的是一個實現了 AuthenticationManager 介面的類的 authenticate 方法。
從原始碼中我們找不到它用的是哪個實現類,網上說是 ProviderManager 類,我們來看一下該類。
public class ProviderManager implements AuthenticationManager, MessageSourceAware, InitializingBean { 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(); for (AuthenticationProvider provider : getProviders()) { if (!provider.supports(toTest)) { continue; } if (debug) { logger.debug("Authentication attempt using " + provider.getClass().getName()); } try { result = provider.authenticate(authentication); if (result != null) { copyDetails(authentication, result); break; } } catch (AccountStatusException e) { prepareException(e, authentication); // SEC-546: Avoid polling additional providers if auth failure is due to // invalid account status throw e; } catch (InternalAuthenticationServiceException e) { prepareException(e, authentication); throw e; } catch (AuthenticationException e) { lastException = e; } } if (result == null && parent != null) { // Allow the parent to try. try { result = parentResult = parent.authenticate(authentication); } catch (ProviderNotFoundException e) { // ignore as we will throw below if no other exception occurred prior to // calling parent and the parent // may throw ProviderNotFound even though a provider in the child already // handled the request } catch (AuthenticationException e) { lastException = parentException = e; } } if (result != null) { if (eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) { // Authentication is complete. Remove credentials and other secret data // from authentication ((CredentialsContainer) result).eraseCredentials(); } // If the parent AuthenticationManager was attempted and successful than it will publish an AuthenticationSuccessEvent // This check prevents a duplicate AuthenticationSuccessEvent if the parent AuthenticationManager already published it if (parentResult == null) { eventPublisher.publishAuthenticationSuccess(result); } return result; } // Parent was null, or didn't authenticate (or throw an exception). if (lastException == null) { lastException = new ProviderNotFoundException(messages.getMessage( "ProviderManager.providerNotFound", new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}")); } // If the parent AuthenticationManager was attempted and failed than it will publish an AbstractAuthenticationFailureEvent // This check prevents a duplicate AbstractAuthenticationFailureEvent if the parent AuthenticationManager already published it if (parentException == null) { prepareException(lastException, authentication); } throw lastException; } }
這裡我只給出該類的宣告和 authenticate 方法,從類的宣告可以看出來它也會初始化為一個 Bean,咱找不到很正常對吧。
authenticate 方法會遍歷所有的 AuthenticationProvider ,然後呼叫 provider 的 authenticate 方法。
如果認證結果不為空的話將會儲存到 result 中,並且擦除認證資訊再返回 result。
為空的話一般是沒有提供 AuthenticationProvider,會報 ProviderNotFoundException 錯誤。
現在我們來看下 provider 的 authenticate 方法。
@Bean public AuthenticationProvider authenticationProvider() { DaoAuthenticationProvider provider = new CustomAuthenticationProvider(); provider.setMessageSource(messageSource); provider.setUserDetailsService(userService); provider.setPasswordEncoder(new BCryptPasswordEncoder()); return provider; }
這個是我寫的一個 AuthenticationProvider,只不過我重寫了一個類繼承了 DaoAuthenticationProvider。
這裡我們來看 DaoAuthenticationProvider 類:(這個類裡面並沒有發現 authenticate 方法,那先從它的父類找)
父類是 AbstractUserDetailsAuthenticationProvider,它也實現了 InitializingBean 介面,也是初始化為一個 Bean。
public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> messages.getMessage( "AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); // Determine username String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); boolean cacheWasUsed = true; UserDetails user = this.userCache.getUserFromCache(username); if (user == null) { cacheWasUsed = false; 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(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); }
在這段程式碼中可以知道:如果 authentication.getPrincipal() 為空的話,username 將會為 NONE_PROVIDED。
不為空的話將會得到 authentication.getPrincipal(),也就是使用者名稱,只是這種型別不是 String 型別,但可以強制轉換。
程式碼中是 authentication.getName(),這種和上面基本一樣,只不過該型別是 String 型別的。
然後定義一個 user,先嚐試從快取中獲取 user,沒獲取到的話就通過 retrieveUser 獲取。
該類中 retrieveUser 是一個抽象方法,我們現在來看 DaoAuthenticationProvider 類裡的方法。
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); } }
從程式碼中可以看到是通過我們之前寫的 UserDetailsService 方法獲取使用者。
接下來我們看後面的程式碼,這部分異常程式碼我們等會再看。
try { preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); }
這兩句程式碼是對使用者進行檢查的,第一行程式碼呼叫的其實是這部分的:
private class DefaultPreAuthenticationChecks implements UserDetailsChecker { public void check(UserDetails user) { if (!user.isAccountNonLocked()) { logger.debug("User account is locked"); throw new LockedException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.locked", "User account is locked")); } if (!user.isEnabled()) { logger.debug("User account is disabled"); throw new DisabledException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled")); } if (!user.isAccountNonExpired()) { logger.debug("User account is expired"); throw new AccountExpiredException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.expired", "User account has expired")); } } }
可以看到並不是檢查密碼的,只是對使用者狀態進行檢查。那麼我們不管它了,看下一行程式碼:
@SuppressWarnings("deprecation") protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { if (authentication.getCredentials() == null) { logger.debug("Authentication failed: no credentials provided"); throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } String presentedPassword = authentication.getCredentials().toString(); 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")); } }
這裡有個獲取密碼的操作:authentication.getCredentials()。
然後如果密碼不為空的話就通過 passwordEncoder.matches(presentedPassword, userDetails.getPassword() 檢查是否匹配。
如果匹配成功的話,嗯,這部分結束了,我們回到 AbstractUserDetailsAuthenticationProvider 類裡的 authenticate 方法。
return createSuccessAuthentication(principalToReturn, authentication, user);
它會返回一個建立成功認證方法的返回值。這裡我們就不管了。
現在我們先回到AbstractUserDetailsAuthenticationProvider 類的錯誤處理上:
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; } }
這個是使用者找不到引起的錯誤,我們看下 messages.getMessage():
public String getMessage(String code, String defaultMessage) { String msg = this.messageSource.getMessage(code, null, defaultMessage, getDefaultLocale()); return (msg != null ? msg : ""); }
再來看下這個裡面的 getMessage():
它是一個介面類裡的方法:根據 code 返回 messageSource 裡的字串,如果不存在這個 code,就返回 defaultMessage。
既然是個介面類,那我們看下它的實現類,回到 messageSource,檢視一下它:
public class SpringSecurityMessageSource extends ResourceBundleMessageSource { // ~ Constructors // =================================================================================================== public SpringSecurityMessageSource() { setBasename("org.springframework.security.messages"); } // ~ Methods // ======================================================================================================== public static MessageSourceAccessor getAccessor() { return new MessageSourceAccessor(new SpringSecurityMessageSource()); } }
原來是從這個路徑裡找資料來源。
其他的錯誤處理也是一樣,這裡就省略了。那我們如何獲取錯誤資訊呢?
@Override protected void configure(HttpSecurity http) throws Exception { // TODO Auto-generated method stub http .authorizeRequests() .anyRequest().permitAll() .and() .formLogin().loginPage("/signin") .usernameParameter("username") .passwordParameter("password") .loginProcessingUrl("/signin") .failureHandler(authenticationFailureHandler) .and() .csrf().disable();
看到那個 failureHandler 沒,這個是登入失敗處理器,這裡加上只是看一下里面原始碼:AbstractAuthenticationFilterConfigurer
/** * Specifies the {@link AuthenticationFailureHandler} to use when authentication * fails. The default is redirecting to "/login?error" using * {@link SimpleUrlAuthenticationFailureHandler} * * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} to use * when authentication fails. * @return the {@link FormLoginConfigurer} for additional customization */ public final T failureHandler( AuthenticationFailureHandler authenticationFailureHandler) { this.failureUrl = null; this.failureHandler = authenticationFailureHandler; return getSelf(); }
從註釋中可以看出預設的失敗處理器是 SimpleUrlAuthenticationFailureHandler:
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { if (defaultFailureUrl == null) { logger.debug("No failure URL set, sending 401 Unauthorized error"); response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase()); } else { saveException(request, exception); if (forwardToDestination) { logger.debug("Forwarding to " + defaultFailureUrl); request.getRequestDispatcher(defaultFailureUrl) .forward(request, response); } else { logger.debug("Redirecting to " + defaultFailureUrl); redirectStrategy.sendRedirect(request, response, defaultFailureUrl); } } }
因為預設的 defaultFailureUrl 為 /login?error,從 AbstractAuthenticationFilterConfigurer 類裡可以看出來。
登入失敗後,會呼叫 saveException(request, exception); 儲存錯誤資訊。
protected final void saveException(HttpServletRequest request, AuthenticationException exception) { if (forwardToDestination) { request.setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, exception); } else { HttpSession session = request.getSession(false); if (session != null || allowSessionCreation) { request.getSession().setAttribute(WebAttributes.AUTHENTICATION_EXCEPTION, exception); } } }
由於該類中 forwardToDestination 為 false,它將執行 else 裡的語句。
將錯誤資訊儲存到會話的 WebAttributes.AUTHENTICATION_EXCEPTION 屬性中:
public static final String AUTHENTICATION_EXCEPTION = "SPRING_SECURITY_LAST_EXCEPTION";
所有我們可以通過會話的這個屬性來獲取錯誤資訊。(thymeleaf)
(注意:signin.html 不能放在 static 目錄下,不然獲取不到錯誤資訊。)
<p th:if="${param.error}" th:text="${session?.SPRING_SECURITY_LAST_EXCEPTION?.message}" ></p>
好啦,都介紹完了,可以看下我的 CustomAuthenticationProvider:
package security.config; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.util.Assert; public class CustomAuthenticationProvider extends DaoAuthenticationProvider { @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { // TODO Auto-generated method stub String presentedPassword = authentication.getCredentials().toString(); if (!getPasswordEncoder().matches(presentedPassword, userDetails.getPassword())) { logger.debug("Authentication failed: password does not match stored value"); throw new BadCredentialsException(messages.getMessage( "UNameOrPwdIsError","Username or Password is not correct")); } } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { // TODO Auto-generated method stub Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> messages.getMessage( "AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); if("".equals(authentication.getPrincipal())) { throw new BadCredentialsException(messages.getMessage( "UsernameIsNull","Username cannot be empty")); } if("".equals(authentication.getCredentials())) { throw new BadCredentialsException(messages.getMessage( "PasswordIsNull","Password cannot be empty")); } String username = (String) authentication.getPrincipal(); boolean cacheWasUsed = true; UserDetails user = this.getUserCache().getUserFromCache(username); if (user == null) { cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } catch (UsernameNotFoundException notFound) { logger.debug("User '" + username + "' not found"); if (hideUserNotFoundExceptions) { throw new BadCredentialsException(messages.getMessage( "UNameOrPwdIsError","Username or Password is not correct")); } else { throw notFound; } } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); } try { getPreAuthenticationChecks().check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } catch (AuthenticationException exception) { if (cacheWasUsed) { cacheWasUsed = false; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); getPreAuthenticationChecks().check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } else { throw exception; } } getPostAuthenticationChecks().check(user); if (!cacheWasUsed) { this.getUserCache().putUserInCache(user); } Object principalToReturn = user; if (isForcePrincipalAsString()) { principalToReturn = user.getUsername(); } return createSuccessAuthentication(principalToReturn, authentication, user); } }
這裡值得注意的是 "".equals(authentication.getPrincipal()),"".equals(authentication.getCredentials())
因為如果按照那個 AbstractUserDetailsAuthenticationProvider 類來寫的話,發現這一步永不為 null。
我通過加入程式碼 System.out.println(username); 才知道的,應該是個坑吧。
專案程式碼可供大家參考:
連結:https://pan.baidu.com/s/1pNWQMyIgZOzX5_rF3Tvd2A
提取碼: