基礎-進階-升級!圖解SpringSecurity的RememberMe流程
前言
之前我已經寫過好幾篇權限認證相關的文章了,有想復習的同學可以查看【身份權限認證合集】。今天我們來聊一下登陸頁面中“記住我”這個看似簡單實則復雜的小功能。
如圖就是博客園登陸時的“記住我”選項,在實際開發登陸接口以前,我一直認為這個“記住我”就是把我的用戶名和密碼保存到瀏覽器的 cookie 中,當下次登陸時瀏覽器會自動顯示我的用戶名和密碼,就不用我再次輸入了。
直到我看了 Spring Security 中 Remember Me 相關的源碼,我才意識到之前的理解全錯了,它的作用其實是讓用戶在關閉瀏覽器之后再次訪問時不需要重新登陸。
原理
如果用戶勾選了 “記住我” 選項,Spring Security 將在用戶登錄時創建一個持久的安全令牌,并將令牌存儲在 cookie 中或者數據庫中。當用戶關閉瀏覽器并再次打開時,Spring Security 可以根據該令牌自動驗證用戶身份。
先來張圖感受下,然后跟著阿Q從簡單的Spring Security 登陸樣例開始慢慢搭建吧!
基礎版
搭建
初始化sql
//用戶表
CREATE TABLE `sys_user_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`username` varchar(255) DEFAULT NULL,
`password` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8
//插入用戶數據
INSERT INTO sys_user_info
(id, username, password)
VALUES(1, 'cheetah', '$2a$10$N.zJIQtKLyFe62/.wL17Oue4YFXUYmbWICsMiB7c0Q.sF/yMn5i3q');
//產品表
CREATE TABLE `product_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`price` decimal(10,4) DEFAULT NULL,
`create_date` datetime DEFAULT NULL,
`update_date` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8
//插入產品數據
INSERT INTO product_info
(id, name, price, create_date, update_date)
VALUES(1, '從你的全世界路過', 32.0000, '2020-11-21 21:26:12', '2021-03-27 22:17:39');
INSERT INTO product_info
(id, name, price, create_date, update_date)
VALUES(2, '喬布斯傳', 25.0000, '2020-11-21 21:26:42', '2021-03-27 22:17:42');
INSERT INTO product_info
(id, name, price, create_date, update_date)
VALUES(3, 'java開發', 87.0000, '2021-03-27 22:43:31', '2021-03-27 22:43:34');
依賴引入
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
配置類
自定義 SecurityConfig 類繼承 WebSecurityConfigurerAdapter 類,并實現里邊的 configure(HttpSecurity httpSecurity)方法。
/**
* 安全認證及授權規則配置
**/
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeRequests()
.anyRequest()
//除上面外的所有請求全部需要鑒權認證
.authenticated()
.and()
//登陸成功之后的跳轉頁面
.formLogin().defaultSuccessUrl("/productInfo/index").permitAll()
.and()
//CSRF禁用
.csrf().disable();
}
另外還需要指定認證對象的來源和密碼加密方式
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userInfoService).passwordEncoder(passwordEncoder());
}
@Bean
public BCryptPasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
驗證
啟動程序,瀏覽器打開http://127.0.0.1:8080/login
輸入用戶名密碼登陸成功
我們就可以拿著 JSESSIONID 去請求需要登陸的資源了。
源碼分析
方框中的是類和方法名,方框外是類中的方法具體執行到的代碼。
首先會按照圖中箭頭的方向來執行,最終會執行到我們自定義的實現了 UserDetailsService 接口的 UserInfoServiceImpl 類中的查詢用戶的方法 loadUserByUsername()。
該流程如果不清楚的話記得復習《實戰篇:Security+JWT組合拳 | 附源碼》
當認證通過之后會在SecurityContext中設置Authentication對象
org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter#successfulAuthentication中的方法SecurityContextHolder.getContext().setAuthentication(authResult);
最后調用onAuthenticationSuccess方法跳轉鏈接。
進階版
集成
接下來我們就要開始進入正題了,快速接入“記住我”功能。
在配置類 SecurityConfig 的 configure() 方法中加入兩行代碼,如下所示
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeRequests()
.anyRequest()
//除上面外的所有請求全部需要鑒權認證
.authenticated()
.and()
//開啟 rememberMe 功能
.rememberMe()
.and()
//登陸成功之后的跳轉頁面
.formLogin().defaultSuccessUrl("/productInfo/index").permitAll()
.and()
//CSRF禁用
.csrf().disable();
}
重啟應用頁面上會出現單選框“Remember me on this computer”
可以查看下頁面的屬性,該單選框的名字為“remember-me”
點擊登陸,在 cookie 中會出現一個屬性為 remember-me 的值,在以后的每次發送請求都會攜帶這個值到后臺
然后我們直接輸入http://127.0.0.1:8080/productInfo/getProductList獲取產品信息
當我們把 cookie 中的 JSESSIONID 刪除之后重新獲取產品信息,發現會生成一個新的 JSESSIONID。
源碼分析
認證通過的流程和基礎版本一致,我們著重來分析身份認證通過之后,跳轉鏈接之前的邏輯。
疑問1
圖中1處為啥是 AbstractRememberMeServices 類呢?
我們發現在項目啟動時,在類 AbstractAuthenticationFilterConfigurer 的 configure() 方法中有如下代碼
RememberMeServices rememberMeServices = http.getSharedObject(RememberMeServices.class);
if (rememberMeServices != null) {
this.authFilter.setRememberMeServices(rememberMeServices);
}
AbstractRememberMeServices 類型就是在此處設置完成的,是不是一目了然了?
疑問2
當代碼執行到圖中2和3處時
@Override
public final void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
if (!rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
return;
}
onLoginSuccess(request, response, successfulAuthentication);
}
因為我們勾選了“記住我”,所以此時的值為“on”,即rememberMeRequested(request, this.parameter)返回 true,然后加非返回 false,最后一步就是設置 cookie 的值。
鑒權
此處的講解一定要對照著代碼來看,要不然很容易錯位,沒有類標記的方法都屬于RememberMeAuthenticationFilter#doFilter
當直接調用http://127.0.0.1:8080/productInfo/index接口時,會走RememberMeAuthenticationFilter#doFilter的代碼
//此處存放的是登陸的用戶信息,可以理解為對應的cookie中的 JSESSIONID
if (SecurityContextHolder.getContext().getAuthentication() != null) {
this.logger.debug(LogMessage
.of(() -> "SecurityContextHolder not populated with remember-me token, as it already contained: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'"));
chain.doFilter(request, response);
return;
}
因為SecurityContextHolder.getContext().getAuthentication()中有用戶信息,所以直接返回商品信息。
當刪掉 JSESSIONID 后重新發起請求,發現SecurityContextHolder.getContext().getAuthentication()為 null ,即用戶未登錄,會往下走Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);代碼,即自動登陸的邏輯
@Override
public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
//該方法的this.cookieName 的值為"remember-me",所以該處返回的是 cookie中remember-me的值
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
this.logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0) {
this.logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
try {
//對rememberMeCookie進行解碼:
String[] cookieTokens = decodeCookie(rememberMeCookie);
//重點:執行TokenBasedRememberMeServices#processAutoLoginCookie下的 UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);
//就又回到我們自定義的 UserInfoServiceImpl 類中執行代碼,返回user
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
this.userDetailsChecker.check(user);
this.logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException ex) {
cancelCookie(request, response);
throw ex;
}
catch (UsernameNotFoundException ex) {
this.logger.debug("Remember-me login was valid but corresponding user not found.", ex);
}
catch (InvalidCookieException ex) {
this.logger.debug("Invalid remember-me cookie: " + ex.getMessage());
}
catch (AccountStatusException ex) {
this.logger.debug("Invalid UserDetails: " + ex.getMessage());
}
catch (RememberMeAuthenticationException ex) {
this.logger.debug(ex.getMessage());
}
cancelCookie(request, response);
return null;
}
執行完之后接著執行RememberMeAuthenticationFilter#doFilter中的rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
當執行到ProviderManager#authenticate中的result = provider.authenticate(authentication);時,會走RememberMeAuthenticationProvider 中的方法返回 Authentication 對象。
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);將登錄成功信息保存到 SecurityContextHolder 對象中,然后返回商品信息。
升級版
如果記錄在服務器 session 中的 token 因為服務重啟而失效,就會導致前端用戶明明勾選了“記住我”的功能,但是仍然提示需要登陸。
這就需要我們對 session 中的 token 做持久化處理,接下來我們就對他進行升級。
集成
初始化sql
CREATE TABLE `persistent_logins` (
`username` varchar(64) NOT NULL COMMENT '用戶名',
`series` varchar(64) NOT NULL COMMENT '主鍵',
`token` varchar(64) NOT NULL COMMENT 'token',
`last_used` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '最后一次使用的時間',
PRIMARY KEY (`series`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
不要問我為啥這樣創建表,我會在下邊告訴你??
配置類
//在SecurityConfig的configure方法中增加一行
.rememberMe().tokenRepository(persistentTokenRepository());
//引入依賴,注入bean
@Autowired
private DataSource dataSource;
@Bean
public PersistentTokenRepository persistentTokenRepository(){
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);
return tokenRepository;
}
驗證
重啟項目,訪問http://127.0.0.1:8080/login之后返回數據,查看表中數據,完美。
源碼分析
前邊的流程和升級版是相同的,區別就在于創建 token 之后是保存到 session 中還是持久化到數據庫中,接下來我們從源碼分析一波。
定位到AbstractRememberMeServices#loginSuccess中的 onLoginSuccess()方法,實際執行的是PersistentTokenBasedRememberMeServices#onLoginSuccess方法。
/**
* 使用新的序列號創建新的永久登錄令牌,并將數據存儲在
* 持久令牌存儲庫,并將相應的 cookie 添加到響應中。
*
*/
@Override
protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
......
try {
//重點代碼創建token并保存到數據庫中
this.tokenRepository.createNewToken(persistentToken);
addCookie(persistentToken, request, response);
}
......
}
因為我們在配置類中定義的是JdbcTokenRepositoryImpl,所以進入改類的createNewToken方法。
@Override
public void createNewToken(PersistentRememberMeToken token) {
getJdbcTemplate().update(this.insertTokenSql, token.getUsername(), token.getSeries(), token.getTokenValue(),
token.getDate());
}
此時我們發現他就是做了插入數據庫的操作,并且this.insertTokenSql為
insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)
同時我們看到了熟悉的建表語句
create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, "
+ "token varchar(64) not null, last_used timestamp not null)
這樣是不是就決解了上邊的疑惑了呢。
執行完PersistentTokenBasedRememberMeServices#onLoginSuccess方法之后又進入到RememberMeAuthenticationFilter#doFilter()方法中結束。
有了持久化之后就不用擔心服務重啟了,接著我們重啟服務,繼續訪問獲取商品接口,成功返回商品信息。
鑒權
鑒權的邏輯也是和進階版相似的,區別在于刪除瀏覽器的 JSESSIONID 之后的邏輯。
定位到AbstractRememberMeServices#autoLogin中的UserDetails user = processAutoLoginCookie(cookieTokens, request, response);執行的是PersistentTokenBasedRememberMeServices#processAutoLoginCookie。
//刪減版代碼
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request,
HttpServletResponse response) {
......
String presentedSeries = cookieTokens[0];
String presentedToken = cookieTokens[1];
PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
if (token == null) {
throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
}
if (!presentedToken.equals(token.getTokenValue())) {
this.tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(this.messages.getMessage(
"PersistentTokenBasedRememberMeServices.cookieStolen",
"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
}
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(),
generateTokenData(), new Date());
try {
this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
addCookie(newToken, request, response);
}
......
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
流程
- 通過數據庫中的 series 字段找到對應的記錄;
- 記錄是否為空判斷以及記錄中的 token 是否和傳入的相同;
- 記錄中的 last_used 加上默認的兩周后是否大于當前時間,即是否 token 失效;
- 更新該記錄并將新生成的 token 放到 cookie 中;
后續的邏輯和進階版一致。
擴展版
看到這有的小伙伴肯定會問了,如果我不用默認的登錄頁面,想用自己的登錄頁需要注意些什么呢?
首先要注意的就是“記住我”勾選框參數名必須為“remember-me”。如果你想自定義的話也是可以的,需要將自定義的名字例如:remember-me-new 配置到配置類中。
.rememberMe().rememberMeParameter("remember-me-new")
token 的有效期也是可以自定義的,例如設置有效期為2天
.rememberMe().tokenValiditySeconds(2*24*60*60)
我們還可以自定義保存在瀏覽器中的 cookie 的名稱
.rememberMe().rememberMeCookieName("remember-me-cookie")