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

本文在前文 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(基本使用)

📚 创建数据库表 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
);

📚 修改安全配置类 SpringSecurityConfig,使用持久化 Token 方式

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private DataSource dataSource;  // 数据源

    /**
     * 配置 JdbcTokenRepositoryImpl,用于 Remember-Me 的持久化 Token
     */
    @Bean
    public JdbcTokenRepositoryImpl tokenRepository() {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        // 配置数据源
        jdbcTokenRepository.setDataSource(dataSource);
        // 第一次启动的时候可以使用以下语句自动建表(可以不用这句话,自己手动建表,源码中有语句的)
        // jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }
    //...
    
    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 开启 Remember-Me 功能
        http.rememberMe()
                // 指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
                .rememberMeParameter("remember-me")
                // 设置 Token 有效期为 200s,默认时长为 2 星期
                .tokenValiditySeconds(200)
                // 设置操作数据表的 Repository
                .tokenRepository(tokenRepository())
                // 指定 UserDetailsService 对象
                .userDetailsService(userDetailsService);

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

完整的安全配置类 SpringSecurityConfig 如下:

package com.example.config;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.config.security.mobile.MobileAuthenticationConfig;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import javax.sql.DataSource;

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

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定义认证成功处理器

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器

    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)

    @Autowired
    private MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类

    @Autowired
    private DataSource dataSource;  // 数据源

    /**
     * 配置 JdbcTokenRepositoryImpl,用于 Remember-Me 的持久化 Token
     */
    @Bean
    public JdbcTokenRepositoryImpl tokenRepository() {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        // 配置数据源
        jdbcTokenRepository.setDataSource(dataSource);
        // 第一次启动的时候可以使用以下语句自动建表(可以不用这句话,自己手动建表,源码中有语句的)
        // jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }


    /**
     * 密码编码器,密码不能明文存储
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
        return new BCryptPasswordEncoder();
    }

    /**
     * 定制用户认证管理器来实现用户认证
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 采用内存存储方式,用户认证信息存储在内存中
        // auth.inMemoryAuthentication()
        //        .withUser("admin").password(passwordEncoder()
        //        .encode("123456")).roles("ROLE_ADMIN");
        
        // 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 启动 form 表单登录
        http.formLogin()
                // 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
                .loginPage("/login/page")
                // 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
                .loginProcessingUrl("/login/form")
                // 设置登录表单中的用户名参数,默认为 username
                .usernameParameter("name")
                // 设置登录表单中的密码参数,默认为 password
                .passwordParameter("pwd")
                // 认证成功处理,如果存在原始访问路径,则重定向到该路径;如果没有,则重定向 /index
                //.defaultSuccessUrl("/index")
                // 认证失败处理,重定向到指定地址,默认为 loginPage() + ?error;该路径不设限访问
                //.failureUrl("/login/page?error");
                // 不再使用 defaultSuccessUrl() 和 failureUrl() 方法进行认证成功和失败处理,
                // 使用自定义的认证成功和失败处理器
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler);

        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
                // 以下访问不需要任何权限,任何人都可以访问
                .antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
                // 以下访问需要 ROLE_ADMIN 权限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下访问需要 ROLE_USER 权限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何请求访问都需要先通过认证
                .anyRequest().authenticated();

        // 关闭 csrf 防护
        http.csrf().disable();

        // 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);

        // 将手机短信验证码认证的配置与当前的配置绑定
        http.apply(mobileAuthenticationConfig);

        // 开启 Remember-Me 功能
        http.rememberMe()
                // 指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
                .rememberMeParameter("remember-me")
                // 设置 Token 有效期为 200s,默认时长为 2 星期
                .tokenValiditySeconds(200)
                // 设置操作数据库表的 Repository
                .tokenRepository(tokenRepository())
                // 指定 UserDetailsService 对象
                .userDetailsService(userDetailsService);

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

    /**
     * 定制一些全局性的安全配置,例如:不拦截静态资源的访问
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 静态资源的访问不需要拦截,直接放行
        web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
    }
}

📚 测试

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

首次登录

成功登录认证后,可以在对应的数据表中找到相关 Token 记录:

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

上图中,如果对 remember-me 的 Cookie 值进行 base64 解码,可以发现解码后的字符串就是series:token

二次登录

将浏览器保存的 JSESSIONID 删除,只保留 remember-me 的 Cookie。访问localhost:8080,查看请求头和响应头:

从上图可以看出,请求头中只携带 remember-me 的 Cookie,响应头返回新的 JSESSIONID 和 remember-me 的 Cookie。对比上面两张图,明显可以发现 remember-me 的 Cookie 值改变,并且该 Cookie 的 Max-Age 重置。查看数据库表,可以发现 token 字段改变,last_used 字段更新:

注销登录

访问localhost:8080/logout,注销登录,数据库表中对应的 Token 记录会被删除:

在返回的响应头中可以找到 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() 方法是个抽象方法,所以实际调用的是子类
            // PersistentTokenBasedRememberMeServices 重写的 onLoginSuccess() 方法
            this.onLoginSuccess(request, response, successfulAuthentication);
        }
    }
}

⭐ PersistentTokenBasedRememberMeServices#onLoginSuccess

public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
    //...
    protected void onLoginSuccess(HttpServletRequest request, HttpServletResponse response, Authentication successfulAuthentication) {
        //(1) 获取用户名
        String username = successfulAuthentication.getName();
        this.logger.debug("Creating new persistent login for user " + username);
        //(2) 创建要插入的 Token 记录对应实例 persistentToken
        PersistentRememberMeToken persistentToken = new PersistentRememberMeToken(username, this.generateSeriesData(), this.generateTokenData(), new Date());

        try {
            //(3) 使用 tokenRepository 往数据库表中插入 Token 记录
            this.tokenRepository.createNewToken(persistentToken);
            //(4) 将 Token 记录的 series:token 字段通过 Cookie 发送给用户浏览器
            this.addCookie(persistentToken, request, response);
        } catch (Exception var7) {
            this.logger.error("Failed to save persistent token ", var7);
        }

    }
    //...
}

二次登陆

✏️ 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() 方法是一个抽象方法,
                    // 所以实际调用的是子类 PersistentTokenBasedRememberMeServices 重写的方法
                    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)过程调用 PersistentTokenBasedRememberMeServices 的 procssAutoLoginCookie() 方法获取用户信息。

✏️ PersistentTokenBasedRememberMeServices#processAutoLoginCookie

public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
    //...
    protected UserDetails processAutoLoginCookie(String[] cookieTokens, HttpServletRequest request, HttpServletResponse response) {
        if (cookieTokens.length != 2) {
            throw new InvalidCookieException("Cookie token did not contain 2 tokens, but contained '" + Arrays.asList(cookieTokens) + "'");
        } else {
            //(1) 获取 Cookie 中的 series 和 token 字段
            String presentedSeries = cookieTokens[0];
            String presentedToken = cookieTokens[1];
            //(2) 根据 series 字段从数据库中查询 Token 记录
            PersistentRememberMeToken token = this.tokenRepository.getTokenForSeries(presentedSeries);
            if (token == null) {
                //(3) 没有查询到 Token 记录的处理,抛出异常
                throw new RememberMeAuthenticationException("No persistent token found for series id: " + presentedSeries);
            } else if (!presentedToken.equals(token.getTokenValue())) {
                //(3) 查询到的 Token 记录中的 token 字段和 Cookie 中的字段不同,可能是 Cookie 
                // 被盗用,所以删除数据库表的该用户的所有 Token 记录,并抛出异常
                this.tokenRepository.removeUserTokens(token.getUsername());
                throw new CookieTheftException(this.messages.getMessage("PersistentTokenBasedRememberMeServices.cookieStolen", "Invalid remember-me token (Series/token) mismatch. Implies previous cookie theft attack."));
            } else if (token.getDate().getTime() + (long)this.getTokenValiditySeconds() * 1000L < System.currentTimeMillis()) {
                //(3) Token 过期的处理,抛出异常
                throw new RememberMeAuthenticationException("Remember-me login has expired");
            } else {
                //(3) 查询到正常 Token 记录
                if (this.logger.isDebugEnabled()) {
                    this.logger.debug("Refreshing persistent login token for user '" + token.getUsername() + "', series '" + token.getSeries() + "'");
                }
                //(3.1) 生成新的 Token(token 字段改变,series 字段不变,last_used 更新)
                PersistentRememberMeToken newToken = new PersistentRememberMeToken(token.getUsername(), token.getSeries(), this.generateTokenData(), new Date());

                try {
                    //(3.2) 更新数据库中的 Token 记录 
                    this.tokenRepository.updateToken(newToken.getSeries(), newToken.getTokenValue(), newToken.getDate());
                    //(3.3) 将新 Token 的 series:token 通过 Cookie 发送给用户浏览器
                    this.addCookie(newToken, request, response);
                } catch (Exception var9) {
                    this.logger.error("Failed to update token: ", var9);
                    throw new RememberMeAuthenticationException("Autologin failed due to data access problem");
                }
		//(3.4) 通过 UserDetailsService 对象查询用户信息 UserDetails,并返回
                return this.getUserDetailsService().loadUserByUsername(token.getUsername());
            }
        }
    }
    //...
}

注销登录

注销登录需要在安全配置类的configure(HttpSecurity http) 里使用http.logout()配置,该配置主要会在过滤器链中加入 LogoutFilter 过滤器,Spring Security通过该过滤器实现注销登录功能。

此处对http.logout()返回值的主要方法进行介绍,这些方法设计注销登录的配置,具体如下:

  • logoutUrl(String outUrl):指定用户注销登录时请求访问的地址,默认为 POST 方式的/logout
  • logoutSuccessUrl(String logoutSuccessUrl):指定用户成功注销登录后的重定向地址,默认为/登录页面url?logout
  • logoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler):指定用户成功注销登录后使用的处理器。
  • deleteCookies(String ...cookieNamesToClear):指定用户注销登录后删除的 Cookie。
  • invalidateHttpSession(boolean invalidateHttpSession):指定用户注销登录后是否立即清除用户的 Session,默认为 true。
  • clearAuthentication(boolean clearAuthentication):指定用户退出登录后是否立即清除用户认证信息对象 Authentication,默认为 true。
  • addLogoutHandler(LogoutHandler logoutHandler):指定用户注销登录时使用的处理器。

需要注意,Spring Security默认以 POST 方式请求访问/logout注销登录,以 POST 方式请求的原因是为了防止 csrf(跨站请求伪造),如果想使用 GET 方式的请求,则需要关闭 csrf 防护。前面我们能以 GET 方式的请求注销登录,是因为我们在configure(HttpSecurity http)方法中关闭了 csrf 防护:

@Override
protected void configure(HttpSecurity http) throws Exception {
    // ... 
    http.csrf().disable();  // 关闭 csrf 防护
    // ...
}

默认配置下,成功注销登录后会进行如下三个操作:

  1. 删除用户浏览器中的指定 Cookie。
  2. 将用户浏览器中 remember-me 的 Cookie 删除,并清除用户在数据库中 remember-me 的 Token 记录;
  3. 当前用户的 Session 删除,并清除当前 SecurityContext 中的用户认证信息对象 Authentication。
  4. 通知用户浏览器重定向到/登录页面url?logout

基本使用

📚 自定义成功注销登录处理器

package com.example.config.security;

import com.example.entity.ResultData;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import org.springframework.stereotype.Component;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 继承 SimpleUrlLogoutSuccessHandler 处理器,该类是 logoutSuccessUrl() 方法使用的成功注销登录处理器
 */
@Component
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {

    @Autowired
    private ObjectMapper objectMapper;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException {
        String xRequestedWith = request.getHeader("x-requested-with");
        // 判断前端的请求是否为 ajax 请求
        if ("XMLHttpRequest".equals(xRequestedWith)) {
            // 成功注销登录,响应 JSON 数据
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write(objectMapper.writeValueAsString(new ResultData<>(0, "注销登录成功!")));
        }else {
            // 以下配置等同于在 http.logout() 后配置 logoutSuccessUrl("/login/page?logout")
            
            // 设置默认的重定向路径
            super.setDefaultTargetUrl("/login/page?logout");
            // 调用父类的 onLogoutSuccess() 方法
            super.onLogoutSuccess(request, response, authentication);
        }
    }
}

📚 修改安全配置类 SpringSecurityConfig

@EnableWebSecurity       // 开启 MVC Security 安全配置
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    //...
    @Autowired
    private CustomLogoutSuccessHandler logoutSuccessHandler;  // 自定义成功注销登录处理器
    //...
    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //...
        // 开启注销登录功能
        http.logout()
                // 用户注销登录时访问的 url,默认为 /logout
                .logoutUrl("/logout")
                // 用户成功注销登录后重定向的地址,默认为 loginPage() + ?logout
                //.logoutSuccessUrl("/login/page?logout")
                // 不再使用 logoutSuccessUrl() 方法,使用自定义的成功注销登录处理器
                .logoutSuccessHandler(logoutSuccessHandler)
                // 指定用户注销登录时删除的 Cookie
                .deleteCookies("JSESSIONID")
                // 用户注销登录时是否立即清除用户的 Session,默认为 true
                .invalidateHttpSession(true)
                // 用户注销登录时是否立即清除用户认证信息 Authentication,默认为 true
                .clearAuthentication(true);        
    }
    //...
}

