Spring Security 入门(三):Remember-Me 和注销登录

时间:2024-03-03 19:26:17

本文在前文 Spring Security 入门(二):图形验证码和手机短信验证码 的基础上介绍 Remember-Me 功能和注销登录。

Remember-Me 功能

在实际开发中,为了用户登录方便常常会启用记住我(Remember-Me)功能。如果用户登录时勾选了“记住我”选项,那么在一段有效时间内,会默认自动登录,免去再次输入用户名、密码等登录操作。该功能的实现机理是根据用户登录信息生成 Token 并保存在用户浏览器的 Cookie 中,当用户需要再次登录时,自动实现校验并建立登录态的一种机制。

Spring Security提供了两种 Remember-Me 的实现方式:

  • 简单加密 Token:用散列算法加密用户必要的登录系信息并生成 Token 令牌。
  • 持久化 Token:数据库等持久性数据存储机制用的持久化 Token 令牌。

基本原理

Remember-Me 功能的开启需要在configure(HttpSecurity http)方法中通过http.rememberMe()配置,该配置主要会在过滤器链中添加 RememberMeAuthenticationFilter 过滤器,通过该过滤器实现自动登录。该过滤器的位置在其它认证过滤器之后,其它认证过滤器没有进行认证处理时,该过滤器尝试工作:

注意:Remember-Me 功能是用于再次登录(认证)的,而不是再次请求。工作流程如下:

  • 当用户成功登录认证后,浏览器中存在两个 Cookie,一个是 remember-me,另一个是 JSESSIONID。用户再次请求访问时,请求首先被 SecurityContextPersistenceFilter 过滤器拦截,该过滤器会根据 JSESSIONID 获取对应 Session 中存储的 SecurityContext 对象。如果获取到的 SecurityContext 对象中存储了认证用户信息对象 Authentiacaion,也就是说线程可以直接获得认证用户信息,那么后续的认证过滤器不需要对该请求进行拦截,remember-me 不起作用。
  • 当 JSESSIONID 过期后,浏览器中只存在 remember-me 的 Cookie。用户再次请求访问时,由于请求没有携带 JSESSIONID,SecurityContextPersistenceFilter 过滤器无法获取 Session 中的 SecurityContext 对象,也就没法获得认证用户信息,后续需要进行登录认证。如果没有 remember-me 的 Cookie,浏览器会重定向到登录页面进行表单登录认证;但是 remember-me 的 Cookie 存在,RememberMeAuthenticationFilter 过滤器会将请求进行拦截,根据 remember-me 存储的 Token 值实现自动登录,并将成功登录后的认证用户信息对象 Authentiacaion 存储到 SecurityContext 中。当响应返回时,SecurityContextPersistenceFilter 过滤器会将 SecurityContext 存储在 Session 中,下次请求又通过 JSEESIONID 获取认证用户信息。

总结:remember-me 只有在 JSESSIONID 失效和前面的过滤器认证失败或者未进行认证时才发挥作用。此时,只要 remember-me 的 Cookie 不过期,我们就不需要填写登录表单,就能实现再次登录,并且 remember-me 自动登录成功之后,会生成新的 Token 替换旧的 Token,相应 Cookie 的 Max-Age 也会重置。

此处对http.rememberMe()返回值的主要方法进行说明,这些方法涉及 Remember-Me 配置,具体如下:

  • rememberMeParameter(String rememberMeParameter):指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
  • key(String key):“记住我”的 Token 中的标识字段,默认是一个随机的 UUID 值。
  • tokenValiditySeconds(int tokenValiditySeconds):“记住我” 的 Token 令牌有效期,单位为秒,即对应的 cookie 的 Max-Age 值,默认时间为 2 周。
  • userDetailsService(UserDetailsService userDetailsService):指定 Remember-Me 功能自动登录过程使用的 UserDetailsService 对象,默认使用 Spring 容器中的 UserDetailsService 对象.
  • tokenRepository(PersistentTokenRepository tokenRepository):指定 TokenRepository 对象,用来配置持久化 Token。
  • alwaysRemember(boolean alwaysRemember):是否应该始终创建记住我的 Token,默认为 false。
  • useSecureCookie(boolean useSecureCookie):是否设置 Cookie 为安全,如果设置为 true,则必须通过 https 进行连接请求。

