什么是RememberMe?
RememberMe 是一种服务器端的行为。传统的登录方式基于 Session会话,一旦用户的会话超时过期,就要再次登录,这样太过于烦琐。如果能有一种机制,让用户会话过期之后,还能继续保持认证状态,就会方便很多,RememberMe 就是为了解决这一需求而生的。
原本的交互流程是,用户登录了之后会将用户的信息保存在服务端的session中,并且返回客户端一个jsessionid作为一个标识,默认过期时间是30分钟,30min没有进行任何操作,时间到了之后就会过期。也可以在application.yml自定义过期时间
server:
servlet:
session:
timeout: 1
具体的实现思路就是通过 Cookie 来记录当前用户身份。当用户登录成功之后,会通过一定算法,将用户信息、时间戳等进行加密,加密完成后,通过响应头带回前端存储在cookie中,当浏览器会话过期之后,如果再次访问该网站,会自动将 Cookie 中的信息发送给服务器,服务器对 Cookie中的信息进行校验分析,进而确定出用户的身份,Cookie中所保存的用户信息也是有时效的,例如三天、一周等。
SS中RememberMe的使用
SS中开启
这个时候如果我们没有自定义登录界面的话,内部的默认的登录界面会出现一个RememberMe的下标
如果我们自定义了登录界面那么就需要编写rememberme,这里看一下别人的源码
这里有一个小技巧,它这里先自定义了一个常量然后把常量赋值给变量,这也我们就既可以自己设置值,不设置的话也会有默认值,比较灵活。
这里面有一个方法
private void initDefaultLoginFilter(H http) {
DefaultLoginPageGeneratingFilter loginPageGeneratingFilter = http
.getSharedObject(DefaultLoginPageGeneratingFilter.class);
if (loginPageGeneratingFilter != null) {
loginPageGeneratingFilter.setRememberMeParameter(getRememberMeParameter());
}
}
看一下DefaultLoginPageGeneratingFilter
这个类
String contextPath = request.getContextPath();
if (this.formLoginEnabled) {
sb.append(" <form class=\"form-signin\" method=\"post\" action=\"" + contextPath + this.authenticationUrl + "\">\n"
+ " <h2 class=\"form-signin-heading\">Please sign in</h2>\n"
+ createError(loginError, errorMsg)
+ createLogoutSuccess(logoutSuccess)
+ " <p>\n"
+ " <label for=\"username\" class=\"sr-only\">Username</label>\n"
+ " <input type=\"text\" id=\"username\" name=\"" + this.usernameParameter + "\" class=\"form-control\" placeholder=\"Username\" required autofocus>\n"
+ " </p>\n"
+ " <p>\n"
+ " <label for=\"password\" class=\"sr-only\">Password</label>\n"
+ " <input type=\"password\" id=\"password\" name=\"" + this.passwordParameter + "\" class=\"form-control\" placeholder=\"Password\" required>\n"
+ " </p>\n"
+ createRememberMe(this.rememberMeParameter)
+ renderHiddenInputs(request)
+ " <button class=\"btn btn-lg btn-primary btn-block\" type=\"submit\">Sign in</button>\n"
+ " </form>\n");
}
这里面拼接了这部分代码
所以我在自己的登录界面里面加上
<input type='checkbox' name='remember-me'>记住我
<br>
即可。
RememberMe的实现原理
开启了记住我的功能之后,在密码验证的过程中,会进入UsernamePasswordAuthenticationFilter
的这个方法:
public Authentication attemptAuthentication(HttpServletRequest request,
HttpServletResponse response) throws AuthenticationException {
if (postOnly && !request.getMethod().equals("POST")) {
throw new AuthenticationServiceException(
"Authentication method not supported: " + request.getMethod());
}
String username = obtainUsername(request);
String password = obtainPassword(request);
if (username == null) {
username = "";
}
if (password == null) {
password = "";
}
username = username.trim();
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
username, password);
// Allow subclasses to set the "details" property
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
这个方法是在它的父类AbstractAuthenticationProcessingFilter
中调用的
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) res;
if (!requiresAuthentication(request, response)) {
chain.doFilter(request, response);
return;
}
if (logger.isDebugEnabled()) {
logger.debug("Request is to process authentication");
}
Authentication authResult;
try {
//这里调用前面的方法认证之后就是一个具有完整的用户信息的对象
authResult = attemptAuthentication(request, response);
if (authResult == null) {
// return immediately as subclass has indicated that it hasn't completed
// authentication
return;
}
sessionStrategy.onAuthentication(authResult, request, response);
}
catch (InternalAuthenticationServiceException failed) {
logger.error(
"An internal error occurred while trying to authenticate the user.",
failed);
unsuccessfulAuthentication(request, response, failed);
return;
}
catch (AuthenticationException failed) {
// Authentication failed
unsuccessfulAuthentication(request, response, failed);
return;
}
// Authentication success
if (continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authResult);
}
在认证成功之后调用,可以看到这里是将用户的信息保存在SecurityContextHolder
中。
protected void successfulAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Authentication authResult)
throws IOException, ServletException {
if (logger.isDebugEnabled()) {
logger.debug("Authentication success. Updating SecurityContextHolder to contain: "
+ authResult);
}
SecurityContextHolder.getContext().setAuthentication(authResult);
rememberMeServices.loginSuccess(request, response, authResult);
// Fire event
if (this.eventPublisher != null) {
eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(
authResult, this.getClass()));
}
successHandler.onAuthenticationSuccess(request, response, authResult);
}
上面的代码中调用了loginSuccess
,
@Override
public final void loginSuccess(HttpServletRequest request,
HttpServletResponse response, Authentication successfulAuthentication) {
if (!rememberMeRequested(request, parameter)) {
logger.debug("Remember-me login not requested.");
return;
}
onLoginSuccess(request, response, successfulAuthentication);
}
它又调用了抽象方法onLoginSuccess
,具体实现是TokenBasedRememberMeServices
@Override
public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication successfulAuthentication) {
String username = retrieveUserName(successfulAuthentication);
String password = retrievePassword(successfulAuthentication);
// If unable to find a username and password, just abort as
// TokenBasedRememberMeServices is
// unable to construct a valid token in this case.
if (!StringUtils.hasLength(username)) {
logger.debug("Unable to retrieve username");
return;
}
if (!StringUtils.hasLength(password)) {
UserDetails user = getUserDetailsService().loadUserByUsername(username);
password = user.getPassword();
if (!StringUtils.hasLength(password)) {
logger.debug("Unable to obtain password for user: " + username);
return;
}
}
int tokenLifetime = calculateLoginLifetime(request, successfulAuthentication);
long expiryTime = System.currentTimeMillis();
// SEC-949
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);
if (logger.isDebugEnabled()) {
logger.debug("Added remember-me cookie for user '" + username
+ "', expiry: '" + new Date(expiryTime) + "'");
}
}
这部分代码就是生成cookie,返回给前端
protected void setCookie(String[] tokens, int maxAge, HttpServletRequest request,
HttpServletResponse response) {
String cookieValue = encodeCookie(tokens);
Cookie cookie = new Cookie(cookieName, cookieValue);
cookie.setMaxAge(maxAge);
cookie.setPath(getCookiePath(request));
if (cookieDomain != null) {
cookie.setDomain(cookieDomain);
}
if (maxAge < 1) {
cookie.setVersion(1);
}
if (useSecureCookie == null) {
cookie.setSecure(request.isSecure());
}
else {
cookie.setSecure(useSecureCookie);
}
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
后面如果会话过期,用户之前勾选了记住我功能,就会被RememberMeAuthenticationFilter
捕获
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) {
// Attempt authenticaton via AuthenticationManager
try {
rememberMeAuth = authenticationManager.authenticate(rememberMeAuth);
// Store to SecurityContextHolder
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方法中就会解析cookie
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 {
/**
Decodes the cookie and splits it into a set of token strings using the ":" delimiter.
Params:
cookieValue – the value obtained from the submitted cookie
Returns:
the array of tokens.
Throws:
InvalidCookieException – if the cookie was not base64 encoded.
*/
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) {
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;
}
在方法中进行比较签名,如果相同说明用户是认证用户。
@Override
protected UserDetails processAutoLoginCookie(String[] cookieTokens,
HttpServletRequest request, HttpServletResponse response) {
if (cookieTokens.length != 3) {
throw new InvalidCookieException("Cookie token did not contain 3"
+ " tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
}
long tokenExpiryTime;
try {
tokenExpiryTime = new Long(cookieTokens[1]);
}
catch (NumberFormatException nfe) {
throw new InvalidCookieException(
"Cookie token[1] did not contain a valid number (contained '"
+ cookieTokens[1] + "')");
}
if (isTokenExpired(tokenExpiryTime)) {
throw new InvalidCookieException("Cookie token[1] has expired (expired on '"
+ new Date(tokenExpiryTime) + "'; current time is '" + new Date()
+ "')");
}
// Check the user exists.去数据库检验用户是否存在
// Defer lookup until after expiry time checked, to possibly avoid expensive
// database call.
UserDetails userDetails = getUserDetailsService().loadUserByUsername(
cookieTokens[0]);
Assert.notNull(userDetails, () -> "UserDetailsService " + getUserDetailsService()
+ " returned null for username " + cookieTokens[0] + ". "
+ "This is an interface contract violation");
// Check signature of token matches remaining details.
// Must do this after user lookup, as we need the DAO-derived password.
// If efficiency was a major issue, just add in a UserCache implementation,
// but recall that this method is usually only called once per HttpSession - if
// the token is valid,
// it will cause SecurityContextHolder population, whilst if invalid, will cause
// the cookie to be cancelled.
String expectedTokenSignature = makeTokenSignature(tokenExpiryTime,
userDetails.getUsername(), userDetails.getPassword());
if (!equals(expectedTokenSignature, cookieTokens[2])) {
throw new InvalidCookieException("Cookie token[2] contained signature '"
+ cookieTokens[2] + "' but expected '" + expectedTokenSignature + "'");
}
return userDetails;
}
完整的实现流程如下:
首先如果用户登陆的时候勾选了记住我的功能,系统就会根据用户的用户名密码以及过期时间计算一个签名,这个签名使用MD5消息摘要算法生成,不可逆。然后将用户名、过期时间、签名拼接成一个字符串,使用 : 分割,然后将这个编码之后的结果返回给前端,当会话过期之后浏览器会带上这个cookie中的令牌,然后进行Base64解码,解码之后提取令牌中的三个数据,如果没有过期则根据用户名查询用户的数据,然后计算签名,和令牌中的签名进行对比,如果相同,则可以成功登录。
最后记录一下自己遇到的问题:
由于我已经自定义了一个类用来实现验证码的验证逻辑,这个类继承UsernamePasswordAuthenticationFilter
package com.dongmu.filter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@EqualsAndHashCode(callSuper = true)
@Data
public class KaptchaFilter extends UsernamePasswordAuthenticationFilter {
private static final String FROM_KAPTCHA = "kaptcha";
private String kaptchaName = FROM_KAPTCHA;
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
String attribute = (String) request.getSession().getAttribute(FROM_KAPTCHA);
if (attribute!=null&&attribute.equalsIgnoreCase(request.getParameter(getKaptchaName()))){
return super.attemptAuthentication(request, response);
}else {
throw new AuthenticationException("验证码错误,请重新输入!") {
@Override
public String getMessage() {
return super.getMessage();
}
};
}
}
}
然后放入过滤器链中http.addFilterAt(kaptchaFilter(), UsernamePasswordAuthenticationFilter.class);
导致rememberMe一致不生效,经过debug源码发现在返回给前端一个cookie的时候并没有走TokenBasedRememberMeServices
的实现而是NullRememberMeServices
,这是个空方法导致内部没有生成cookie
,因此解决方式如下:
@Bean
protected TokenBasedRememberMeServices tokenBasedRememberMeServices(){
return new TokenBasedRememberMeServices(UUID.randomUUID().toString(),myUserDetailService);
}
@Bean
// @DependsOn("myAuthenticationHandler")
public KaptchaFilter kaptchaFilter() throws Exception {
//自定义UsernamePasswordAuthenticationFilter之后这些属性要重新设置
KaptchaFilter kaptchaFilter = new KaptchaFilter();
kaptchaFilter.setFilterProcessesUrl("/login");
kaptchaFilter.setUsernameParameter("username");
kaptchaFilter.setPasswordParameter("password");
//把这个bean注入进入才能使rememberMe生效
kaptchaFilter.setRememberMeServices(tokenBasedRememberMeServices());
kaptchaFilter.setAuthenticationManager(authenticationManager());
kaptchaFilter.setAuthenticationSuccessHandler(myAuthenticationHandler());
kaptchaFilter.setAuthenticationFailureHandler(myAuthenticationHandler());
return kaptchaFilter;
}
这个为什么要设置成同一个我测试的时候发现如果是两个key不一样的tokenBasedRememberMeServices
,生成的签名是不一样的,导致验证失败,还是需要重写登录。