完整的安全配置类 SpringSecurityConfig 如下:

package com.example.config;

import com.example.config.security.CustomAuthenticationFailureHandler;
import com.example.config.security.CustomAuthenticationSuccessHandler;
import com.example.config.security.CustomLogoutSuccessHandler;
import com.example.config.security.ImageCodeValidateFilter;
import com.example.config.security.mobile.MobileAuthenticationConfig;
import com.example.service.CustomUserDetailsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import javax.sql.DataSource;

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

    @Autowired
    private CustomUserDetailsService userDetailsService;

    @Autowired
    private CustomAuthenticationSuccessHandler authenticationSuccessHandler; // 自定义认证成功处理器

    @Autowired
    private CustomAuthenticationFailureHandler authenticationFailureHandler; // 自定义认证失败处理器

    @Autowired
    private ImageCodeValidateFilter imageCodeValidateFilter; // 自定义过滤器(图形验证码校验)

    @Autowired
    private MobileAuthenticationConfig mobileAuthenticationConfig; // 手机短信验证码认证方式的配置类

    @Autowired
    private CustomLogoutSuccessHandler logoutSuccessHandler;  // 自定义成功注销登录处理器

    @Autowired
    private DataSource dataSource;  // 数据源

    /**
     * 配置 JdbcTokenRepositoryImpl,用于 Remember-Me 的持久化 Token
     */
    @Bean
    public JdbcTokenRepositoryImpl tokenRepository() {
        JdbcTokenRepositoryImpl jdbcTokenRepository = new JdbcTokenRepositoryImpl();
        // 配置数据源
        jdbcTokenRepository.setDataSource(dataSource);
        // 第一次启动的时候可以使用以下语句自动建表(可以不用这句话,自己手动建表,源码中有语句的)
        // jdbcTokenRepository.setCreateTableOnStartup(true);
        return jdbcTokenRepository;
    }


    /**
     * 密码编码器,密码不能明文存储
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        // 使用 BCryptPasswordEncoder 密码编码器,该编码器会将随机产生的 salt 混入最终生成的密文中
        return new BCryptPasswordEncoder();
    }

    /**
     * 定制用户认证管理器来实现用户认证
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 不再使用内存方式存储用户认证信息,而是动态从数据库中获取
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
    }

    /**
     * 定制基于 HTTP 请求的用户访问控制
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 启动 form 表单登录
        http.formLogin()
                // 设置登录页面的访问路径,默认为 /login,GET 请求;该路径不设限访问
                .loginPage("/login/page")
                // 设置登录表单提交路径,默认为 loginPage() 设置的路径,POST 请求
                .loginProcessingUrl("/login/form")
                // 设置登录表单中的用户名参数,默认为 username
                .usernameParameter("name")
                // 不再使用 defaultSuccessUrl() 和 failureUrl() 方法进行认证成功和失败处理,
                // 使用自定义的认证成功和失败处理器
                .successHandler(authenticationSuccessHandler)
                .failureHandler(authenticationFailureHandler);

        // 开启基于 HTTP 请求访问控制
        http.authorizeRequests()
                // 以下访问不需要任何权限,任何人都可以访问
                .antMatchers("/login/page", "/code/image","/mobile/page", "/code/mobile").permitAll()
                // 以下访问需要 ROLE_ADMIN 权限
                .antMatchers("/admin/**").hasRole("ADMIN")
                // 以下访问需要 ROLE_USER 权限
                .antMatchers("/user/**").hasAuthority("ROLE_USER")
                // 其它任何请求访问都需要先通过认证
                .anyRequest().authenticated();

        // 关闭 csrf 防护
        http.csrf().disable();

        // 将自定义过滤器(图形验证码校验)添加到 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(imageCodeValidateFilter, UsernamePasswordAuthenticationFilter.class);

        // 将手机短信验证码认证的配置与当前的配置绑定
        http.apply(mobileAuthenticationConfig);

        // 开启 Remember-Me 功能
        http.rememberMe()
                // 指定在登录时“记住我”的 HTTP 参数,默认为 remember-me
                .rememberMeParameter("remember-me")
                // 设置 Token 有效期为 200s,默认时长为 2 星期
                //.tokenValiditySeconds(200)
                // 设置操作数据库表的 Repository
                .tokenRepository(tokenRepository())
                // 指定 UserDetailsService 对象
                .userDetailsService(userDetailsService);

        // 开启注销登录功能
        http.logout()
                // 用户注销登录时访问的 url,默认为 /logout
                .logoutUrl("/logout")
                // 用户成功注销登录后重定向的地址,默认为 loginPage() + ?logout
                //.logoutSuccessUrl("/login/page?logout")
                // 不再使用 logoutSuccessUrl() 方法,使用自定义的成功注销登录处理器
                .logoutSuccessHandler(logoutSuccessHandler)
                // 指定用户注销登录时删除的 Cookie
                .deleteCookies("JSESSIONID")
                // 用户注销登录时是否立即清除用户的 Session,默认为 true
                .invalidateHttpSession(true)
                // 用户注销登录时是否立即清除用户认证信息 Authentication,默认为 true
                .clearAuthentication(true);
    }

    /**
     * 定制一些全局性的安全配置,例如:不拦截静态资源的访问
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        // 静态资源的访问不需要拦截,直接放行
        web.ignoring().antMatchers("/**/*.css", "/**/*.js", "/**/*.png", "/**/*.jpg", "/**/*.jpeg");
    }
}