简单加密 Token(基本使用)

在用户选择“记住我”登录并成功认证后,Spring Security将默认会生成一个名为 remember-me 的 Cookie 存储 Token 并发送给浏览器;用户注销登录后,该 Cookie 的 Max-Age 会被设置为 0,即删除该 Cookie。Token 值由下列方式组合而成:

base64(username + ":" + expirationTime + ":" +
	   md5Hex(username + ":" + expirationTime + ":" + password + ":" + key))

其中,username 代表用户名;password 代表用户密码;expirationTime 表示记住我的 Token 的失效日期,以毫秒为单位;key 表示防止修改 Token 的标识,默认是一个随机的 UUID 值。具体使用如下:

☕️ 修改 login.html 和 login-mobile.html,在登录表单中添加“记住我”选项

<div><input name="remember-me" type="checkbox">记住我</div>

以 login.html 为例:

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <h3>表单登录</h3>
    <form method="post" th:action="@{/login/form}">
        <input type="text" name="name" placeholder="用户名"><br>
        <input type="password" name="pwd" placeholder="密码"><br>

        <input name="imageCode" type="text" placeholder="验证码"><br>
        <img th:onclick="this.src=\'/code/image?\'+Math.random()" th:src="@{/code/image}" alt="验证码"/><br>
        <div th:if="${param.error}">
            <span th:text="${session.SPRING_SECURITY_LAST_EXCEPTION.message}" style="color:red">用户名或密码错误</span>
        </div>
        <div><input name="remember-me" type="checkbox">记住我</div>
        <button type="submit">登录</button>
    </form>
</body>
</html>

☕️ 修改安全配置类 SpringSecurityConfig

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    private CustomUserDetailsService userDetailsService;
    //...
    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 开启 Remember-Me 功能
        http.rememberMe()
                // 指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
                .rememberMeParameter("remember-me")
                // 设置 Token 有效期为 200s,默认时长为 2 星期
                .tokenValiditySeconds(200)
            	// 指定 UserDetailsService 对象
                .userDetailsService(userDetailsService);

        // 开启注销登录功能
        http.logout()
                // 用户注销登录时访问的 url,默认为 /logout
                .logoutUrl("/logout")
                // 用户成功注销登录后重定向的地址,默认为 loginPage() + ?logout
                .logoutSuccessUrl("/login/page?logout");            
    }
    //...
}

☕️ 测试

访问localhost:8080/login/page,输入正确用户名、密码和验证码,并勾选上“记住我”进行登录:

成功登录认证后,在返回的响应头中可以找到 key 为 JSESSIONID 的 Cookie,生命周期为浏览器关闭时就删除;key 为 remember-me 的 Cookie,Max-age 为 200 秒:

访问localhost:8080/logout,注销登录,在返回的响应头中可以找到 remember-me 的 Cookie,Max-Age 被设置为 0,即删除该 Cookie:


简单加密 Token(源码分析)

首次登录

⭐️ AbstractAuthenticationProcessingFilter#successfulAuthentication

public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware {
    //...
    // 认证成功后的处理
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Authentication success. Updating SecurityContextHolder to contain: " + authResult);
        }

        //(1) 将认证成功的用户信息对象 Authentication 封装进 SecurityContext 对象中,并存入 SecurityContextHolder;
        SecurityContextHolder.getContext().setAuthentication(authResult);
        //(2) rememberMe 的处理
        this.rememberMeServices.loginSuccess(request, response, authResult);
        if (this.eventPublisher != null) {
            //(3) 发布认证成功的事件
            this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
        }
	//(4) 调用认证成功处理器
        this.successHandler.onAuthenticationSuccess(request, response, authResult);
    }
    //...
}

用户登录后,在成功认证处理时,上述(2)过程会调用 AbstractRememberMeServices 的 loginSuccess() 方法进行 Remember-Me 处理。

⭐️ AbstractRememberMeServices#loginSuccess

