Spring Security---记住我功能详解
Posted 大忽悠爱忽悠
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Spring Security---记住我功能详解相关的知识,希望对你有一定的参考价值。
Spring Security---记住我功能详解
Remember me
登录过程中经常使用的“记住我”功能,也就是我们经常会在各种网站登陆时见到的"两周内免登录",“三天内免登录”的功能。该功能的作用就是:当我们登录成功之后,一定的周期内当我们再次访问该网站,不需要重新登录。
步骤
实现这个功能非常简单,只需要我们在重写WebSecurityConfigurerAdapter 方法配置HttpSecurity 的时候增加rememberMe()方法。
@Override
protected void configure(HttpSecurity http) throws Exception
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.and()
.csrf().disable();
然后在登录表单中加入一个checkbox勾选框,name属性的值目前必须是“remember-me”(个性化更改的方法后面会讲)。
<label><input type="checkbox" name="remember-me"/>记住密码</label>
就是这么简单,我们就实现了记住我功能,默认效果是:2周内免登录.
测试
添加一个测试接口:
@RestController
public class HelloController
@GetMapping("/hello")
public String hello()
return "hello";
重启项目,我们访问 hello 接口,此时会自动跳转到登录页面:
这个时候大家发现,默认的登录页面多了一个选项,就是记住我。我们输入用户名密码,并且勾选上记住我这个框,然后点击登录按钮执行登录操作:
可以看到,登录数据中,除了 username 和 password 之外,还有一个 remember-me,之所以给大家看这个,是想告诉大家,如果你你需要自定义登录页面,RememberMe 这个选项的 key 该怎么写。
登录成功之后,就会自动跳转到 hello 接口了。我们注意,系统访问 hello 接口的时候,携带的 cookie:
大家注意到,这里多了一个 remember-me,这就是这里实现的核心,关于这个 remember-me 我一会解释,我们先来测试效果。
接下来,我们关闭浏览器,再重新打开浏览器。正常情况下,浏览器关闭再重新打开,如果需要再次访问 hello 接口,就需要我们重新登录了。但是此时,我们再去访问 hello 接口,发现不用重新登录了,直接就能访问到,这就说明我们的 RememberMe 配置生效了(即下次自动登录功能生效了)。
实现原理
- 当我们登陆的时候,除了用户名、密码,我们还可以勾选remember-me。
- 如果我们勾选了remember-me,当我们登录成功之后服务端会生成一个Cookie返回给浏览器,这个Cookie的名字默认是remember-me;值是一个token令牌。
- 当我们在有效期内再次访问应用时,经过
RememberMeAuthenticationFilter
,读取Cookie
中的token
进行验证。验正通过不需要再次登录就可以进行应用访问。
RememberMeToken 的组成
我们来分析一下 cookie 中多出来的这个 remember-me,这个值一看就是一个 Base64 转码后的字符串,我们可以使用网上的一些在线工具来解码,可以自己简单写两行代码来解码:
@Test
void contextLoads() throws UnsupportedEncodingException
String s = new String(Base64.getDecoder().decode("amF2YWJveToxNTg5MTA0MDU1MzczOjI1NzhmZmJjMjY0ODVjNTM0YTJlZjkyOWFjMmVmYzQ3"), "UTF-8");
System.out.println("s = " + s);
执行这段代码,输出结果如下:
s = javaboy:1589104055373:2578ffbc26485c534a2ef929ac2efc47
可以看到,这段 Base64 字符串实际上用 : 隔开,分成了三部分:
- 第一段是用户名,这个无需质疑。
- 第二段看起来是一个时间戳,我们通过在线工具或者 Java 代码解析后发现,这是一个两周后的数据。
- 第三段我就不卖关子了,这是使用 MD5 散列函数算出来的值,他的明文格式是
username + ":" + tokenExpiryTime + ":" + password + ":" + key
最后的 key 是一个散列盐值,可以用来防止令牌被修改。
了解到 cookie 中 remember-me 的含义之后,那么我们对于记住我的登录流程也就很容易猜到了了。
在浏览器关闭后,并重新打开之后,用户再去访问 hello 接口,此时会携带着 cookie 中的 remember-me 到服务端,服务到拿到值之后,可以方便的计算出用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将计算出的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效。
下图是TokenBasedRememberMeService中的源码
可能有的朋友会问:这样安全么?
如果cookie被劫持,一定是不安全的,
别人拿到了这个字符串在有效期内就可以访问你的应用。
这就和你的钥匙token被盗了,你家肯定不安全是一个道理。
但是不存在密码被破解为明文的可能性,MD5 hash是不可逆的。
过滤器执行流程
- 第一次登录请求的时候,用户使用其他验证方式进行登录(如用户名密码),勾选remember-me,并生成RememberMeToken 令牌。
- 第二次登陆的时候使用RememberMeToken令牌(就不用输入用户名密码了),RememberMeAuthenticationFilter在Spring Security过滤器链中处于整体偏后的位置,所以只有当各种传统的登录方式都无法完成验证的情况下,才走RememberMeAuthenticationFilter,这也是符合实际需求的。
个性化配置
在实际的开发过程中,我们还可以根据需求做一些个性化的设置,如下:
.rememberMe()
.rememberMeParameter("remember-me-new")
.rememberMeCookieName("remember-me-cookie")
.tokenValiditySeconds(2 * 24 * 60 * 60);
- tokenValiditySeconds用于设置token的有效期,即多长时间内可以免除重复登录,单位是秒。不修改配置情况下默认是2周。
- 通过rememberMeParameter设置from表单“自动登录”勾选框的参数名称。如果这里改了,from表单中checkbox的name属性要对应的更改。如果不设置默认是remember-me。
- rememberMeCookieName设置了保存在浏览器端的cookie的名称,如果不设置默认也是remember-me。如下图中查看浏览器的cookie。
源码分析
接下来,我们通过源码来验证一下我们上面说的对不对。
这里主要从两个方面来介绍,一个是 remember-me 这个令牌生成的过程,另一个则是它解析的过程。
生成
生成的核心处理方法在:TokenBasedRememberMeServices#onLoginSuccess:
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication)
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
if (!StringUtils.hasLength(password))
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
expiryTime += 1000L * (tokenLifetime < 0 ? TWO_WEEKS_S : tokenLifetime);
String signatureValue = makeTokenSignature(expiryTime, username, password);
setCookie(new String[] username, Long.toString(expiryTime), signatureValue ,
tokenLifetime, request, response);
protected String makeTokenSignature(long tokenExpiryTime, String username,
String password)
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + getKey();
MessageDigest digest;
digest = MessageDigest.getInstance("MD5");
return new String(Hex.encode(digest.digest(data.getBytes())));
- 首先从登录成功的 Authentication 中提取出用户名/密码。
- 由于登录成功之后,密码可能被擦除了,所以,如果一开始没有拿到密码,就再从 UserDetailsService 中重新加载用户并重新获取密码。
- 再接下来去获取令牌的有效期,令牌有效期默认就是两周。
- 再接下来调用
makeTokenSignature
方法去计算散列值,实际上就是根据username
、令牌有效期以及password
、key
一起计算一个散列值。如果我们没有自己去设置这个 key,默认是在RememberMeConfigurer#getKey
方法中进行设置的,它的值是一个UUID
字符串 - 最后,将用户名、令牌有效期以及计算得到的散列值放入 Cookie 中。
关于第四点,我这里再说一下。
//默认key
private String getKey()
if (this.key == null)
if (this.rememberMeServices instanceof AbstractRememberMeServices)
//如果我们配置了key
this.key = ((AbstractRememberMeServices)this.rememberMeServices).getKey();
else
//采用默认的key
this.key = UUID.randomUUID().toString();
return this.key;
由于我们自己没有设置 key,key 默认值是一个 UUID 字符串,这样会带来一个问题,就是如果服务端重启,这个 key 会变,这样就导致之前派发出去的所有 remember-me 自动登录令牌失效,所以,我们可以指定这个 key。指定方式如下:
@Override
protected void configure(HttpSecurity http) throws Exception
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.rememberMe()
.key("javaboy")
.and()
.csrf().disable();
如果自己配置了 key,即使服务端重启,即使浏览器打开再关闭,也依然能够访问到 hello 接口。
总结:
AbstractAuthenticationProcessingFilter#doFilter ->
AbstractAuthenticationProcessingFilter#successfulAuthentication ->
AbstractRememberMeServices#loginSuccess ->
TokenBasedRememberMeServices#onLoginSuccess。
注意:有小伙伴可能会问cookie默认的生命周期不是一次会话时间吗? 即关闭浏览器cookie就会被销毁,除非做了持久化,那么这里,关闭浏览器后再次访问,cookie依然存在,依然可以完成自动登录,说明cookie做了持久化,那么源码体现在何处呢? 如下AbstractRememberMeServices#setCookie:
protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request, HttpServletResponse response)
//加密拼串获取最终cookie的值
String cookieValue = this.encodeCookie(tokens);
Cookie cookie = new Cookie(this.cookieName, cookieValue);
//设置cookie的存活时间---持久化的体现
cookie.setMaxAge(maxAge);
//设置项目的路径
cookie.setPath(this.getCookiePath(request));
if (this.cookieDomain != null)
cookie.setDomain(this.cookieDomain);
if (maxAge < 1)
cookie.setVersion(1);
cookie.setSecure(this.useSecureCookie != null ? this.useSecureCookie : request.isSecure());
cookie.setHttpOnly(true);
response.addCookie(cookie);
//getCookiePath方法:
private String getCookiePath(HttpServletRequest request)
String contextPath = request.getContextPath();
return contextPath.length() > 0 ? contextPath : "/";
包括失败和退出登录调用cancelCoookie后,会自动清除cookie
//登录失败
public final void loginFail(HttpServletRequest request, HttpServletResponse response)
this.logger.debug("Interactive login attempt was unsuccessful.");
this.cancelCookie(request, response);
this.onLoginFail(request, response);
//退出登录
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
this.logger.debug(LogMessage.of(() ->
return "Logout of user " + (authentication != null ? authentication.getName() : "Unknown");
));
this.cancelCookie(request, response);
//清除cookie具体源码体现
protected void cancelCookie(HttpServletRequest request, HttpServletResponse response)
this.logger.debug("Cancelling cookie");
Cookie cookie = new Cookie(this.cookieName, (String)null);
cookie.setMaxAge(0);
cookie.setPath(this.getCookiePath(request));
if (this.cookieDomain != null)
cookie.setDomain(this.cookieDomain);
cookie.setSecure(this.useSecureCookie != null ? this.useSecureCookie : request.isSecure());
response.addCookie(cookie);
解析
那么当用户关掉并打开浏览器之后,重新访问 /hello 接口,此时的认证流程又是怎么样的呢?
我们之前说过,Spring Security 中的一系列功能都是通过一个过滤器链实现的,RememberMe 这个功能当然也不例外。
Spring Security 中提供了 RememberMeAuthenticationFilter 类专门用来做相关的事情,我们来看下
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)
//自动登录
Authentication rememberMeAuth = rememberMeServices.autoLogin(request,
response);
if (rememberMeAuth != null)
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
onSuccessfulAuthentication(request, response, rememberMeAuth);
if (this.eventPublisher != null)
eventPublisher
.publishEvent(new InteractiveAuthenticationSuccessEvent(
SecurityContextHolder.getContext()
.getAuthentication(), this.getClass()));
if (successHandler != null)
successHandler.onAuthenticationSuccess(request, response,
rememberMeAuth);
return;
chain.doFilter(request, response);
else
chain.doFilter(request, response);
可以看到,就是在这里实现的。
这个方法最关键的地方在于,如果从 SecurityContextHolder
中无法获取到当前登录用户实例,那么就调用 rememberMeServices.autoLogin
逻辑进行登录,我们来看下这个方法:
public final Authentication autoLogin(HttpServletRequest request,
HttpServletResponse response)
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null)
return null;
logger.debug("Remember-me cookie detected");
if (rememberMeCookie.length() == 0)
logger.debug("Cookie was empty");
cancelCookie(request, response);
return null;
UserDetails user = null;
try
String[] cookieTokens = decodeCookie(rememberMeCookie);
user = processAutoLoginCookie(cookieTokens, request, response);
userDetailsChecker.check(user);
logger.debug("Remember-me cookie accepted");
return createSuccessfulAuthentication(request, user);
catch (CookieTheftException cte)
throw cte;
cancelCookie(request, response);
return null;
可以看到,这里就是提取出 cookie 信息,并对 cookie 信息进行解码,解码之后,再调用 processAutoLoginCookie 方法去做校验,processAutoLoginCookie 方法的代码我就不贴了,核心流程就是首先获取用户名和过期时间,再根据用户名查询到用户密码,然后通过 MD5 散列函数计算出散列值,再将拿到的散列值和浏览器传递来的散列值进行对比,就能确认这个令牌是否有效,进而确认登录是否有效。
通过内存中获取到的用户,加密后,和前端传过来的token进行比对:
protected String makeTokenSignature(long tokenExpiryTime, String username, String password)
String data = username + ":" + tokenExpiryTime + ":" + password + ":" + this.getKey();
try
MessageDigest digest = MessageDigest.getInstance("MD5");
return new String(Hex.encode(digest.digest(data.getBytes())));
catch (NoSuchAlgorithmException var7)
throw new IllegalStateException("No MD5 algorithm available!");
总结
看了上面的文章,大家可能已经发现,如果我们开启了 RememberMe 功能,最最核心的东西就是放在 cookie 中的令牌了,这个令牌突破了 session 的限制,即使服务器重启、即使浏览器关闭又重新打开,只要这个令牌没有过期,就能访问到数据。
一旦令牌丢失,别人就可以拿着这个令牌随
以上是关于Spring Security---记住我功能详解的主要内容,如果未能解决你的问题,请参考以下文章