Spring Security 解析(三) —— 個性化認證 以及 RememberMe 實現 在學習Spring Cloud 時,遇到了授權服務oauth 相關內容時,總是一知半解,因此決定先把Spring Security 、Spring Security Oauth2 ...
Spring Security 解析(三) —— 個性化認證 以及 RememberMe 實現
在學習Spring Cloud 時,遇到了授權服務oauth 相關內容時,總是一知半解,因此決定先把Spring Security 、Spring Security Oauth2 等許可權、認證相關的內容、原理及設計學習並整理一遍。本系列文章就是在學習的過程中加強印象和理解所撰寫的,如有侵權請告知。
項目環境:
- JDK1.8
- Spring boot 2.x
- Spring Security 5.x
一、個性化認證
(一) 配置登錄
在 授權過程 和 認證過程 中我們都是使用的 Security 預設的一個登錄頁面(/login),那麼如果我們想自定義一個登錄頁面該如何實現呢?其實很簡單,我們新建 FormAuthenticationConfig 配置類,然後在configure(HttpSecurity http) 方法中實現以下設置:
http.formLogin()
//可以設置自定義的登錄頁面 或者 (登錄)介面
// 註意1: 一般來說設置成(登錄)介面後,該介面會配置成無許可權即可訪問,所以會走匿名filter, 也就意味著不會走認證過程了,所以我們一般不直接設置成介面地址
// 註意2: 這裡配置的 地址一定要配置成無許可權訪問,否則將出現 一直重定向問題(因為無許可權後又會重定向到這裡配置的登錄頁url)
.loginPage(securityProperties.getLogin().getLoginPage())
//.loginPage("/loginRequire")
// 指定驗證憑據的URL(預設為 /login) ,
// 註意1:這裡修改後的 url 會意味著 UsernamePasswordAuthenticationFilter 將 驗證此處的 url
// 註意2: 與 loginPage設置的介面地址是有 區別, 一但 loginPage 設置了的是訪問介面url,那麼此處配置將無任何意義
// 註意3: 這裡設置的 Url 是有預設無許可權訪問的
.loginProcessingUrl(securityProperties.getLogin().getLoginUrl())
//分別設置成功和失敗的處理器
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler);
最後在 SpringSecurityConfig 的 configure(HttpSecurity http) 方法中 調用 formAuthenticationConfig.configure(http) 即可;
正如看到的一樣,我們通過 loginPage()設置 登錄頁面 或 介面, 通過 loginProcessingUrl() 設置 UsernamePasswordAuthenticationFilter 要匹配的 介面地址(一定是Post)(看過授權過程的同學應該都知道其預設的是/login)。 這裡有以下幾點值得註意:
- loginPage() 這裡配置的 地址(不管是介面url還是登錄頁面)一定要配置成無許可權訪問,否則將出現 一直重定向問題(因為無許可權後又會重定向到這裡配置的登錄頁url
- loginPage() 一般來說不直接設置成(登錄)介面,因為設置了介面會配置成無許可權即可訪問(當然設置成登錄頁面也需要配置無許可權訪問),所以會走匿名filter, 也就意味著不會走認證過程了,所以我們一般不直接設置成介面地址
- loginProcessingUrl() 這裡修改後的 url 會意味著 UsernamePasswordAuthenticationFilter 將 驗證此處的 url
- loginProcessingUrl() 這裡設置的 Url 是有預設無許可權訪問的,與 loginPage設置的介面地址是有 區別, 一但 loginPage 設置了的是介面url,那麼此處配置將無任何意義
- successHandler() 和 failureHandler 分別 設置認證成功處理器 和 認證失敗處理器 (如果對這2個處理器沒印象的話,建議回顧下授權過程)
(二) 配置成功和失敗處理器
在授權過程中,我們增簡單提及到過這2個處理器,在Security中預設的處理器分別是SavedRequestAwareAuthenticationSuccessHandler 和 SimpleUrlAuthenticationFailureHandler ,這次我們自定義這2個處理器,分別為 CustomAuthenticationSuccessHandler ( extends SavedRequestAwareAuthenticationSuccessHandler ) 重寫 onAuthenticationSuccess() 方法 :
@Component("customAuthenticationSuccessHandler")
@Slf4j
public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
@Autowired
private SecurityProperties securityProperties;
private RequestCache requestCache = new HttpSessionRequestCache();
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
logger.info("登錄成功");
// 如果設置了loginSuccessUrl,總是跳到設置的地址上
// 如果沒設置,則嘗試跳轉到登錄之前訪問的地址上,如果登錄前訪問地址為空,則跳到網站根路徑上
if (!StringUtils.isEmpty(securityProperties.getLogin().getLoginSuccessUrl())) {
requestCache.removeRequest(request, response);
setAlwaysUseDefaultTargetUrl(true);
setDefaultTargetUrl(securityProperties.getLogin().getLoginSuccessUrl());
}
super.onAuthenticationSuccess(request, response, authentication);
}
}
和 CustomAuthenticationFailureHandler( extends SimpleUrlAuthenticationFailureHandler) 重寫 onAuthenticationFailure() 方法 :
@Component("customAuthenticationFailureHandler")
@Slf4j
public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private SecurityProperties securityProperties;
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
logger.info("登錄失敗");
if (StringUtils.isEmpty(securityProperties.getLogin().getLoginErrorUrl())){
response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(exception.getMessage()));
} else {
// 跳轉設置的登陸失敗頁面
redirectStrategy.sendRedirect(request,response,securityProperties.getLogin().getLoginErrorUrl());
}
}
}
(三) 自定義的登陸頁面
這裡就不再描述,直接貼代碼:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>登錄</title>
</head>
<body>
<h2>登錄頁面</h2>
<form action="/loginUp" method="post">
<table>
<tr>
<td>用戶名:</td>
<td><input type="text" name="username"></td>
</tr>
<tr>
<td>密碼:</td>
<td><input type="password" name="password"></td>
</tr>
<tr>
<td colspan='2'><input name="remember-me" type="checkbox" value="true"/>記住我</td>
</tr>
<tr>
<td colspan="2">
<button type="submit">登錄</button>
</td>
</tr>
</table>
</form>
</body>
</html>
註意這裡請求的地址是 loginProcessingUrl() 配置的地址
(四)測試驗證
這裡就不在貼結果圖了,只要我們明白結果流程就行是這樣的就可以:
localhost:8080 ——> 點擊 測試驗證Security 許可權控制 ————> 跳轉到 我們自定義的 /loginUp.html 登錄頁,登錄後 ————> 有配置loginSuccessUrl,則跳轉到 loginSuccess.html;反之則直接跳轉到 /get_user/test 介面返回結果。 整個流程就全面涉及到了我們自定義的登錄頁面、自定義的登錄成功/失敗處理器。
二、 RememberMe (記住我)功能解析
(一)RememberMe 功能實現配置
首先我們一股腦的將rememberMe配置加上,然後看下現象:
1、 創建 persistent_logins 表,用於存儲token和用戶的關聯信息:
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null);
2 、 添加rememberMe配置 信息
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
// 如果token表不存在,使用下麵語句可以初始化 persistent_logins(ddl在db目錄下) 表;若存在,請註釋掉這條語句,否則會報錯。
//tokenRepository.setCreateTableOnStartup(true);
return tokenRepository;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
formAuthenticationConfig.configure(http);
http. ....
.and()
// 開啟 記住我功能,意味著 RememberMeAuthenticationFilter 將會 從Cookie 中獲取token信息
.rememberMe()
// 設置 tokenRepository ,這裡預設使用 jdbcTokenRepositoryImpl,意味著我們將從資料庫中讀取token所代表的用戶信息
.tokenRepository(persistentTokenRepository())
// 設置 userDetailsService , 和 認證過程的一樣,RememberMe 有專門的 RememberMeAuthenticationProvider ,也就意味著需要 使用UserDetailsService 載入 UserDetails 信息
.userDetailsService(userDetailsService)
// 設置 rememberMe 的有效時間,這裡通過 配置來設置
.tokenValiditySeconds(securityProperties.getLogin().getRememberMeSeconds())
.and()
.csrf().disable(); // 關閉csrf 跨站(域)攻擊防控
}
這裡解釋下配置:
- rememberMe() 開啟 記住我功能,意味著 RememberMeAuthenticationFilter 將會 從Cookie 中獲取token信息
- tokenRepository() 配置 token的獲取策略,這裡配置成從資料庫中讀取
- userDetailsService() 配置 UserDetaisService (如果不熟悉該對象,建議回顧認證過程)
- tokenValiditySeconds() 設置 rememberMe 的有效時間,這裡通過 配置來設置
另一個重要的配置在登錄頁面,這裡的 必須是 name="remember-me" ,rememberMe就是通過驗證這個配置來開啟remermberMe功能的。
<input name="remember-me" type="checkbox" value="true"/>記住我</td>
實操結果應該為:進入登陸頁面 ——> 勾選記住我後登錄 ——> 成功後,查看persistent_logins 表發現有一條數據——> 重啟項目 ——> 重新訪問需要登錄才能訪問的頁面,發現無需登錄即可訪問——> 刪除 persistent_logins 表數據,等待token設置的有效時間過期,然後重新刷新頁面發現跳轉到登陸頁面。
(二) RembemberMe 實現源碼解析
首先我們查看UsernamePasswordAuthenticationFiler(AbstractAuthenticationProcessingFilter) 的 successfulAuthentication() 方法內部源碼:
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
// 1 設置 認證成功的Authentication對象到SecurityContext中
SecurityContextHolder.getContext().setAuthentication(authResult);
// 2 調用 RememberMe 相關service處理
rememberMeServices.loginSuccess(request, response, authResult);
// Fire event
if (this.eventPublisher != null) {
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
authResult, this.getClass()));
}
//3 調用成功處理器
successHandler.onAuthenticationSuccess(request, response, authResult);
}
其中我們發現我們本次重點關註的一行代碼: rememberMeServices.loginSuccess(request, response, authResult) , 查看這個方法內部源碼:
@Override
public final void loginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
// 這裡就在判斷用戶是否勾選了記住我
if (!rememberMeRequested(request, parameter)) {
logger.debug("Remember-me login not requested.");
return;
}
onLoginSuccess(request, response, successfulAuthentication);
}
通過 rememberMeRequested() 判斷是否勾選了記住我。
onLoginSuccess() 方法 最終會調用到 PersistentTokenBasedRememberMeServices 的 onLoginSuccess() 方法,貼出其方法源碼如下:
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
// 1 獲取賬戶名
String username = successfulAuthentication.getName();
// 2 創建 PersistentRememberMeToken 對象
PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(
username, generateSeriesData(), generateTokenData(), new Date());
try {
// 3 通過 tokenRepository 存儲 persistentRememberMeToken 信息
tokenRepository.createNewToken(persistentToken);
// 4 將 persistentRememberMeToken 信息添加到Cookie中
addCookie(persistentToken, request, response);
}
catch (Exception e) {
logger.error("Failed to save persistent token ", e);
}
}
分析下源碼步驟:
- 獲取 賬戶信息 username
- 傳入 username 創建 PersistentRememberMeToken 對象
- 通過 tokenRepository 存儲 persistentRememberMeToken信息
- 將 persistentRememberMeToken 信息添加到Cookie中
這裡的 tokenRepository 就是我們配置 rememberMe功能所設置的。經過上面的解析我們看到了rememberServices 將 創建一個 token 信息,並存儲到資料庫(因為我們配置的是資料庫存儲方式 JdbcTokenRepositoryImpl )中,並將token信息添加到Cookie中了。到這裡,我們看到了RememberMe實現前的一些業務處理,那麼後面如何實現RememberMe,我想大家心裡大概都有個底了。這裡直接拋出之前授權過程中我們沒有提及到的 filter 類 RememberMeAuthenticationFilter,它是介於 UsernamePasswordAuthenticationFilter 和 AnonymousAuthenticationFilter 之間的一個filter,它主要負責的就是前面的filter都沒有認證成功後從Cookie中獲取token信息然後再通過tokenRepository 獲取 登錄用戶名,然後UserDetailsServcie 載入 UserDetails 信息 ,最後創建 Authticaton(RememberMeAuthenticationToken) 信息再調用 AuthenticationManager.authenticate() 進行認證過程。
RememberMeAuthenticationFilter
我們來看下 RememberMeAuthenticationFilter 的dofiler方法源碼:
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (SecurityContextHolder.getContext().getAuthentication() == null) {
// 1 調用 rememberMeServices.autoLogin() 獲取Authtication 信息
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
if (rememberMeAuth != null) {
// Attempt authenticaton via AuthenticationManager
try {
// 2 調用 authenticationManager.authenticate() 認證
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
......
}
}
catch (AuthenticationException authenticationException) {
.....
}
chain.doFilter(request, response);
}
我們主要關註 rememberMeServices.autoLogin(request,response) 方法實現,查看器源碼:
@Override
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
// 1 從Cookie 中獲取 token 信息
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
if (rememberMeCookie.length() == 0) {
cancelCookie(request, response);
return null;
}
UserDetails user = null;
try {
// 2 解析 token信息
String[] cookieTokens = decodeCookie(rememberMeCookie);
// 3 通過 token 信息 生成 Uerdetails 信息
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);
logger.debug("Remember-me cookie accepted");
// 4 通過 UserDetails 信息創建 Authentication
return createSuccessfulAuthentication(request, user);
}
.....
}
內部實現步驟:
- 從Cookie中獲取 token 信息並解析
- 通過 解析的token 生成 UserDetails (processAutoLoginCookie() 方法實現 )
- 通過 UserDetails 生成 Authentication ( createSuccessfulAuthentication() 創建 RememberMeAuthenticationToken )
其中最關鍵的一部是 processAutoLoginCookie() 方法是如何生成UserDetails 對象的,我們查看這個方法源碼實現:
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request, HttpServletResponse response) {
final String presentedSeries = cookieTokens[0];
final String presentedToken = cookieTokens[1];
// 1 通過 tokenRepository 載入資料庫token信息
PersistentRememberMeToken token = tokenRepository
.getTokenForSeries(presentedSeries);
PersistentRememberMeToken newToken = new PersistentRememberMeToken(
token.getUsername(), token.getSeries(), generateTokenData(), new Date());
// 2 判斷 用戶傳入token和數據中的token是否一致,不一致可能存在安全問題
if (!presentedToken.equals(token.getTokenValue())) {
tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(
messages.getMessage(
"PersistentTokenBasedRememberMeServices.cookieStolen",
"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
}
try {
// 3 更新 token 並添加到Cookie中
tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
newToken.getDate());
addCookie(newToken, request, response);
}
catch (Exception e) {
throw new RememberMeAuthenticationException(
"Autologin failed due to data access problem");
}
// 4 通過 UserDetailsService().loadUserByUsername() 方法載入UserDetails 信息並返回
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
我們看下其內部步驟:
- 通過 tokenRepository 載入資料庫token信息
- 判斷 用戶傳入token和數據中的token是否一致,不一致可能存在安全問題
- 更新 token 並添加到Cookie中
- 通過 UserDetailsService().loadUserByUsername() 方法載入UserDetails 信息並返回
看到這裡相信大家以下就明白了,當初為啥在啟用rememberMe功能時要配置 tokenRepository 和 UserDetailsService了。
這裡我就不再演示整個實現的流程了,老規矩,上流程圖:
本文介紹個性化認證和RememberMe的代碼可以訪問代碼倉庫中的 security 模塊 ,項目的github 地址 : https://github.com/BUG9/spring-security
如果您對這些感興趣,歡迎star、follow、收藏、轉發給予支持!