📚 测试

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

访问localhost:8080/logout,注销登录,查看请求头和响应头:

由上图可以看出,注销登录后,用户浏览器的 JSESSIONID 和 remember-me 的 Cookie 被删除。


源码分析

✌ LogoutFilter#doFilter

public class LogoutFilter extends GenericFilterBean {
    //...
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest)req;
        HttpServletResponse response = (HttpServletResponse)res;
        //(1) 判断请求的 URL 是否为指定的注销登录路径,默认为 /logout
        if (this.requiresLogout(request, response)) {
            //(2) 获取认证用户信息 Authentication
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("Logging out user '" + auth + "' and transferring to logout destination");
            }
            //(3) 注销登录的处理,调用 CompositeLogoutHandler 处理器的 logout 方法
            this.handler.logout(request, response, auth);
            //(4) 成功注销登录后的处理,调用成功注销登录处理器的 onLogoutSuccess() 方法,
            //  默认重定向到 /登录页面url?logout
            this.logoutSuccessHandler.onLogoutSuccess(request, response, auth);
        } else {
            chain.doFilter(request, response);
        }
    }
    //...
}

上述(3)过程调用 CompositeLogoutHandler 处理器的 logout 方法进行注销登录的处理。

✌ CompositeLogoutHandler#logout

public final class CompositeLogoutHandler implements LogoutHandler {
    private final List<LogoutHandler> logoutHandlers;
    //...
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Iterator var4 = this.logoutHandlers.iterator();
	// 此处有多个处理器操作,主要有三个:
        //  1. 清除指定 Cookie:CookieClearingLogoutHandler#logout
        //  2. 清除 remember-me:PersistentTokenBasedRememberMeServices#logout
        //  3. 使当前 Session无效,清空当前的 SecurityContext 中认证用户信息 Authentication:
        //     SecurityContextLogoutHandler#logout
        while(var4.hasNext()) {
            LogoutHandler handler = (LogoutHandler)var4.next();
            handler.logout(request, response, authentication);
        }
    }
}