public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
    //...
    public final void loginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
        //(1) 判断 Request 请求中是否携带了 remember-me 参数,且参数值为 true/on/yes/1
        if (!this.rememberMeRequested(request, this.parameter)) {
            this.logger.debug("Remember-me login not requested.");
        } else {
            //(2) 本类的 onLoginSuccess() 方法是个抽象方法,所以实际调用的是子类
            // TokenBasedRememberMeServices 重写的 onLoginSuccess() 方法
            this.onLoginSuccess(request, response, successfulAuthentication);
        }
    }
}

⭐ TokenBasedRememberMeServices#onLoginSuccess

public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
    //...
    public void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
        //(1) 获取用户名、密码
        String username = this.retrieveUserName(successfulAuthentication);
        String password = this.retrievePassword(successfulAuthentication);
        if (!StringUtils.hasLength(username)) {
            this.logger.debug("Unable to retrieve username");
        } else {
            if (!StringUtils.hasLength(password)) {
                //(2) 通过 UserDetailsService 对象从数据库中查询对应 User的信息
                UserDetails user = this.getUserDetailsService().loadUserByUsername(username);
                password = user.getPassword();
                if (!StringUtils.hasLength(password)) {
                    this.logger.debug("Unable to obtain password for user: " + username);
                    return;
                }
            }
	    //(3) 获取 Token 的生命周期,默认为 1209600s(两周)
            int tokenLifetime = this.calculateLoginLifetime(request, successfulAuthentication);
            long expiryTime = System.currentTimeMillis();
            //(4) 获取 Token 的过期时间 
            expiryTime += 1000L * (long)(tokenLifetime < 0 ? 1209600 : tokenLifetime);
            //(5) 计算并获取 Token 值
            String signatureValue = this.makeTokenSignature(expiryTime, username, password);
            //(6) 设置 Cookie,将 Token 传递给浏览器
            this.setCookie(new String[]{username, Long.toString(expiryTime), signatureValue}, tokenLifetime, request, response);
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Added remember-me cookie for user \'" + username + "\', expiry: \'" + new Date(expiryTime) + "\'");
            }
        }
    }
    //...
}

二次登陆

✏️ RememberMeAuthenticationFilter#doFilter

public class RememberMeAuthenticationFilter extends GenericFilterBean implements ApplicationEventPublisherAware {
    //...
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)req;
        HttpServletResponse response = (HttpServletResponse)res;
        //(1) 判断当前线程的 SecurityContext 对象是否存储 Authentication 对象;
        // 如果存在,意味着当前线程已经获取了用户信息,不需要再次进行登录
        if (SecurityContextHolder.getContext().getAuthentication() == null) {
            //(2) 当前线程没有对应用户信息,调用 AbstractRememberMeServices 类的 autoLogin() 方法进行自动登录,获取用户信息
            Authentication rememberMeAuth = this.rememberMeServices.autoLogin(request, response);
            if (rememberMeAuth != null) {
                // 获取用户信息成功
                try {
                    //(3) 调用 ProviderManager 实现类的 authenticate() 方法进行身份认证
                    rememberMeAuth = this.authenticationManager.authenticate(rememberMeAuth);
                    //(4) 认证成功后,将 Authentication 对象存储当前线程的 SecurityContext 
                    SecurityContextHolder.getContext().setAuthentication(rememberMeAuth);
                    //(5) 调用本类的认证成功处理,是一个空方法
                    this.onSuccessfulAuthentication(request, response, rememberMeAuth);
                    if (this.logger.isDebugEnabled()) {
                        this.logger.debug("SecurityContextHolder populated with remember-me token: \'" + SecurityContextHolder.getContext().getAuthentication() + "\'");
                    }

                    if (this.eventPublisher != null) {
                        //(6) 发布认证成功的事件
                        this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(SecurityContextHolder.getContext().getAuthentication(), this.getClass()));
                    }
					
                    if (this.successHandler != null) {
                        //(7) 调用认证成功的处理器
                        this.successHandler.onAuthenticationSuccess(request, response, rememberMeAuth);
                        return;
                    }
                } catch (AuthenticationException var8) {
                    if (this.logger.isDebugEnabled()) {
                        this.logger.debug("SecurityContextHolder not populated with remember-me token, as AuthenticationManager rejected Authentication returned by RememberMeServices: \'" + rememberMeAuth + "\'; invalidating remember-me token", var8);
                    }
		    // 认证失败后的处理
                    this.rememberMeServices.loginFail(request, response);
                    this.onUnsuccessfulAuthentication(request, response, var8);
                }
            }

            chain.doFilter(request, response);
        } else {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("SecurityContextHolder not populated with remember-me token, as it already contained: \'" + SecurityContextHolder.getContext().getAuthentication() + "\'");
            }

            chain.doFilter(request, response);
        }
    }    
}

