思考
如果使用过springsecurity的记住我功能就知道,其实用户每一次上线都会刷新一次最后登录时间,那么如果将过期时间设置为7天的话,只要用户在7天内再次登录,那么后面的7天时间又不用再登录了,就会无限循环下去。
但有第二种场景,规定7天内免登录,只要过了7天时间就一定需要再次登录,结合springSecurity的remember-me记住我功能源码分析,下文就是对这一场景的思考。
一、remember-me功能源码解析
首先,现在假设已经登录过一次,且已经使用了remember-me的功能,数据库也存储了信息了。
现在关闭浏览器,再次登录网站,打上断点,看一下它的流程是怎么样的。应该在RememberMeAuthenticationFilter的doFilter方法上的第一行代码打上断点进行调试。
因为已经开启了remember-me的功能,访问网站后,就会进入到RememberMeAuthenticationFilter过滤器的doFilter方法上了。
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) {
//关键方法autoLogin,下面直接进入这个方法进行调试,它接受的是一个Authentication类型的值
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
//如果rememberMeAuth不为空,进入这个if语句,此时应该是RememberMeAuthenticationToken类型的对象
if (rememberMeAuth != null) {
// Attempt authenticaton via AuthenticationManager
try {
//和表单登录的方式一样,同样会利用ProviderManager对RememberMeAuthenticationToken进行认证
//最终是使用RememberMeAuthenticationProvider进行认证
//认证关键代码
//if(this.key.hashCode() != ((RememberMeAuthenticationToken) authentication).getKeyHash())
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
//将认证对象设置到上下文中,这样就认证成功了
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
//一个空实现的方法,什么都没有做
onSuccessfulAuthentication(request, response, rememberMeAuth);
if (logger.isDebugEnabled()) {
logger.debug("SecurityContextHolder populated with remember-me token: '"
+ SecurityContextHolder.getContext().getAuthentication()
+ "'");
}
// Fire event
if (this.eventPublisher != null) {
eventPublisher
.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext()
.getAuthentication(), this.getClass()));
}
if (successHandler != null) {
successHandler.onAuthenticationSuccess(request, response,
rememberMeAuth);
return;
}
}
catch (AuthenticationException authenticationException) {
if (logger.isDebugEnabled()) {
logger.debug(
"SecurityContextHolder not populated with remember-me token, as "
+ "AuthenticationManager rejected Authentication returned by RememberMeServices: '"
+ rememberMeAuth
+ "'; invalidating remember-me token",
authenticationException);
}
rememberMeServices.loginFail(request, response);
onUnsuccessfulAuthentication(request, response,
authenticationException);
}
}
//继续往下执行过滤链的过滤器。
chain.doFilter(request, response);
}
else {
if (logger.isDebugEnabled()) {
logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: '"
+ SecurityContextHolder.getContext().getAuthentication() + "'");
}
chain.doFilter(request, response);
}
}
第一个关键方法autoLogin
@Override
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response) {
//这个方法的作用是获取cookie名称为remember-me的值,这里不再点入,我这里的值是STdCYzR1RExXQXEwblFMZ3ZHeWJmZyUzRCUzRDolMkZJNTd3OFdHU0xOWW1qaFJHUTlRSkElM0QlM0Q
String rememberMeCookie = extractRememberMeCookie(request);
//简单判断rememberMeCookie是否为空
if (rememberMeCookie == null) {
return null;
}
logger.debug("Remember-me cookie detected");
//简单判断rememberMeCookie的长度是否为0
if (rememberMeCookie.length() == 0) {
logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
}
//先定义一个UserDetails,UserDetails是一个接口,最常用的是User的实现类
UserDetails user = null;
try {
//解码rememberMeCookie里面的值,不用在意是如何解析的,一个可以解析出两个值,分别为数据库中存储的series和token的值,可以比对一下。
String[] cookieTokens = decodeCookie(rememberMeCookie);
//第二个关键方法processAutoLoginCookie,可以看到它的返回值是UserDetails类型的对象,进入该方法,先看它的源码实现。
user = processAutoLoginCookie(cookieTokens, request, response);
//在进行第二个关键方法processAutoLoginCookie后,我们成功获取到UserDetails类型的对象了。
//这个方法主要是校验这个user是否可用,如果serDetails类型中的isAccountNonExpired(),isAccountNonLocked(),
//isCredentialsNonExpired(),isEnabled()这四个方法都返回true,则通过检验,否则抛出异常。
userDetailsChecker.check(user);
logger.debug("Remember-me cookie accepted");
//autoLogin返回值是Authentication接口的对象,方法名称是创建成功的身份验证
//这里不再点进去看,返回的是一个RememberMeAuthenticationToken类型的对象,他是Authentication接口的实现类
//权限信息也会在这里进行赋值,返回doFilter方法继续调试
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException cte) {
cancelCookie(request, response);
throw cte;
}
catch (UsernameNotFoundException noUser) {
logger.debug("Remember-me login was valid but corresponding user not found.",
noUser);
}
catch (InvalidCookieException invalidCookie) {
logger.debug("Invalid remember-me cookie: " + invalidCookie.getMessage());
}
catch (AccountStatusException statusInvalid) {
logger.debug("Invalid UserDetails: " + statusInvalid.getMessage());
}
catch (RememberMeAuthenticationException e) {
logger.debug(e.getMessage());
}
cancelCookie(request, response);
return null;
}
第二个关键方法processAutoLoginCookie
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request, HttpServletResponse response) {
//如果解析出的cookie数组的值不等于2,则抛出异常
if (cookieTokens.length != 2) {
throw new InvalidCookieException("Cookie token did not contain " + 2
+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
//获取series的值,这个值和数据库中的值相同
final String presentedSeries = cookieTokens[0];
//获取token的值,这个值和数据库中的值相同
final String presentedToken = cookieTokens[1];
//getTokenForSeries方法,从名字就可以看出,它会根据Series的值从数据库中获取数据库的记住我信息,这里不再点进去看
//它的sql是 select username,series,token,last_used from persistent_logins where series = ?
//查询出来的token结果是username=yuki,series=I7Bc4uDLWAq0nQLgvGybfg==,tokenvalue=/I57w8WGSLNYmjhRGQ9QJA==,
//date=2021-09-22 15:32:03.0 和数据库中的信息是一摸一样的。
PersistentRememberMeToken token = tokenRepository
.getTokenForSeries(presentedSeries);
//简单判断,忽略
if (token == null) {
// No series match, so we can't authenticate using this cookie
throw new RememberMeAuthenticationException(
"No persistent token found for series id: " + presentedSeries);
}
//presentedToken是一开始从cookieTokens中获取的值,token.getTokenValue()是数据库中的值,明显相等,不会进入if语句
if (!presentedToken.equals(token.getTokenValue())) {
// Token doesn't match series value. Delete all logins for this user and throw
// an exception to warn them.
tokenRepository.removeUserTokens(token.getUsername());
throw new CookieTheftException(
messages.getMessage(
"PersistentTokenBasedRememberMeServices.cookieStolen",
"Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
}
//判断用户是否已经超过了remember-me规定的时间了,如果超过了,则抛出异常,记住我功能失效,需要重新登录
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System
.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
//日志模块,不管
if (logger.isDebugEnabled()) {
logger.debug("Refreshing persistent login token for user '"
+ token.getUsername() + "', series '" + token.getSeries() + "'");
}
//构建一个新的token,很明显是为了要更新数据库中的信息,其中username和series的值都没有发生改变
//而数据库中的token和last_used值会发生改变,PersistentRememberMeToken重新封装了而已
PersistentRememberMeToken newToken = new PersistentRememberMeToken(
token.getUsername(), token.getSeries(), generateTokenData(), new Date());
try {
//真正的更新数据库的语句,这里不点进去看了
//sql是update persistent_logins set token = ?, last_used = ? where series = ?
tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
newToken.getDate());
//给浏览器添加cookie,不点进去看,只需要知道更新remember-me的cookie值即可
addCookie(newToken, request, response);
}
catch (Exception e) {
logger.error("Failed to update token: ", e);
throw new RememberMeAuthenticationException(
"Autologin failed due to data access problem");
}
//根据从数据库中查询出来的username到UserDetailsService中查询出用户的具体信息
//通常我们会使用我们自己定义的UserDetailsService类来进行查询
//只要返回值是UserDetails类型就可以,可以直接使用User类,也可以进行自定义
//现在直接返回到第一个关键方法autoLogin处继续调试
return getUserDetailsService().loadUserByUsername(token.getUsername());
}
二、从源码看出的问题
在源码的第二个关键方法processAutoLoginCookie中可以看到,springsecurity会拿用户数据库中的last_used时间+我们设定的过期时间<当前时间进行判断用户登录时间是否过期。
//判断用户是否已经超过了remember-me规定的时间了,如果超过了,则抛出异常,记住我功能失效,需要重新登录
if (token.getDate().getTime() + getTokenValiditySeconds() * 1000L < System
.currentTimeMillis()) {
throw new RememberMeAuthenticationException("Remember-me login has expired");
}
但是在判断完成后,如果没有超时,它还会更新数据库用户的last_used时间,那么如果将过期时间设置为7天的话,只要用户在7天内再次登录,那么后面的7天时间就又可以直接进行登录了,就可以无限循环下去。
//构建一个新的token,很明显是为了要更新数据库中的信息,其中username和series的值都没有发生改变
//而数据库中的token和last_used值会发生改变,PersistentRememberMeToken重新封装了而已
PersistentRememberMeToken newToken = new PersistentRememberMeToken(
token.getUsername(), token.getSeries(), generateTokenData(), new Date());
//真正的更新数据库的语句,这里不点进去看了
//sql是update persistent_logins set token = ?, last_used = ? where series = ?
tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(),
newToken.getDate());
但有的场景是7天内时间免登录,只要过了7天时间就都要登录,这样springsecurity的这种逻辑就不能做到了。
三、尝试解决问题
为了实现第二种场景,可以自己实现记住我功能,正常来说如果我们退出了浏览器,如果没有使用记住我的功能,那么再次打开浏览器,我们就又要进行重新登录了,原因其实是在关闭浏览器的时候,cookie就会被清空,自然JSESSIONID也会消失,那么就需要再次登录了,因此为了不让cookie消失,可以为cookie设定过期时间,利用登录成功的处理器来实现。
@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//设置cookie过期时间
Cookie cookie = new Cookie("JSESSIONID",session.getId());
//单位为s
cookie.setMaxAge(60*60*24*7);
response.addCookie(cookie);
//不仅要设置cookie过期时间,还要设置session过期时间,因为session默认过期时间为30分钟
//如果session消失了,那么即使cookie还在也没有作用了
HttpSession session = request.getSession();
session.setMaxInactiveInterval(60*60*24*7);
response.sendRedirect("http://localhost:7071/index.html");
}
}
最后在WebSecurityConfig配置类中绑定这个登录成功处理器就可以实现第二种场景的记住我功能了,但需要注意session是存储在服务端的,因此过多的session是不好的,可以将session信息存储到redis中。
这样关闭浏览器后,再打开浏览器访问网站,cookie和session信息都还在,就能在规定的时间内访问网站了。
第一次思考这个记住我功能,如果有其他见解,请交流一下。