下面会对这三个处理器的 logout()

✍ CookieClearingLogoutHandler#logout

public final class CookieClearingLogoutHandler implements LogoutHandler {
    private final List<Function<HttpServletRequest, Cookie>> cookiesToClear;
    
    // 此处传入的 cookiesToClear 就是我们在安全配置类中使用 deleteCookies() 方法传入的参数
    public CookieClearingLogoutHandler(String... cookiesToClear) {
        Assert.notNull(cookiesToClear, "List of cookies cannot be null");
        List<Function<HttpServletRequest, Cookie>> cookieList = new ArrayList();
        String[] var3 = cookiesToClear;
        int var4 = cookiesToClear.length;

        for(int var5 = 0; var5 < var4; ++var5) {
            String cookieName = var3[var5];
            // 创建 Cookie 和删除逻辑的 Lambda 表达式
            Function<HttpServletRequest, Cookie> f = (request) -> {
                Cookie cookie = new Cookie(cookieName, (String)null);
                // 此处的 cookiePath 设置存在问题,如果 contextPath 不为空,后缀不需要加 "/" 
                // cookiePath = contextPath.length() > 0 ? contextPath : "/"
                String cookiePath = request.getContextPath() + "/";
                cookie.setPath(cookiePath);
                cookie.setMaxAge(0);
                cookie.setSecure(request.isSecure());
                return cookie;
            };
            cookieList.add(f);
        }

        this.cookiesToClear = cookieList;
    }    
    
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
	// 将指定的 cookiesToClear 列表中的 Cookie 的 Max-Age 设置为 0,删除该列表中的 Cookie
        this.cookiesToClear.forEach((f) -> {
            response.addCookie((Cookie)f.apply(request));
        });
    }
    //...
}

✍ PersistentTokenBasedRememberMeServices#logout

public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
    //...
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        //(1) 调用父类 AbstractRememberMeServices 的同名方法,将 remember-me 的 Cookie 清除
        super.logout(request, response, authentication);
        if (authentication != null) {
            //(2) 使用 tokenRepository 将数据库表中对应用户的 Token 记录删除
            this.tokenRepository.removeUserTokens(authentication.getName());
        }
    }    
    //...
}
public class PersistentTokenBasedRememberMeServices extends AbstractRememberMeServices {
    //...
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Logout of user " + (authentication == null ? "Unknown" : authentication.getName()));
        }
	// 将 remember-me 的 Cookie 清除
        this.cancelCookie(request, response);
    }
    
    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);
        }

        if (this.useSecureCookie == null) {
            cookie.setSecure(request.isSecure());
        } else {
            cookie.setSecure(this.useSecureCookie);
        }

        response.addCookie(cookie);
    }

    private String getCookiePath(HttpServletRequest request) {
        String contextPath = request.getContextPath();
        return contextPath.length() > 0 ? contextPath : "/";
    }    
    //
}

✍ SecurityContextLogoutHandler#logout

public class SecurityContextLogoutHandler implements LogoutHandler {
    //...
    private boolean invalidateHttpSession = true;
    private boolean clearAuthentication = true;
    
    public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        Assert.notNull(request, "HttpServletRequest required");
        if (this.invalidateHttpSession) {
            //(1) 将当前 Session 强制失效
            HttpSession session = request.getSession(false);
            if (session != null) {
                this.logger.debug("Invalidating session: " + session.getId());
                session.invalidate();
            }
        }

        if (this.clearAuthentication) {
            //(2) 移除当前 SecurityContext 中的用户认证用户信息 Authentication
            SecurityContext context = SecurityContextHolder.getContext();
            context.setAuthentication((Authentication)null);
        }
	//(3) 清空当前的 SecurityContextHolder
        SecurityContextHolder.clearContext();
    }
    //...
}
posted @ 2020-10-08 23:32  呵呵233  阅读(5059)  评论(9编辑  收藏  举报