上述的(2)过程调用 AbstractRememberMeServices 的 autoLogin() 方法实现自动登录,获取用户信息。

✏️ AbstractRememberMeServices#autoLogin

public abstract class AbstractRememberMeServices implements RememberMeServices, InitializingBean, LogoutHandler {
    //...
    public final Authentication autoLogin(HttpServletRequest request, HttpServletResponse response) {
        //(1) 从 request 中获取 remember-me 对应的 cookie 值
        String rememberMeCookie = this.extractRememberMeCookie(request);
        if (rememberMeCookie == null) {
            return null;
        } else {
            this.logger.debug("Remember-me cookie detected");
            if (rememberMeCookie.length() == 0) {
                this.logger.debug("Cookie was empty");
                this.cancelCookie(request, response);
                return null;
            } else {
                UserDetails user = null;

                try {
                    //(2) 对 cookie 值进行 Base64 解码获取 series 和 token 字段
                    String[] cookieTokens = this.decodeCookie(rememberMeCookie);
                    //(3) 获取 UserDetails,本类的 procssAutoLoginCookie() 方法是一个抽象方法,
                    // 所以实际调用的是子类 TokenBasedRememberMeServices 重写的方法
                    user = this.procssAutoLoginCookie(cookieTokens, request, response);
                    //(4) 检查用户账号是否锁定、是否可用、是否过期
                    this.userDetailsChecker.check(user);
                    this.logger.debug("Remember-me cookie accepted");
                    //(5) 将 UserDetails 对象封装到 Authentication 对象里,并返回
                    return this.createSuccessfulAuthentication(request, user);
                } catch (CookieTheftException var6) {
	            //...
                }
		// 获取用户信息类对象 UserDetails 失败,删除 remember-me 对应的 cookie
                this.cancelCookie(request, response);
                return null;
            }
        }
    }
}

上述的(3)过程调用 TokenBasedRememberMeServices 的 procssAutoLoginCookie() 方法获取用户信息。

✏️ TokenBasedRememberMeServices#processAutoLoginCookie

public class TokenBasedRememberMeServices extends AbstractRememberMeServices {
    //...
    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) + "\'");
        } else {
            long tokenExpiryTime;
            try {
                //(1) 获取 Token 过期时间
                tokenExpiryTime = new Long(cookieTokens[1]);
            } catch (NumberFormatException var8) {
                throw new InvalidCookieException("Cookie token[1] did not contain a valid number (contained \'" + cookieTokens[1] + "\')");
            }
            //(2) 判断 Token 是否过期
            if (this.isTokenExpired(tokenExpiryTime)) {
                throw new InvalidCookieException("Cookie token[1] has expired (expired on \'" + new Date(tokenExpiryTime) + "\'; current time is \'" + new Date() + "\')");
            } else {
                //(3) 通过 UserDetailsService 对象获取对应用户信息 UserDetails
                UserDetails userDetails = this.getUserDetailsService().loadUserByUsername(cookieTokens[0]);
                Assert.notNull(userDetails, () -> {
                    return "UserDetailsService " + this.getUserDetailsService() + " returned null for username " + cookieTokens[0] + ". This is an interface contract violation";
                });
                //(4) 比较 Token 中信息是否和预期的一样,即判断 Token 是否合法
                String expectedTokenSignature = this.makeTokenSignature(tokenExpiryTime, userDetails.getUsername(), userDetails.getPassword());
                if (!equals(expectedTokenSignature, cookieTokens[2])) {
                    throw new InvalidCookieException("Cookie token[2] contained signature \'" + cookieTokens[2] + "\' but expected \'" + expectedTokenSignature + "\'");
                } else {
                    //(5) 返回用户信息 UserDetails
                    return userDetails;
                }
            }
        }
    }
    //...
}

持久化 Token(原理分析)

在用户选择“记住我”成功登录认证后,默认会生成一个名为 remember-me 的 Cookie 储存 Token,并发送给浏览器,具体实现流程如下:

  1. 用户选择“记住我”功能成功登录认证后,Spring Security会把用户名 username、序列号 series、令牌值 token 和最后一次使用自动登录的时间 last_used 作为一条 Token 记录存入数据库表中,同时生成一个名为 remember-me 的 Cookie 存储series:token的 base64 编码,该编码为发送给浏览器的 Token。
  2. 当用户需要再次登录时,RememberMeAuthenticationFilter 过滤器首先会检查请求是否有 remember-me 的 Cookie。如果存在,则检查其 Token 值中的 series 和 token 字段是否与数据库中的相关记录一致,一致则通过验证,并且系统重新生成一个新 token 值替换数据库中对应记录的旧 token,该记录的序列号 series 保持不变,认证时间 last_used 更新,同时重新生成新的 Token(旧 series : 新 token)通过 Cookie 发送给浏览器,remember-me 的 Cookie 的 Max-Age 也因此重置。
  3. 上述验证通过后,获取数据库中对应 Token 记录的 username 字段,调用 UserDetailsService 获取用户信息。之后进行登录认证,认证成功后将认证用户信息 Authentication 对象存入 SecurityContext。
  4. 如果对应的 Cookie 值包含的 token 字段与数据库中对应 Token 记录的 token 字段不匹配,则有可能是用户的 Cookie 被盗用,这时将会删除数据库中与当前用户相关的所有 Token 记录,用户需要重新进行表单登录。
  5. 如果对应的 Cookie 不存在,或者其值包含的 series 和 token 字段与数据库中的记录不匹配,则用户需要重新进行表单登录。如果用户退出登录,则删除数据库中对应的 Token 记录,并将相应的 Cookie 的 Max-Age 设置为 0。

在实现上,Spring Security使用 PersistentRememberMeToken 来表明一个验证实体:

public class PersistentRememberMeToken {
    private final String username;
    private final String series;
    private final String tokenValue;
    // 最后一个使用自动登录的时间
    private final Date date;
    //...
}

对应的,在数据库需要有一张 persistent_logins 表(存储自动登录信息的表),表结构如下:

CREATE TABLE `persistent_logins` (
  `username` varchar(64) NOT NULL,
  `series` varchar(64) PRIMARY KEY,
  `token` varchar(64) NOT NULL,
  `last_used` timestamp NOT NULL
);

由于需要使用持久化 Token 方案,所以需要定制 tokenRepository,用于与数据库表的交互。为此,我们需要创建一个 PersistentTokenRepository 实例,该实例中定义了持久化令牌的一些必要方法:

public interface PersistentTokenRepository {
    void createNewToken(PersistentRememberMeToken var1);

    void updateToken(String var1, String var2, Date var3);

    PersistentRememberMeToken getTokenForSeries(String var1);

    void removeUserTokens(String var1);
}

我们可以自定义实现 PersistentTokenRepository 接口,也可以使用Spring Security提供的 JDBC 方案实现:

public class JdbcTokenRepositoryImpl extends JdbcDaoSupport implements PersistentTokenRepository {
    public static final String CREATE_TABLE_SQL = "create table persistent_logins (username varchar(64) not null, series varchar(64) primary key, token varchar(64) not null, last_used timestamp not null)";
    public static final String DEF_TOKEN_BY_SERIES_SQL = "select username,series,token,last_used from persistent_logins where series = ?";
    public static final String DEF_INSERT_TOKEN_SQL = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
    public static final String DEF_UPDATE_TOKEN_SQL = "update persistent_logins set token = ?, last_used = ? where series = ?";
    public static final String DEF_REMOVE_USER_TOKENS_SQL = "delete from persistent_logins where username = ?";
    private String tokensBySeriesSql = "select username,series,token,last_used from persistent_logins where series = ?";
    private String insertTokenSql = "insert into persistent_logins (username, series, token, last_used) values(?,?,?,?)";
    private String updateTokenSql = "update persistent_logins set token = ?, last_used = ? where series = ?";
    private String removeUserTokensSql = "delete from persistent_logins where username = ?";
    //...
}

持久化 Token(基本使用)