自定義表單登錄
FormLoginConfigurer 提供了 loginPage 和 loginProcessingUrl 方法分別用于配置登錄頁面和表單提交請求處理路徑。繼承 WebSecurityConfigurerAdapter,重載關(guān)于過濾器和忽略請求的配置方法:
public class CustomSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
// 表單登錄,配置表單頁面和表單提交URL
http.formLogin().loginPage("/login.html").loginProcessingUrl("/login").and()
// 任何請求都需要認(rèn)證
.authorizeRequests().anyRequest().authenticated();
}
@Override
public void configure(WebSecurity web) throws Exception {
// 登錄頁面請求不走 Spring Security 過濾器鏈
web.ignoring().mvcMatchers("/login.html");
}
}
loginPage 自定義登錄頁面
protected T loginPage(String loginPage) {
// 配置自定義登錄
setLoginPage(loginPage);
// 更新登錄錯(cuò)誤 / 登出路徑(基于 loginPage)
updateAuthenticationDefaults();
// 更新自定義標(biāo)識
this.customLoginPage = true;
return getSelf();
}
在配置自定義登錄頁面后,同時(shí)會更新認(rèn)證錯(cuò)誤認(rèn)證策略:
private void setLoginPage(String loginPage) {
this.loginPage = loginPage;
// 更新認(rèn)證異常時(shí)跳轉(zhuǎn)頁面
this.authenticationEntryPoint = new LoginUrlAuthenticationEntryPoint(loginPage);
}
上一節(jié)中說到,當(dāng) ExceptionTranslationFilter 攔截到認(rèn)證異常后,會調(diào)用 LoginUrlAuthenticationEntryPoint#commence 方法進(jìn)行處理,其主要邏輯為將用戶請求重定向到登錄頁面。
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
// ...
// 獲取配置的 loginPage
String loginForm = determineUrlToUseForThisRequest(request, response, authException);
// 請求重定向
RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);
dispatcher.forward(request, response);
// ...
}
loginPage 方法更新 customLoginPage 標(biāo)識后,DefaultLoginPageGeneratingFilter 的過濾邏輯也隨之改變,其默認(rèn)配置的需要攔截的相關(guān)路徑為 /login,當(dāng)開發(fā)者自定義登錄頁面路徑后,經(jīng)由此過濾器就不會再被攔截。
loginPage 定義表單提交路徑
loginPage 方法中調(diào)用了 updateAuthenticationDefaults 方法,可見當(dāng)不手動配置 loginProcessingUrl 時(shí),會使用 loginPage 作為表單提交路徑。
protected final void updateAuthenticationDefaults() {
if (loginProcessingUrl == null) {
// 默認(rèn)實(shí)用 loginPage 作為表單提交路徑
loginProcessingUrl(loginPage);
}
// ...
}
但是 FormLoginConfigurer 配置的 UsernamePasswordAuthenticationFilter 則在默認(rèn)構(gòu)造方法中指定表單提交路徑為 /login,也就是說如果開發(fā)者配置 loginPage 為其它路徑,就無法正常進(jìn)行認(rèn)證。
public UsernamePasswordAuthenticationFilter() {
super(new AntPathRequestMatcher("/login", "POST"));
}
/**
* 路徑匹配才進(jìn)行認(rèn)證
*/
protected boolean requiresAuthentication(HttpServletRequest request,
HttpServletResponse response) {
return requiresAuthenticationRequestMatcher.matches(request);
}
因此為了 UsernamePasswordAuthenticationFilter 能夠正常執(zhí)行,開發(fā)者需要手動指定 loginProcessingUrl。
public T loginProcessingUrl(String loginProcessingUrl) {
this.loginProcessingUrl = loginProcessingUrl;
// 設(shè)置過濾器處理登錄邏輯的請求URL(可以指定其它名稱覆蓋 /login)
authFilter.setRequiresAuthenticationRequestMatcher(createLoginProcessingUrlMatcher(loginProcessingUrl));
return getSelf();
}
忽略對自定義登錄頁面的攔截
上文示例中配置的是對所有請求進(jìn)行攔截,當(dāng)過濾器鏈發(fā)現(xiàn)沒有認(rèn)證就會跳轉(zhuǎn)到登錄頁面,但是訪問登錄頁面也需要認(rèn)證,這就會造成一直重定向,無法完成登錄。因此需要配置登錄頁面請求不走 Spring Security 過濾器鏈,這樣所有人就可以正常訪問登錄頁面。
登錄后處理
登錄成功后是跳轉(zhuǎn)到首頁還是用戶原先想訪問的頁面?失敗后是仍跳轉(zhuǎn)到登錄頁面還是自定義的錯(cuò)誤頁面?Spring Security 提供了若干方法用于開發(fā)者自定義登錄后處理:
@Resource
private AuthenticationSuccessHandler successHandler;
@Resource
private AuthenticationFailureHandler failureHandler;
protected void configure(HttpSecurity http) throws Exception {
// 表單登錄
http.formLogin()
// 認(rèn)證成功后重定向URL
.successForwardUrl("/")
// 認(rèn)證失敗后重定向URL
.failureForwardUrl("/login.html?error")
// 如果是從其它頁面重定向到登錄頁面,則成功后跳轉(zhuǎn)到原請求URL,否則跳轉(zhuǎn)到指定URL
.defaultSuccessUrl("/", false)
// 認(rèn)證失敗后重定向URL
.failureUrl("/login.html?error")
// 自定義認(rèn)證成功后處理器
.successHandler(successHandler)
// 自定義認(rèn)證失敗后處理器
.failureHandler(failureHandler)
.and()
// 任何請求都需要認(rèn)證
.authorizeRequests().anyRequest().authenticated();
}
同樣是重載 void configure(HttpSecurity http) 方法,干預(yù)認(rèn)證過濾器的生成邏輯??梢钥吹?HttpSecurity 提供了 4 個(gè)修改重定向地址的方法,而實(shí)際上他們最后都是對 successHandler 和 failureHandler 進(jìn)行配置。在 UsernamePasswordAuthenticationFilter 的認(rèn)證邏輯中,當(dāng)認(rèn)證成功后會調(diào)用 successfulAuthentication 方法,而在此方法中又調(diào)用了 AuthenticationSuccessHandler#onAuthenticationSuccess 方法,如下是認(rèn)證成功后處理器的一類實(shí)現(xiàn):
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
request.getRequestDispatcher(forwardUrl).forward(request, response);
}
但是在前后端分離的項(xiàng)目中,認(rèn)證系統(tǒng)可能作為一個(gè)單獨(dú)的后端模塊單獨(dú)拆出來,配置登錄跳轉(zhuǎn)就無法滿足與前端交互的任務(wù),因此開發(fā)者需要繼承 AuthenticationSuccessHandler 和
AuthenticationFailureHandler,自定義認(rèn)證后響應(yīng),以下為向前端返回認(rèn)證失敗的 Json 數(shù)據(jù)的一類實(shí)現(xiàn):
@Component
public class JsonAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
// 狀態(tài)碼 401
response.setStatus(HttpStatus.UNAUTHORIZED.value());
// 設(shè)置返回類型為 json
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(JSONUtils.toJSONString(exception));
}
}
自定義過濾器
HttpSecurity 提供了若干方法為 web 請求添加過濾器,例如默認(rèn)表單、認(rèn)證過期、CSRF ` 保護(hù)等。同時(shí)開發(fā)者可以定義自已的過濾器,并指定在整個(gè)過濾器中的位置。如果需要在表單中添加驗(yàn)證碼校驗(yàn)邏輯,可以使用如下示例:
/**
* 認(rèn)證失敗后處理器
*/
@Resource
private AuthenticationFailureHandler failureHandler;
@Override
protected void configure(HttpSecurity http) throws Exception {
// 在認(rèn)證登錄前驗(yàn)證驗(yàn)證碼
http.addFilterBefore(new CaptchaFilter(failureHandler),
UsernamePasswordAuthenticationFilter.class);
}
在上文中我們說到,登錄表單提交請求無論是成功還是失敗,默認(rèn)都會交由認(rèn)證處理器進(jìn)行跳轉(zhuǎn)。因此在整個(gè)過濾器鏈中,關(guān)于驗(yàn)證碼的過濾邏輯需要排在 UsernamePasswordAuthenticationFilter 之前。
public HttpSecurity addFilterBefore(Filter filter, Class<? extends Filter> beforeFilter) {
// 過濾器排序器注冊
comparator.registerBefore(filter.getClass(), beforeFilter);
return addFilter(filter);
}
所有 Spring Security 配置的過濾器,其順序由 FilterComparator 管理:
final class FilterComparator implements Comparator<Filter>, Serializable {
/**
* 初始化順序
*/
private static final int INITIAL_ORDER = 100;
/**
* order 步長
*/
private static final int ORDER_STEP = 100;
/**
* 過濾器名稱 - 在過濾器中的順序(order 越小,排序越靠前)
*/
private final Map<String, Integer> filterToOrder = new HashMap<>();
FilterComparator() {
Step order = new Step(INITIAL_ORDER, ORDER_STEP);
// ...
put(SecurityContextPersistenceFilter.class, order.next());
// ...
put(UsernamePasswordAuthenticationFilter.class, order.next());
// ...
put(FilterSecurityInterceptor.class, order.next());
// ...
}
public void registerBefore(Class<? extends Filter> filter, Class<? extends Filter> beforeFilter) {
Integer position = getOrder(beforeFilter);
// ...
// 注冊自定義過濾器
put(filter, position - 1);
}
}
可以看到,FilterComparator 對 Spring Security 配置的默認(rèn)過濾器維護(hù)了一個(gè) filterToOrder,用于描述各個(gè)過濾器在過濾器鏈中的順序,前后兩個(gè)過濾器的順序相差 100。registerBefore 方法將開發(fā)者自定義的過濾器注冊到 FilterComparator 方法中,并指定其順序與 UsernamePasswordAuthenticationFilter 相差 1。這樣在過濾器中就能保證自定義的驗(yàn)證碼過濾器 CaptchaFilter 能夠在認(rèn)證過濾器前一位執(zhí)行。
記住我
在過濾器配置中,可以通過 remember 方法配置 記住我 功能:
@Override
protected void configure(HttpSecurity http) throws Exception {
// 在認(rèn)證登錄前驗(yàn)證驗(yàn)證碼
http.rememberMe().and()
// 任何請求都需要認(rèn)證
.authorizeRequests().anyRequest().authenticated();
}
rememberMe 方法通過 RememberMeConfigurer 配置 RememberMeAuthenticationFilter,在 init 方法中先生成了一個(gè) RememberMeServices:
private RememberMeServices getRememberMeServices(H http, String key) throws Exception {
// ...
//
AbstractRememberMeServices tokenRememberMeServices = createRememberMeServices(
http, key);
// 表單登錄參數(shù)名默認(rèn)為 remember-me
tokenRememberMeServices.setParameter(this.rememberMeParameter);
// Cookie 名稱默認(rèn)為 remember-me
tokenRememberMeServices.setCookieName(this.rememberMeCookieName);
// ...
// 配置記住我過期時(shí)間,默認(rèn)為兩周
if (this.tokenValiditySeconds != null) {
tokenRememberMeServices.setTokenValiditySeconds(this.tokenValiditySeconds);
}
// ...
// 如果用戶主動登出了,需要清除記住我功能做的相關(guān)配置
this.logoutHandler = tokenRememberMeServices;
this.rememberMeServices = tokenRememberMeServices;
return tokenRememberMeServices;
}
對于創(chuàng)建基礎(chǔ)的 AbstractRememberMeServices,Spring Security 提供了兩種方式,一種是 TokenBasedRememberMeServices:是否能夠使用 記住我 功能、校驗(yàn)都只依賴請求中的 Cookie;另一種是配置 PersistentTokenRepository:InMemoryTokenRepositoryImpl 在內(nèi)存中維護(hù)一個(gè) Map 用于對 記住我 進(jìn)行校驗(yàn),JdbcTokenRepositoryImpl 會從數(shù)據(jù)庫中查詢相關(guān)的 token 進(jìn)行校驗(yàn)。這兩種方式都是依賴請求中攜帶的 Cookie。當(dāng)用戶提交登出請求,應(yīng)該取消 記住我 功能,AbstractRememberMeServices 對此的實(shí)現(xiàn)為清除名為 remember-me 的 Cookie:
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response) {
// ...
Cookie cookie = new Cookie(cookieName, null);
cookie.setMaxAge(0);
cookie.setPath(getCookiePath(request));
// ...
response.addCookie(cookie);
}
RememberMeServices 配置完成后,RememberMeAuthenticationFilter 被加到過濾器鏈中,其過濾邏輯如下:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
// ...
if (SecurityContextHolder.getContext().getAuthentication() == null) {
// 沒有經(jīng)過認(rèn)證,SecurityContext 為空,嘗試使用 記住我 功能
// 檢驗(yàn) Cookie,如果有效進(jìn)行自動登錄
Authentication rememberMeAuth = rememberMeServices.autoLogin(request, response);
if (rememberMeAuth != null) {
// remeber-me Cookie 有效,走用戶認(rèn)證邏輯
try {
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
// ...
} catch (AuthenticationException authenticationException) {
// 認(rèn)證失敗了,也會清除 remeber-me Cookie
rememberMeServices.loginFail(request, response);
// ...
}
}
chain.doFilter(request, response);
} else {
// ...
chain.doFilter(request, response);
}
}
如果 SecurityContext 沒有認(rèn)證信息,過濾器會嘗試使用 記住我 功能,調(diào)用 autoLogin 方法:
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
// 從請求中獲取名為 remember-me 的 Cookie
String rememberMeCookie = extractRememberMeCookie(request);
// ...
UserDetails user = null;
try {
// 原 Cookie 解碼
String[] cookieTokens = decodeCookie(rememberMeCookie);
user = processAutoLoginCookie(cookieTokens, request, response);
// 校驗(yàn)用戶賬號是否有效
userDetailsChecker.check(user);
// 創(chuàng)建認(rèn)證信息
return createSuccessfulAuthentication(request, user);
} catch (CookieTheftException cte) {
// 清理客戶端的 remember-me Cookie
cancelCookie(request, response);
throw cte;
}
// ...
cancelCookie(request, response);
return null;
}
TokenBasedRememberMeServices 中對于 processAutoLoginCookie 方法的實(shí)現(xiàn)為:
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
// ...
long tokenExpiryTime;
try {
tokenExpiryTime = new Long(cookieTokens[1]).longValue();
} catch (NumberFormatException nfe) {
throw new InvalidCookieException(
"Cookie token[1] did not contain a valid number (contained '"
+ cookieTokens[1] + "')");
}
// ...
// 根據(jù)用戶名加載用戶信息
UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
// 根據(jù)過期時(shí)間、用戶名、密碼生成 MD5 簽名
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime,
userDetails.getUsername(), userDetails.getPassword());
// 校驗(yàn)簽名
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '"
+ cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
}
return userDetails;
}
對 Cookie 進(jìn)行解碼后,根據(jù)登錄用戶的相關(guān)信息做 MD5 校驗(yàn),如果認(rèn)定 Cookie 中的 Token 有效,則會查詢用戶信息,生成認(rèn)證身份,通過認(rèn)證流程。那么,記住我 的 Cookie 是在何時(shí)放入的呢?在 RememberMeConfigurer 的初始化過程中,默認(rèn)的 remember-me 表單名被傳遞給表單生成過濾器:
private void initDefaultLoginFilter(H http) {
DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
.getSharedObject(DefaultLoginPageGeneratingFilter.class);
if (loginPageGeneratingFilter != null) {
// 將表單名 remember-me 傳遞給默認(rèn)表單生成過濾器,生成 記住我 復(fù)選框
loginPageGeneratingFilter.setRememberMeParameter(getRememberMeParameter());
}
}
表單中會根據(jù)傳入的名稱生成如下 HTML 代碼:
<p>
<input type="checkbox" name="remember-me" /> Remember me on this computer.
</p>
UsernamePasswordAuthenticationFilter 在用戶認(rèn)證成功后會調(diào)用 successfulAuthentication 方法,在此方法中會取出 remember-me 參數(shù),判斷是否使用 記住我 功能。而后又調(diào)用了 RememberMeServices 的 loginSuccess 方法:
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
// 請求參數(shù)中是否要求使用記住我功能
if (!rememberMeRequested(request, parameter)) {
logger.debug("Remember-me login not requested.");
return;
}
// 要求使用記住我功能,響應(yīng)中構(gòu)造 Cookie
onLoginSuccess(request, response, successfulAuthentication);
}
TokenBasedRememberMeServices 中對于 onLoginSuccess 方法的實(shí)現(xiàn)為:
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
// ...
// Cookie 失效時(shí)間
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);
// 構(gòu)造 MD5 簽名,與校驗(yàn) Cookie 的邏輯一致
String signatureValue = makeTokenSignature(expiryTime, username, password);
// 在響應(yīng)中添加 Cookie
setCookie(new String[] { username, Long.toString(expiryTime), signatureValue },
tokenLifetime, request, response);
// ...
}
如果配置了 PersistentTokenRepository 其流程大致相同,區(qū)別在于 Cookie 的生成和校驗(yàn)邏輯不同,在此不多做贅述。
小結(jié)
通過配置
FormLoginConfigurer的loginPage和loginProcessingUrl可以切換默認(rèn)登錄頁面和表單請求地址。通過配置
FormLoginConfigurer的successHandler和failureHandler可以干預(yù)認(rèn)證成功 / 失敗后的服務(wù)端控制行為。Spring Security過濾器鏈中的過濾器執(zhí)行順序由FilterComparator管理。如果需要在過濾器鏈中增加自定義過濾器,可以通過HttpSecurity的addFilterBefore或者addFilterAfter方法將自定義過濾器加在指定過濾器前 / 后。配置
RememberMe后,認(rèn)證成功和失敗,響應(yīng)都會返回Cookie給客戶端,下一次未經(jīng)認(rèn)證即訪問web資源時(shí),會對請求攜帶的Cookie進(jìn)行校驗(yàn),如果有效則會自動登錄,執(zhí)行認(rèn)證邏輯。表單登錄的相關(guān)擴(kuò)展仍然離不開過濾器的支持,以下是幾個(gè)較為重要的
Spring Security過濾器:
