目錄
1 認證流程
Spring Security是如何完成身份認證的?
具體攔截在UsernamePasswordAuthenticationFilter->AbstractAuthenticationProcessingFilter攔截器中;
具體認證流程在ProviderManager->AuthenticationManager中
UsernamePasswordAuthenticationFilter類
public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean
implements ApplicationEventPublisherAware, MessageSourceAware {
.....省略
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
//判斷是否需要進行驗證,其實就是判斷請求的路徑是否是/login,如果不是/login,說明不是form表單登錄請求,則不需要進行驗證
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;//如果不需要驗證,則直接return,下面的代碼邏輯都不會走了
}
if (logger.isDebugEnabled()) {
logger.debug("Request is to process authentication");
}
Authentication authResult;
try {
//調用attemptAuthentication方法進行驗證,並獲得驗證結果Authentication對象。
authResult = attemptAuthentication(request, response);
if (authResult == null) {
return;
}
//這段代碼主要是爲了防止session劫持(session fixation attacks)
sessionStrategy.onAuthentication(authResult, request, response);
}
catch (InternalAuthenticationServiceException failed) {
// 驗證失敗
logger.error("An internal error occurred while trying to authenticate the user.",failed);
unsuccessfulAuthentication(request, response, failed);
return;
}catch (AuthenticationException failed) {
// 驗證失敗
unsuccessfulAuthentication(request, response, failed);
return;
}
if (continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
//驗證成功
successfulAuthentication(request, response, chain, authResult);
}
.....省略
}
當驗證失敗時,需要拋出AuthenticationException異常。具體來說,驗證失敗可能分爲2種情況:
- 驗證服務失敗,例如我們從數據庫中查詢用戶名和密碼驗證用戶,但是數據庫服務掛了,此時拋出InternalAuthenticationServiceException異常
- 驗證參數失敗,例如用戶輸入的用戶名和密碼錯誤,此時拋出AuthenticationException異常
InternalAuthenticationServiceException是AuthenticationException的子類,因此我們看到在上面的catch代碼塊中,先捕獲前者,再捕獲後者。
無論是哪一種情況,都會調用unsuccessfulAuthentication方法,此方法內部會跳轉到我們定義的登錄失敗頁面。
如果驗證成功,會調用successfulAuthentication方法,默認情況下,這個方法內部會將用戶登錄信息放到Session中,然後跳轉到我們定義的登錄成功頁面。
上述代碼中,最重要的就是attemptAuthentication方法,這是一個抽象方法,UsernamePasswordAuthenticationFilter中進行了實現,以實現自己的驗證邏輯,也就是前面我看到的從HttpServletRequest對象中獲得用戶名和密碼,進行驗證。
public class UsernamePasswordAuthenticationFilter extends
AbstractAuthenticationProcessingFilter {
.....省略
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"));//指定form表單的action屬性值爲/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);//內部調用request.getParameter(usernameParameter)獲得用戶名
String password = obtainPassword(request);//內部調用request.getParameter(passwordParameter)獲得密碼
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
// 用戶名和密碼被過濾器獲取到,封裝成Authentication
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
setDetails(request, authRequest);
// AuthenticationManager 身份管理器負責驗證這個Authentication
return this.getAuthenticationManager().authenticate(authRequest);
}
.....省略
}
在UsernamePasswordAuthenticationFilter中,指定了form表單的method屬性必須爲post,同時指定了form的action屬性值默認/login。當用戶表單提交時,attemptAuthentication方法會被回調,這個方法內部會通過HttpServletRequest.getParameter的方式,獲得表單中填寫的用戶名和密碼的值,封裝成一個UsernamePasswordAuthenticationToken對象,然後利用ProviderManager類進行校驗。
ProviderManager類
public class ProviderManager implements AuthenticationManager, MessageSourceAware,
InitializingBean {
.....省略
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
Class<? extends Authentication> toTest = authentication.getClass();
AuthenticationException lastException = 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 {
// 由具體的provider認證
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
}
catch (AccountStatusException e) {
prepareException(e, authentication);
throw e;
}
catch (InternalAuthenticationServiceException e) {
prepareException(e, authentication);
throw e;
}
catch (AuthenticationException e) {
lastException = e;
}
}
if (result == null && parent != null) {
try {
result = parentResult = parent.authenticate(authentication);
}
catch (ProviderNotFoundException e) {
}
catch (AuthenticationException e) {
lastException = e;
}
}
if (result != null) {
if (eraseCredentialsAfterAuthentication
&& (result instanceof CredentialsContainer)) {
((CredentialsContainer) result).eraseCredentials();
}
if (parentResult == null) {
eventPublisher.publishAuthenticationSuccess(result);
}
return result;
}
if (lastException == null) {
lastException = new ProviderNotFoundException(messages.getMessage(
"ProviderManager.providerNotFound",
new Object[] { toTest.getName() },
"No AuthenticationProvider found for {0}"));
}
prepareException(lastException, authentication);
throw lastException;
}
.....省略
}
初次接觸Spring Security的朋友相信會被AuthenticationManager,ProviderManager ,AuthenticationProvider …這麼多相似的Spring認證類搞得暈頭轉向,但只要稍微梳理一下就可以理解清楚它們的聯繫和設計者的用意。AuthenticationManager(接口)是認證相關的核心接口,也是發起認證的出發點,因爲在實際需求中,我們可能會允許用戶使用用戶名+密碼登錄,同時允許用戶使用郵箱+密碼,手機號碼+密碼登錄,甚至,可能允許用戶使用指紋登錄(還有這樣的操作?沒想到吧),所以說AuthenticationManager一般不直接認證,AuthenticationManager接口的常用實現類ProviderManager 內部會維護一個List列表,存放多種認證方式,實際上這是委託者模式的應用(Delegate)。也就是說,核心的認證入口始終只有一個:AuthenticationManager,不同的認證方式:用戶名+密碼(UsernamePasswordAuthenticationToken),郵箱+密碼,手機號碼+密碼登錄則對應了三個AuthenticationProvider。這樣一來四不四就好理解多了?熟悉shiro的朋友可以把AuthenticationProvider理解成Realm。在默認策略下,只需要通過一個AuthenticationProvider的認證,即可被認爲是登錄成功。
具體流程總結
- 用戶名和密碼被過濾器獲取到,封裝成Authentication,通常情況下是UsernamePasswordAuthenticationToken這個實現類。
- AuthenticationManager 身份管理器負責驗證這個Authentication
- 在AuthenticationManager 通過AuthenticationProvider認證
- 認證成功後,AuthenticationManager身份管理器返回一個被填充滿了信息的(包括上面提到的權限信息,身份信息,細節信息,但密碼通常會被移除)Authentication實例。
- SecurityContextHolder安全上下文容器將第3步填充了信息的Authentication,通過SecurityContextHolder.getContext().setAuthentication(…)方法,設置到其中。
時序圖
2 loginPage方法
FormLoginConfigurer的loginPage方法用於自定義登錄頁面。如果我們沒有調用這個方法,spring security將會註冊一個DefaultLoginPageGeneratingFilter,這個filter的generateLoginPageHtml方法會幫我們生成一個默認的登錄頁面,也就是我們前面章節看到的那樣。在本節案例中,我們自定義了登錄頁面地址爲/login.html,則DefaultLoginPageGeneratingFilter不會被註冊。
FormLoginConfigurer繼承了AbstractAuthenticationFilterConfigurer,事實上,loginPage方法定義在這個類中。AbstractAuthenticationFilterConfigurer中維護了一個customLoginPage字段,用於記錄用戶是否設置了自定義登錄頁面。
AbstractAuthenticationFilterConfigurer#loginPage
protected T loginPage(String loginPage) {
setLoginPage(loginPage);//當spring security判斷用戶需要登錄時,會跳轉到loginPage指定的頁面中
updateAuthenticationDefaults();
this.customLoginPage = true; //標記用戶使用了自定義登錄頁面
return getSelf();
}
而在FormLoginConfigurer初始化時,會根據customLoginPage的值判斷是否註冊DefaultLoginPageGeneratingFilter。參見:
FormLoginConfigurer#initDefaultLoginFilter
private void initDefaultLoginFilter(H http) {
DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
.getSharedObject(DefaultLoginPageGeneratingFilter.class);
//如果沒有自定義登錄頁面,則使用DefaultLoginPageGeneratingFilter
if (loginPageGeneratingFilter != null && !isCustomLoginPage()) {
loginPageGeneratingFilter.setFormLoginEnabled(true);
loginPageGeneratingFilter.setUsernameParameter(getUsernameParameter());
loginPageGeneratingFilter.setPasswordParameter(getPasswordParameter());
loginPageGeneratingFilter.setLoginPageUrl(getLoginPage());
loginPageGeneratingFilter.setFailureUrl(getFailureUrl());
loginPageGeneratingFilter.setAuthenticationUrl(getLoginProcessingUrl());
}
}
在前面的配置中,我們指定的loginPage是一個靜態頁面login.html,我們也可以定義一個Controller,返回一個ModelAndView對象跳轉到登錄頁面,注意Controller中方法的@RequestMapping註解的值,需要和loginPage方法中的值相對應。
可以看到這裏,還創建了一個LoginUrlAuthenticationEntryPoint對象,事實上,跳轉的邏輯就是在這個類中完成的。
LoginUrlAuthenticationEntryPoint#commence
//commence方法用戶判斷跳轉到登錄頁面時,是使用重定向(redirect)的方式,還是使用轉發(forward)的方式
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
String redirectUrl = null;
if (useForward) {//使用轉發的方法,用戶瀏覽器地址url不會發生改變
if (forceHttps && "http".equals(request.getScheme())) {
// First redirect the current request to HTTPS.
// When that request is received, the forward to the login page will be
// used.
redirectUrl = buildHttpsRedirectUrlForRequest(request);
}
if (redirectUrl == null) {
String loginForm = determineUrlToUseForThisRequest(request, response,
authException);
if (logger.isDebugEnabled()) {
logger.debug("Server side forward to: " + loginForm);
}
RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
dispatcher.forward(request, response);
return;
}
}
else {//使用重定向的方式,用戶瀏覽器地址url發生改變
// redirect to login page. Use https if forceHttps true
redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);
}
redirectStrategy.sendRedirect(request, response, redirectUrl);
}
而commence方法的調用是在ExceptionTranslationFilter#handleSpringSecurityException中進行的。spring security中的驗證錯誤會統一放到ExceptionTranslationFilter處理,其handleSpringSecurityException中,會判斷異常的類型,如果是AuthenticationException類型的異常,則會調用sendStartAuthentication方法,進行跳轉。如下
protected void sendStartAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
// SEC-112: Clear the SecurityContextHolder's Authentication, as the
// existing Authentication is no longer considered valid
SecurityContextHolder.getContext().setAuthentication(null);
requestCache.saveRequest(request, response);
logger.debug("Calling Authentication entry point.");
authenticationEntryPoint.commence(request, response, reason);//進行頁面跳轉
}
3 usernameParameter、passwordParameter方法
通過前面的分析,我們知道在UsernamePasswordAuthenticationFilter中,是通過HttpServletRequest的getParamter方法來獲得用戶性和密碼參數值的。默認的參數名是"username"、“password”。要求登錄頁面的form表單中的參數名,與此必須匹配。
我們可以通過調用FormLoginConfigurer的相關方法,重新定義參數名,例如
formLogin()
.usernameParameter("user") //form表單密碼參數名
.passwordParameter("pwd")//form表單用戶名參數名
此時login.html頁面的form表單也要進行相應的修改,如:
<input type="text" name="user" placeholder="請輸入用戶名">
<input type="password" name="pwd" placeholder="請輸入密碼">
4 successForwardUrl、failureForwardUrl、defaultSuccessUrl方法
FormLoginConfigurer的successForwardUrl、failureForwardUrl方法分別用於定義登錄成功和失敗的跳轉地址。
.formLogin()
.successForwardUrl("/success.html")
.failureForwardUrl("/error.html")
這兩個方法內部實現如下:
public FormLoginConfigurer<H> successForwardUrl(String forwardUrl) {
successHandler(new ForwardAuthenticationSuccessHandler(forwardUrl));
return this;
}
public FormLoginConfigurer<H> failureForwardUrl(String forwardUrl) {
failureHandler(new ForwardAuthenticationFailureHandler(forwardUrl));
return this;
}
可以看到,內部利用跳轉路徑url分別構建了ForwardAuthenticationSuccessHandler、ForwardAuthenticationFailureHandler,用於跳轉。
其中successHandler和failureHandler方法都繼承自父類AbstractAuthenticationFilterConfigurer,用於給父類中維護的successHandler、failureHandler字段賦值。
因此FormLoginConfigurer的successForwardUrl、failureForwardUrl方法實際上只是AbstractAuthenticationFilterConfigurer的successHandler、failureHandler方法的一種快捷方式而已,我們可以直接調用successHandler、failureHandler方法,來定義跳轉方式
ForwardAuthenticationSuccessHandler和ForwardAuthenticationFailureHandler的實現類似,以前者爲例,其源碼如下:
public class ForwardAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final String forwardUrl;
public ForwardAuthenticationSuccessHandler(String forwardUrl) {
Assert.isTrue(UrlUtils.isValidRedirectUrl(forwardUrl), "'"
+ forwardUrl + "' is not a valid forward URL");
this.forwardUrl = forwardUrl;
}
//登錄成功時,通過調用此方法進行頁面的跳轉,forward方式
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
request.getRequestDispatcher(forwardUrl).forward(request, response);
}
}
可以看到這個方法裏面就是利用request.getRequestDispatcher來進行轉發。
回顧1 我們分析formLogin方法源碼時,在AbstractAuthenticationProcessingFilter的doFilter方法中,驗證成功或者失敗分別會回調successfulAuthentication、unsuccessfulAuthentication方法。事實上ForwardAuthenticationSuccessHandler的onAuthenticationSuccess方法就是在AbstractAuthenticationProcessingFilter的successfulAuthentication中被回調的。
AbstractAuthenticationProcessingFilter#successfulAuthentication
//用戶驗證成功後,回調此方法
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
if (logger.isDebugEnabled()) {
logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
+ authResult);
}
//1、記錄用戶登錄成功信息,默認放入到Session中
SecurityContextHolder.getContext().setAuthentication(authResult);
//2、如果開啓了自動登錄(用於支持我們經常在各個網站登錄頁面上的"記住我"複選框)
rememberMeServices.loginSuccess(request, response, authResult);
//3、 發佈用戶登錄成功事件,我們可以定義一個bean,實現spring的ApplicationListener接口,則可以獲取到所有的用戶登錄事件
if (this.eventPublisher != null) {
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
authResult, this.getClass()));
}
//4、最後調用ForwardAuthenticationSuccessHandler的onAuthenticationSuccess方法,進行頁面的轉發
successHandler.onAuthenticationSuccess(request, response, authResult);
}
defaultSuccessUrl
如果這樣定義form表單登錄,如果沒有訪問受保護的資源,登錄成功後,默認跳轉到的頁面,默認值爲"/"
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/index.html").permitAll()//訪問index.html不要權限驗證
.anyRequest().authenticated()//其他所有路徑都需要權限校驗
.and()
.csrf().disable()//默認開啓,這裏先顯式關閉
.formLogin() //內部註冊 UsernamePasswordAuthenticationFilter
.loginPage("/login.html") //表單登錄頁面地址
.loginProcessingUrl("/login")//form表單POST請求url提交地址,默認爲/login
.passwordParameter("password")//form表單用戶名參數名
.usernameParameter("username")//form表單密碼參數名
}
我們可以通過配置defaultSuccessUrl來修改默認跳轉到的頁面
.formLogin()
.defaultSuccessUrl("/index.html")//如果沒有訪問受保護的資源,登錄成功後,默認跳轉到的頁面,默認值爲"/"
這裏需要注意的是,在登錄失敗後,瀏覽器的地址會變爲http://localhost:8080/login?error,也就是說,在loginProccessUrl方法指定的url後面加上?error。
由於這裏使用的靜態頁面,所以無法展示錯誤信息。事實上,spring security會將錯誤信息放到Session中,key爲SPRING_SECURITY_LAST_EXCEPTION,如果你使用jsp或者其他方式,則可以從session中把錯誤信息獲取出來,常見的錯誤信息包括:
- 用戶名不存在:UsernameNotFoundException;
- 密碼錯誤:BadCredentialException;
- 帳戶被鎖:LockedException;
- 帳戶未啓動:DisabledException;
- 密碼過期:CredentialExpiredException;等等!
現在我們來分析,spring security是如何做到這種登錄成功/失敗跳轉的挑戰邏輯的:
登錄成功
在AbstractAuthenticationFilterConfigurer中,默認的successHandler是SavedRequestAwareAuthenticationSuccessHandler。當訪問受保護的目標頁面,登錄後直接跳轉到目標頁面,以及直接訪問登錄頁面,登錄後默認跳轉到首頁,就是通過SavedRequestAwareAuthenticationSuccessHandler這個類完成的。
public abstract class AbstractAuthenticationFilterConfigurer...{
....
private AuthenticationSuccessHandler successHandler = new SavedRequestAwareAuthenticationSuccessHandler();
}
由於前面的案例中,我們調用了successForwardUrl方法,因此successHandler的默認值被覆蓋爲ForwardAuthenticationSuccessHandler,因此失去了這個功能。
具體來說,SavedRequestAwareAuthenticationSuccessHandler有一個RequestCache對象,當用戶訪問受保護的頁面時,spring security會將當前請求HttpServletRequest對象信息放到這個RequestCache中。
參見ExceptionTranslationFilter#sendStartAuthentication方法
//需要進行登錄驗證
protected void sendStartAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain,
AuthenticationException reason) throws ServletException, IOException {
SecurityContextHolder.getContext().setAuthentication(null);
//緩存當前request對象,其中包含了用戶想訪問的目標頁面登錄信息
requestCache.saveRequest(request, response);
logger.debug("Calling Authentication entry point.");
//跳轉到登錄頁面,這個之前已經分析過,不再贅述
authenticationEntryPoint.commence(request, response, reason);
}
當用戶登錄成功後,AbstractAuthenticationProcessingFilter#successfulAuthentication方法會被回調,這個方法源碼之前也已經分析過,最後一步是調用
successHandler.onAuthenticationSuccess(request, response, authResult);
此時就是調用SavedRequestAwareAuthenticationSuccessHandler#onAuthenticationSuccess
public class SavedRequestAwareAuthenticationSuccessHandler extends
SimpleUrlAuthenticationSuccessHandler {
protected final Log logger = LogFactory.getLog(this.getClass());
private RequestCache requestCache = new HttpSessionRequestCache();//這是一個session級別的緩存
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication authentication)
throws ServletException, IOException {
//1、從requestCache中獲得之前保存的HttpServletRequest對象,SavedRequest是對HttpServletRequest的封裝
SavedRequest savedRequest = requestCache.getRequest(request, response);
//2、如果用戶直接訪問的登錄頁面,則savedRequest爲空,跳轉到默認頁面
if (savedRequest == null) {
super.onAuthenticationSuccess(request, response, authentication);
return;
}
/*3 如果設置爲targetUrlParameter參數,會從當前請求request對象,查看請求url是否包含要跳轉到的路徑參數,如果有則跳轉到這個url,
這個邏輯是在父類SimpleUrlAuthenticationSuccessHandler中進行的*/
String targetUrlParameter = getTargetUrlParameter();
if (isAlwaysUseDefaultTargetUrl()
|| (targetUrlParameter != null && StringUtils.hasText(request
.getParameter(targetUrlParameter)))) {
//從requestCache中移除之前保存的request,以免緩存過多,內存溢出。
//注意保存的是前一個request,移除的卻是當前request,因爲二者引用的是同一個session,內部只要找到這個session,移除對應的緩存key即可
requestCache.removeRequest(request, response);
super.onAuthenticationSuccess(request, response, authentication);
return;
}
//4、移除在session中緩存的之前的登錄錯誤信息,key爲:SPRING_SECURITY_LAST_EXCEPTION
clearAuthenticationAttributes(request);
//5、跳轉到之前保存的request對象中訪問的url
String targetUrl = savedRequest.getRedirectUrl();
logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
public void setRequestCache(RequestCache requestCache) {
this.requestCache = requestCache;
}
}
登錄失敗
在AbstractAuthenticationFilterConfigurer中,failureHandler字段值默認爲空,在初始化時,updateAuthenticationDefaults方法會被調用:
AbstractAuthenticationFilterConfigurer#updateAuthenticationDefaults
....
private AuthenticationFailureHandler failureHandler;
....
private void updateAuthenticationDefaults() {
....
if (failureHandler == null) {
failureUrl(loginPage + "?error");
}
....
}
可以看到,如果發現failureHandler爲空,則會調用failureUrl方法創建一個AuthenticationFailureHandler實例,傳入的參數是是我們設置的loginPage+"?ERROR",這也是我們在前面的gif動態圖中,看到登錄失敗之後,登錄頁面變爲http://localhost:8080/login?error的原因。
failUrl方法如下所示:
public final T failureUrl(String authenticationFailureUrl) {
T result = failureHandler(new SimpleUrlAuthenticationFailureHandler(
authenticationFailureUrl));
this.failureUrl = authenticationFailureUrl;
return result;
}
可以看到這裏創建的AuthenticationFailureHandler實現類爲SimpleUrlAuthenticationFailureHandler。