文章目录
前言
之前我已经写过好几篇权限认证相关的文章了,有想复习的同学可以查看【身份权限认证合集】。今天我们来聊一下登陆页面中“记住我”这个看似简单实则复杂的小功能。
如图就是博客园登陆时的“记住我”选项,在实际开发登陆接口以前,我一直认为这个“记住我”就是把我的用户名和密码保存到浏览器的 cookie 中,当下次登陆时浏览器会自动显示我的用户名和密码,就不用我再次输入了。
直到我看了
Spring Security
中
Remember Me
相关的源码,我才意识到之前的理解全错了,它的作用其实是让用户在关闭浏览器之后再次访问时不需要重新登陆。
原理
如果用户勾选了 “记住我” 选项,
Spring Security
将在用户登录时创建一个持久的安全令牌,并将令牌存储在 cookie 中或者数据库中。当用户关闭浏览器并再次打开时,Spring Security 可以根据该令牌自动验证用户身份。
先来张图感受下,然后跟着阿Q从简单的
Spring Security
登陆样例开始慢慢搭建吧!
基础版
搭建
初始化sql
//用户表CREATETABLE`sys_user_info`(`id`bigint(20)NOTNULLAUTO_INCREMENT,`username`varchar(255)DEFAULTNULL,`password`varchar(255)DEFAULTNULL,PRIMARYKEY(`id`))ENGINE=InnoDBAUTO_INCREMENT=1DEFAULTCHARSET=utf8
//插入用户数据INSERTINTO sys_user_info
(id, username, password)VALUES(1,'cheetah','$2a$10$N.zJIQtKLyFe62/.wL17Oue4YFXUYmbWICsMiB7c0Q.sF/yMn5i3q');//产品表CREATETABLE`product_info`(`id`bigint(20)NOTNULLAUTO_INCREMENT,`name`varchar(255)DEFAULTNULL,`price`decimal(10,4)DEFAULTNULL,`create_date`datetimeDEFAULTNULL,`update_date`datetimeDEFAULTNULL,PRIMARYKEY(`id`))ENGINE=InnoDBAUTO_INCREMENT=1DEFAULTCHARSET=utf8
//插入产品数据INSERTINTO 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');INSERTINTO 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');INSERTINTO 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)
方法。
/**
* 安全认证及授权规则配置
**/@Overrideprotectedvoidconfigure(HttpSecurity httpSecurity)throwsException{
httpSecurity
.authorizeRequests().anyRequest()//除上面外的所有请求全部需要鉴权认证.authenticated().and()//登陆成功之后的跳转页面.formLogin().defaultSuccessUrl("/productInfo/index").permitAll().and()//CSRF禁用.csrf().disable();}
另外还需要指定认证对象的来源和密码加密方式
@Overridepublicvoidconfigure(AuthenticationManagerBuilder auth)throwsException{
auth.userDetailsService(userInfoService).passwordEncoder(passwordEncoder());}@BeanpublicBCryptPasswordEncoderpasswordEncoder(){returnnewBCryptPasswordEncoder();}
【阿Q说代码】后台回复“reme”获取项目源码。
验证
启动程序,浏览器打开
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() 方法中加入两行代码,如下所示
@Overrideprotectedvoidconfigure(HttpSecurity httpSecurity)throwsException{
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处时
@OverridepublicfinalvoidloginSuccess(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);
代码,即自动登陆的逻辑
@OverridepublicfinalAuthenticationautoLogin(HttpServletRequest request,HttpServletResponse response){//该方法的this.cookieName 的值为"remember-me",所以该处返回的是 cookie中remember-me的值String rememberMeCookie =extractRememberMeCookie(request);if(rememberMeCookie ==null){returnnull;}this.logger.debug("Remember-me cookie detected");if(rememberMeCookie.length()==0){this.logger.debug("Cookie was empty");cancelCookie(request, response);returnnull;}try{//对rememberMeCookie进行解码:String[] cookieTokens =decodeCookie(rememberMeCookie);//重点:执行TokenBasedRememberMeServices#processAutoLoginCookie下的 UserDetails userDetails = getUserDetailsService().loadUserByUsername(cookieTokens[0]);//就又回到我们自定义的 UserInfoServiceImpl 类中执行代码,返回userUserDetails user =processAutoLoginCookie(cookieTokens, request, response);this.userDetailsChecker.check(user);this.logger.debug("Remember-me cookie accepted");returncreateSuccessfulAuthentication(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);returnnull;}
执行完之后接着执行
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
CREATETABLE`persistent_logins`(`username`varchar(64)NOTNULLCOMMENT'用户名',`series`varchar(64)NOTNULLCOMMENT'主键',`token`varchar(64)NOTNULLCOMMENT'token',`last_used`timestampNOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'最后一次使用的时间',PRIMARYKEY(`series`))ENGINE=InnoDBDEFAULTCHARSET=utf8
不要问我为啥这样创建表,我会在下边告诉你😋
配置类
//在SecurityConfig的configure方法中增加一行.rememberMe().tokenRepository(persistentTokenRepository());//引入依赖,注入bean@AutowiredprivateDataSource dataSource;@BeanpublicPersistentTokenRepositorypersistentTokenRepository(){JdbcTokenRepositoryImpl tokenRepository =newJdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource);return tokenRepository;}
验证
重启项目,访问
http://127.0.0.1:8080/login
之后返回数据,查看表中数据,完美。
源码分析
前边的流程和升级版是相同的,区别就在于创建 token 之后是保存到 session 中还是持久化到数据库中,接下来我们从源码分析一波。
定位到
AbstractRememberMeServices#loginSuccess
中的
onLoginSuccess()
方法,实际执行的是
PersistentTokenBasedRememberMeServices#onLoginSuccess
方法。
/**
* 使用新的序列号创建新的永久登录令牌,并将数据存储在
* 持久令牌存储库,并将相应的 cookie 添加到响应中。
*
*/@OverrideprotectedvoidonLoginSuccess(HttpServletRequest request,HttpServletResponse response,Authentication successfulAuthentication){......try{//重点代码创建token并保存到数据库中this.tokenRepository.createNewToken(persistentToken);addCookie(persistentToken, request, response);}......}
因为我们在配置类中定义的是
JdbcTokenRepositoryImpl
,所以进入改类的
createNewToken
方法。
@OverridepublicvoidcreateNewToken(PersistentRememberMeToken token){getJdbcTemplate().update(this.insertTokenSql, token.getUsername(), token.getSeries(), token.getTokenValue(),
token.getDate());}
此时我们发现他就是做了插入数据库的操作,并且
this.insertTokenSql
为
insertinto persistent_logins (username, series, token, last_used)values(?,?,?,?)
同时我们看到了熟悉的建表语句
createtable persistent_logins (username varchar(64)notnull, series varchar(64)primarykey,"
+ "token varchar(64)notnull, last_used timestampnotnull)
这样是不是就决解了上边的疑惑了呢。
执行完
PersistentTokenBasedRememberMeServices#onLoginSuccess
方法之后又进入到
RememberMeAuthenticationFilter#doFilter()
方法中结束。
有了持久化之后就不用担心服务重启了,接着我们重启服务,继续访问获取商品接口,成功返回商品信息。
鉴权
鉴权的逻辑也是和进阶版相似的,区别在于删除浏览器的 JSESSIONID 之后的逻辑。
定位到
AbstractRememberMeServices#autoLogin
中的
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
执行的是
PersistentTokenBasedRememberMeServices#processAutoLoginCookie
。
//删减版代码@OverrideprotectedUserDetailsprocessAutoLoginCookie(String[] cookieTokens,HttpServletRequest request,HttpServletResponse response){......String presentedSeries = cookieTokens[0];String presentedToken = cookieTokens[1];PersistentRememberMeToken token =this.tokenRepository.getTokenForSeries(presentedSeries);if(token ==null){thrownewRememberMeAuthenticationException("No persistent token found for series id: "+ presentedSeries);}if(!presentedToken.equals(token.getTokenValue())){this.tokenRepository.removeUserTokens(token.getUsername());thrownewCookieTheftException(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()){thrownewRememberMeAuthenticationException("Remember-me login has expired");}PersistentRememberMeToken newToken =newPersistentRememberMeToken(token.getUsername(), token.getSeries(),generateTokenData(),newDate());try{this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());addCookie(newToken, request, response);}......returngetUserDetailsService().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")
后台回复“reme”获取源码吧!
本文到这里就结束了,看看我为了写这篇文章又日渐稀少的头发,我忍不住哭出声来。可能只有给我点赞,才能平复我的心情吧。
好看的皮囊千篇一律,有趣的灵魂万里挑一,让我们在冷漠的城市里相互温暖,我是阿Q,我们下期再见!
版权归原作者 阿Q说代码 所有, 如有侵权,请联系我们删除。