Spring Security 解析(三) —— 個性化認證 以及 RememberMe 實現(xiàn)
??在學習Spring Cloud 時棕兼,遇到了授權(quán)服務oauth 相關內(nèi)容時,總是一知半解,因此決定先把Spring Security 球匕、Spring Security Oauth2 等權(quán)限、認證相關的內(nèi)容椿争、原理及設計學習并整理一遍逾柿。本系列文章就是在學習的過程中加強印象和理解所撰寫的缀棍,如有侵權(quán)請告知宅此。
項目環(huán)境:
- JDK1.8
- Spring boot 2.x
- Spring Security 5.x
一、個性化認證
(一) 配置登錄
?? 在 授權(quán)過程 和 認證過程 中我們都是使用的 Security 默認的一個登錄頁面(/login)爬范,那么如果我們想自定義一個登錄頁面該如何實現(xiàn)呢父腕?其實很簡單,我們新建 FormAuthenticationConfig 配置類青瀑,然后在configure(HttpSecurity http) 方法中實現(xiàn)以下設置:
http.formLogin()
//可以設置自定義的登錄頁面 或者 (登錄)接口
// 注意1: 一般來說設置成(登錄)接口后璧亮,該接口會配置成無權(quán)限即可訪問,所以會走匿名filter, 也就意味著不會走認證過程了斥难,所以我們一般不直接設置成接口地址
// 注意2: 這里配置的 地址一定要配置成無權(quán)限訪問枝嘶,否則將出現(xiàn) 一直重定向問題(因為無權(quán)限后又會重定向到這里配置的登錄頁url)
.loginPage(securityProperties.getLogin().getLoginPage())
//.loginPage("/loginRequire")
// 指定驗證憑據(jù)的URL(默認為 /login) ,
// 注意1:這里修改后的 url 會意味著 UsernamePasswordAuthenticationFilter 將 驗證此處的 url
// 注意2: 與 loginPage設置的接口地址是有 區(qū)別, 一但 loginPage 設置了的是訪問接口url,那么此處配置將無任何意義
// 注意3: 這里設置的 Url 是有默認無權(quán)限訪問的
.loginProcessingUrl(securityProperties.getLogin().getLoginUrl())
//分別設置成功和失敗的處理器
.successHandler(customAuthenticationSuccessHandler)
.failureHandler(customAuthenticationFailureHandler);
??最后在 SpringSecurityConfig 的 configure(HttpSecurity http) 方法中 調(diào)用 formAuthenticationConfig.configure(http) 即可;
?? 正如看到的一樣哑诊,我們通過 loginPage()設置 登錄頁面 或 接口, 通過 loginProcessingUrl() 設置 UsernamePasswordAuthenticationFilter 要匹配的 接口地址(一定是Post)(看過授權(quán)過程的同學應該都知道其默認的是/login)群扶。 這里有以下幾點值得注意:
- loginPage() 這里配置的 地址(不管是接口url還是登錄頁面)一定要配置成無權(quán)限訪問,否則將出現(xiàn) 一直重定向問題(因為無權(quán)限后又會重定向到這里配置的登錄頁url
- loginPage() 一般來說不直接設置成(登錄)接口镀裤,因為設置了接口會配置成無權(quán)限即可訪問(當然設置成登錄頁面也需要配置無權(quán)限訪問)竞阐,所以會走匿名filter, 也就意味著不會走認證過程了,所以我們一般不直接設置成接口地址
- loginProcessingUrl() 這里修改后的 url 會意味著 UsernamePasswordAuthenticationFilter 將 驗證此處的 url
- loginProcessingUrl() 這里設置的 Url 是有默認無權(quán)限訪問的,與 loginPage設置的接口地址是有 區(qū)別, 一但 loginPage 設置了的是接口url暑劝,那么此處配置將無任何意義
- successHandler() 和 failureHandler 分別 設置認證成功處理器 和 認證失敗處理器 (如果對這2個處理器沒印象的話馁菜,建議回顧下授權(quán)過程)
(二) 配置成功和失敗處理器
?? 在授權(quán)過程中,我們增簡單提及到過這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毁习,總是跳到設置的地址上
// 如果沒設置智嚷,則嘗試跳轉(zhuǎn)到登錄之前訪問的地址上,如果登錄前訪問地址為空纺且,則跳到網(wǎng)站根路徑上
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 {
// 跳轉(zhuǎn)設置的登陸失敗頁面
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() 配置的地址
(四)測試驗證
??這里就不在貼結(jié)果圖了,只要我們明白結(jié)果流程就行是這樣的就可以:
localhost:8080 ——> 點擊 測試驗證Security 權(quán)限控制 ————> 跳轉(zhuǎn)到 我們自定義的 /loginUp.html 登錄頁,登錄后 ————> 有配置loginSuccessUrl,則跳轉(zhuǎn)到 loginSuccess.html;反之則直接跳轉(zhuǎn)到 /get_user/test 接口返回結(jié)果载碌。 整個流程就全面涉及到了我們自定義的登錄頁面猜嘱、自定義的登錄成功/失敗處理器。
二嫁艇、 RememberMe (記住我)功能解析
(一)RememberMe 功能實現(xiàn)配置
首先我們一股腦的將rememberMe配置加上朗伶,然后看下現(xiàn)象:
1、 創(chuàng)建 persistent_logins 表步咪,用于存儲token和用戶的關聯(lián)信息:
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,意味著我們將從數(shù)據(jù)庫中讀取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的獲取策略仇矾,這里配置成從數(shù)據(jù)庫中讀取
- userDetailsService() 配置 UserDetaisService (如果不熟悉該對象庸蔼,建議回顧認證過程)
- tokenValiditySeconds() 設置 rememberMe 的有效時間,這里通過 配置來設置
另一個重要的配置在登錄頁面贮匕,這里的 必須是 name="remember-me" 姐仅,rememberMe就是通過驗證這個配置來開啟remermberMe功能的。
<input name="remember-me" type="checkbox" value="true"/>記住我</td>
??實操結(jié)果應該為:進入登陸頁面 ——> 勾選記住我后登錄 ——> 成功后刻盐,查看persistent_logins 表發(fā)現(xiàn)有一條數(shù)據(jù)——> 重啟項目 ——> 重新訪問需要登錄才能訪問的頁面,發(fā)現(xiàn)無需登錄即可訪問——> 刪除 persistent_logins 表數(shù)據(jù)掏膏,等待token設置的有效時間過期,然后重新刷新頁面發(fā)現(xiàn)跳轉(zhuǎn)到登陸頁面敦锌。
(二) RembemberMe 實現(xiàn)源碼解析
?? 首先我們查看UsernamePasswordAuthenticationFiler(AbstractAuthenticationProcessingFilter) 的 successfulAuthentication() 方法內(nèi)部源碼:
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
// 1 設置 認證成功的Authentication對象到SecurityContext中
SecurityContextHolder.getContext().setAuthentication(authResult);
// 2 調(diào)用 RememberMe 相關service處理
rememberMeServices.loginSuccess(request, response, authResult);
// Fire event
if (this.eventPublisher != null) {
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
authResult, this.getClass()));
}
//3 調(diào)用成功處理器
successHandler.onAuthenticationSuccess(request, response, authResult);
}
其中我們發(fā)現(xiàn)我們本次重點關注的一行代碼: rememberMeServices.loginSuccess(request, response, authResult) , 查看這個方法內(nèi)部源碼:
@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() 方法 最終會調(diào)用到 PersistentTokenBasedRememberMeServices 的 onLoginSuccess() 方法,貼出其方法源碼如下:
protected void onLoginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
// 1 獲取賬戶名
String username = successfulAuthentication.getName();
// 2 創(chuàng)建 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 創(chuàng)建 PersistentRememberMeToken 對象
- 通過 tokenRepository 存儲 persistentRememberMeToken信息
- 將 persistentRememberMeToken 信息添加到Cookie中
??這里的 tokenRepository 就是我們配置 rememberMe功能所設置的乙墙。經(jīng)過上面的解析我們看到了rememberServices 將 創(chuàng)建一個 token 信息颖变,并存儲到數(shù)據(jù)庫(因為我們配置的是數(shù)據(jù)庫存儲方式 JdbcTokenRepositoryImpl )中,并將token信息添加到Cookie中了听想。到這里腥刹,我們看到了RememberMe實現(xiàn)前的一些業(yè)務處理,那么后面如何實現(xiàn)RememberMe汉买,我想大家心里大概都有個底了衔峰。這里直接拋出之前授權(quán)過程中我們沒有提及到的 filter 類 RememberMeAuthenticationFilter,它是介于 UsernamePasswordAuthenticationFilter 和 AnonymousAuthenticationFilter 之間的一個filter蛙粘,它主要負責的就是前面的filter都沒有認證成功后從Cookie中獲取token信息然后再通過tokenRepository 獲取 登錄用戶名垫卤,然后UserDetailsServcie 加載 UserDetails 信息 ,最后創(chuàng)建 Authticaton(RememberMeAuthenticationToken) 信息再調(diào)用 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 調(diào)用 rememberMeServices.autoLogin() 獲取Authtication 信息
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
if (rememberMeAuth != null) {
// Attempt authenticaton via AuthenticationManager
try {
// 2 調(diào)用 authenticationManager.authenticate() 認證
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
......
}
}
catch (AuthenticationException authenticationException) {
.....
}
chain.doFilter(request, response);
}
我們主要關注 rememberMeServices.autoLogin(request,response) 方法實現(xiàn)穴肘,查看器源碼:
@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 信息創(chuàng)建 Authentication
return createSuccessfulAuthentication(request, user);
}
.....
}
內(nèi)部實現(xiàn)步驟:
- 從Cookie中獲取 token 信息并解析
- 通過 解析的token 生成 UserDetails (processAutoLoginCookie() 方法實現(xiàn) )
- 通過 UserDetails 生成 Authentication ( createSuccessfulAuthentication() 創(chuàng)建 RememberMeAuthenticationToken )
其中最關鍵的一部是 processAutoLoginCookie() 方法是如何生成UserDetails 對象的,我們查看這個方法源碼實現(xiàn):
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request, HttpServletResponse response) {
final String presentedSeries = cookieTokens[0];
final String presentedToken = cookieTokens[1];
// 1 通過 tokenRepository 加載數(shù)據(jù)庫token信息
PersistentRememberMeToken token = tokenRepository
.getTokenForSeries(presentedSeries);
PersistentRememberMeToken newToken = new PersistentRememberMeToken(
token.getUsername(), token.getSeries(), generateTokenData(), new Date());
// 2 判斷 用戶傳入token和數(shù)據(jù)中的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());
}
我們看下其內(nèi)部步驟:
- 通過 tokenRepository 加載數(shù)據(jù)庫token信息
- 判斷 用戶傳入token和數(shù)據(jù)中的token是否一致淆衷,不一致可能存在安全問題
- 更新 token 并添加到Cookie中
- 通過 UserDetailsService().loadUserByUsername() 方法加載UserDetails 信息并返回
?? 看到這里相信大家以下就明白了,當初為啥在啟用rememberMe功能時要配置 tokenRepository 和 UserDetailsService了顿痪。
這里我就不再演示整個實現(xiàn)的流程了蘑斧,老規(guī)矩,上流程圖:
?? 本文介紹個性化認證和RememberMe的代碼可以訪問代碼倉庫中的 security 模塊 边翼,項目的github 地址 : https://github.com/BUG9/spring-security
?? ?? ?? 如果您對這些感興趣鱼响,歡迎star、follow组底、收藏丈积、轉(zhuǎn)發(fā)給予支持!