前言
今天我们来聊聊RemenberMe功能,他的实现或许跟你的最初的想法不一样哦。
什么是RememberMe
其实就是“记住我”功能。在我们工作/生活中,总会存在被打断的情况,临时需要去做其他事情。而当我们想回来继续处理的时候,通常都会发现,网页已经退出登录态了。也就是开发同学常说的,session超时了。而“记住我”则可以完美解决该问题。
除此之外,对于移动端的APP而言,也有同样的妙用。可以让用户长时间保持登录态。
“记住我”意味着,只要用户不是主动退出的,都应该认为用户还处于登录态。
如何实现RememberMe
我们来看看实现RememberMe需要做什么?一个完整流程是怎么样的?
- 登录认证成功后,我们需要生成一个RememberMe的凭证,然后返回给浏览器。这里通常是一个长期有效的Cookie,有效期与你预期的RememberMe的时间一样。
- 检测用户是否处于登录态。当用户没有处于登录态,且cookie中存在RememberMe凭证,在确认凭证有效之后,自动恢复登录态。
- 用户主动注销登录态,我们就要清理cookie,销毁凭证。
-
对于步骤一,SpringSecurity提供了一个新的组件:RememberMeServices。在我们专栏的Spring Security之认证过滤器的UsernamePasswordAuthenticationFilter中我们也看到了源码在认证完成后调用该组件完成RememberMe的凭证处理。
PS: 可能有同学会问为什么不用AuthenticationSuccessHandler。如果你看过他的类注释,你就能找到答案了。这个组件的设计本意是完成登录后给用户呈现的内容。而我们这里要做的与之无关。
-
对于步骤二,我们则需要一个新的过滤器,用来检查每个请求的登录态,以及处理登录态恢复。这便是RememberMeAuthenticationFilter。
-
而步骤三,通过LogoutHandler就行。实际上,RememberMeServices也是他的子类。这一点体现了功能的高内聚,将与RememberMe相关的内容都放在一起了。
PS:题外话,对于软件而言,当功能足够小的时候,可以放在同一个类中。可当功能随着发展,细节就会增加,类就会显得臃肿。我们应当在嗅到代码的坏味道时,重新对功能进行审视,进行必要的新的抽象,大胆定义新的组件,以满足新的业务诉求。
Spring Security的设计
按照“高内聚低耦合”原则,我们应当把RememberMe相关的功能都放在同一个组件里。SpringSecurity则设计了两层结构。首先是RememberMeServices接口:
public interface RememberMeServices {
// 自动登录。这自然是与session超时之后,从cookie中读取凭证自动恢复登录态有关
Authentication autoLogin(HttpServletRequest request, HttpServletResponse response);
// 登录失败的处理。要是自动登录失败了,那必须把cookie清理了哇
void loginFail(HttpServletRequest request, HttpServletResponse response);
// 登录成功。那就是要生成RememberMe凭证并丢到cookie了。
void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication);
}
除了自动登录(基于RememberMe的凭证),还有与认证过滤器配合的登录成功与登录失败的处理(涉及凭证的生成与清理)。
这第二层便是AbstractRememberMeServices
public abstract class AbstractRememberMeServices
implements RememberMeServices, InitializingBean, LogoutHandler, MessageSourceAware {
@Override
public Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
// 寻找目标cookie
String rememberMeCookie = extractRememberMeCookie(request);
if (rememberMeCookie == null) {
return null;
}
if (rememberMeCookie.length() == 0) {
// 清理重置cookie
cancelCookie(request, response);
return null;
}
try {
// 解析凭证
String[] cookieTokens = decodeCookie(rememberMeCookie);
// 通过凭证处理自动登录-这是抽象方法,由子类实现
UserDetails user = processAutoLoginCookie(cookieTokens, request, response);
// 检查用户状态
this.userDetailsChecker.check(user);
// 创建登录成功的认证信息
return createSuccessfulAuthentication(request, user);
}
catch (CookieTheftException ex) {
// 被攻击了,清理cookie
cancelCookie(request, response);
throw ex;
}
catch (UsernameNotFoundException ex) {
// 没解析到用户
}
catch (InvalidCookieException ex) {
// cookie已失效
}
catch (AccountStatusException ex) {
// 用户状态异常
}
catch (RememberMeAuthenticationException ex) {
// 登录异常
}
// 清理cookie
cancelCookie(request, response);
// 返回空,意味着通过rememberMe登录失败了。交由原来的登录过滤器处理。
return null;
}
@Override
public void loginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
// 检查是否勾选了rememberMe
if (!rememberMeRequested(request, this.parameter)) {
this.logger.debug("Remember-me login not requested.");
return;
}
// 完成登录后的处理。这是原来的登录认证后的操作。需要为止生成凭证。这是个抽象方法,由子类实现
onLoginSuccess(request, response, successfulAuthentication);
}
@Override
public void loginFail(HttpServletRequest request, HttpServletResponse response) {
// 重置cookie
cancelCookie(request, response);
// 登录失败后处理。这是个空方法,也是个钩子方法。不过目前子类都没有使用到。
onLoginFail(request, response);
}
@Override
public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
// 清理保存凭证的cookie
cancelCookie(request, response);
}
protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request, HttpServletResponse response) {
String cookieValue = encodeCookie(tokens);
Cookie cookie = new Cookie(this.cookieName, cookieValue);
cookie.setMaxAge(maxAge);
cookie.setPath(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());
// 设置了httpOnly后,js脚本将无法读取到cookie信息
// {@link https://cloud.tencent.com/developer/article/2097036}
cookie.setHttpOnly(true)