LogoutFilter
定义与作用
LogoutFilter 是 Spring Security 过滤器链中负责处理登出请求的核心过滤器。它位于 CsrfFilter 之后、UsernamePasswordAuthenticationFilter 之前,拦截配置的登出 URL(默认为 /logout),执行一系列清理操作:注销 Session、清除 SecurityContext、删除 RememberMe Token、调用自定义的 LogoutHandler 链,最后重定向到登出成功页面。
核心原理
1. LogoutHandler 链
LogoutFilter 内部维护一个 List<LogoutHandler>,登出时按顺序执行:
| 默认 Handler | 职责 |
|---|---|
SecurityContextLogoutHandler | 清除 SecurityContextHolder 中的 Authentication |
ClearSiteDataLogoutHandler(可选) | 发送 Clear-Site-Data 响应头,清除浏览器 Cookie/Storage |
CookieClearingLogoutHandler | 删除指定的 Cookie(如 JSESSIONID、remember-me) |
SessionInvalidationLogoutHandler | 调用 HttpSession.invalidate() 使 Session 失效 |
RememberMeServices.logout() | 清除持久化 RememberMe Token(如数据库记录) |
// LogoutFilter 核心逻辑(简化)
public void doFilter(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) {
if (requiresLogout(request, response)) {
// 1. 获取当前认证信息
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// 2. 依次执行所有 LogoutHandler
for (LogoutHandler handler : handlers) {
handler.logout(request, response, auth);
}
// 3. 调用 LogoutSuccessHandler
logoutSuccessHandler.onLogoutSuccess(request, response, auth);
return; // 不继续执行后续过滤器
}
chain.doFilter(request, response);
}
2. LogoutConfigurer 配置项
完整示例一:前后端分离的 JSON 登出响应
场景说明
前后端分离应用,前端通过 AJAX 调用 /api/logout 接口。需要返回 JSON 响应(而非 HTML 重定向),同时清除后端 Session 和前端相关 Cookie。
操作前配置(默认行为,返回 HTML 重定向)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.logout(Customizer.withDefaults()); // 默认 /logout,POST,成功后 302 到 /login?logout
return http.build();
}
问题分析:默认 LogoutSuccessHandler 是 SimpleUrlLogoutSuccessHandler,执行后返回 302 重定向到 /login?logout。前端 AJAX 无法正确处理 302,可能在控制台报错或需要额外处理重定向逻辑。
操作后配置(自定义 JSON 响应)
@Configuration
@EnableWebSecurity
public class ApiLogoutConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.logout(logout -> logout
.logoutUrl("/api/logout") // 自定义登出 URL
.invalidateHttpSession(true) // 使 Session 失效
.clearAuthentication(true) // 清除 SecurityContext
.deleteCookies("JSESSIONID", "remember-me") // 删除指定 Cookie
.logoutSuccessHandler((request, response, authentication) -> {
// 返回 JSON 而不是重定向
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"code\":200,\"message\":\"登出成功\"}"
);
})
);
return http.build();
}
}
前端配合代码
// 前端 Axios 调用登出
async function logout() {
try {
const response = await axios.post('/api/logout', {}, {
withCredentials: true // 确保携带 Cookie
});
console.log(response.data); // {code: 200, message: "登出成功"}
// 清除前端本地存储的 Token 或状态
localStorage.removeItem('user_info');
window.location.href = '/login';
} catch (error) {
console.error('登出失败', error);
}
}
结果分析
| 请求 | 服务器行为 | 响应 | 前端行为 |
|---|---|---|---|
POST /api/logout(已登录) | Session 失效、SecurityContext 清除、Cookie 删除 | 200 JSON {code:200, message:"登出成功"} | 读取 JSON,跳转登录页 |
POST /api/logout(未登录) | 无 Session 可清除,handler 仍执行 | 200 JSON | 提示已登出 |
GET /api/logout | 默认仅匹配 POST,GET 请求被放行到后续过滤器 | 可能 404 或到达 Controller | 前端应使用 POST 调用 |
完整示例二:自定义 LogoutHandler 实现审计日志
场景说明
企业应用要求记录用户登出行为(审计日志),包括登出时间、IP 地址、用户名。需要自定义 LogoutHandler 在登出时写入日志数据库。
操作前配置(无审计记录)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.logout(Customizer.withDefaults());
return http.build();
}
问题分析:默认登出只清理安全上下文,不记录审计信息。安全团队无法追踪用户何时、从哪里登出,不利于安全审计和问题排查。
操作后配置(添加审计 Handler)
@Configuration
@EnableWebSecurity
public class AuditLogoutConfig {
@Autowired
private AuditLogRepository auditLogRepository;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.logout(logout -> logout
.logoutUrl("/logout")
.invalidateHttpSession(true)
.clearAuthentication(true)
.addLogoutHandler(new AuditLogoutHandler(auditLogRepository))
.logoutSuccessUrl("/login?logout")
);
return http.build();
}
// 自定义审计 LogoutHandler
public static class AuditLogoutHandler implements LogoutHandler {
private final AuditLogRepository auditLogRepository;
public AuditLogoutHandler(AuditLogRepository auditLogRepository) {
this.auditLogRepository = auditLogRepository;
}
@Override
public void logout(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {
if (authentication != null && authentication.getName() != null) {
String username = authentication.getName();
String ipAddress = request.getRemoteAddr();
String userAgent = request.getHeader("User-Agent");
LocalDateTime logoutTime = LocalDateTime.now();
AuditLog log = new AuditLog();
log.setUsername(username);
log.setAction("LOGOUT");
log.setIpAddress(ipAddress);
log.setUserAgent(userAgent);
log.setTimestamp(logoutTime);
auditLogRepository.save(log);
System.out.println("[AUDIT] 用户 " + username + " 从 IP " + ipAddress + " 登出");
}
}
}
}
// 审计日志实体(简化)
@Entity
public class AuditLog {
@Id @GeneratedValue
private Long id;
private String username;
private String action; // LOGIN / LOGOUT / ACCESS_DENIED
private String ipAddress;
private String userAgent;
private LocalDateTime timestamp;
// getters / setters
}
// 审计日志仓库
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
}
测试验证与结果分析
| 测试场景 | 用户操作 | 审计日志记录 | 说明 |
|---|---|---|---|
| 正常登出 | user 点击登出 | username=user, action=LOGOUT, IP=192.168.1.10 | Handler 在 SecurityContext 清除前执行,authentication 非空 |
| Session 过期后访问 | 用户点击登出 | 无记录(或 username=anonymous) | Session 已过期,authentication 可能为 AnonymousAuthenticationToken |
| 多标签页登出 | 标签页A 登出,标签页B 刷新 | 标签页A 记录一次 | 每个 /logout 请求触发一次 Handler |
| 清除 Cookie 后登出 | 用户手动删除 JSESSIONID 后点击登出 | 无记录或匿名 | 无有效 Session 时无法识别用户身份 |
易错场景:登出 URL 被 CSRF 拦截或授权规则错误覆盖
问题现象
开发者配置了自定义的登出 URL /api/auth/logout,但发现:
- POST 请求被
CsrfFilter拦截,返回 403。 - 或者登出 URL 被
.authorizeHttpRequests()的anyRequest().authenticated()覆盖,但奇怪的是——如果用户已登录,这不应该有问题。真正的问题是:将登出 URL 配置为permitAll()但 CSRF 未正确配置,或者 登出 URL 路径与securityMatcher不匹配。
错误配置
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/register").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.logout(logout -> logout
.logoutUrl("/api/auth/logout") // 自定义路径
);
return http.build();
}
问题分析:在 Spring Security 5.x 中,/logout 默认被框架隐式允许(即使未在 authorizeHttpRequests 中显式配置)。但如果改为自定义路径 /api/auth/logout,它不会被隐式放行。如果用户已登录,访问 /api/auth/logout 可以正常通过授权检查。但如果开发者同时使用了 csrf.disable() 或需要前端发送 POST 请求时,问题会出现。
真正典型的陷阱是以下这种多链配置中的登出问题:
@Bean
@Order(1)
public SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) // API 链禁用 CSRF
.logout(logout -> logout
.logoutUrl("/api/logout") // 在 /api/** 链中配置
);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/web/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.csrf(Customizer.withDefaults()) // Web 链启用 CSRF
.logout(logout -> logout
.logoutUrl("/web/logout") // 在 /web/** 链中配置
);
return http.build();
}
陷阱:如果用户通过 /web/** 的表单登录链认证,然后访问 /api/logout,请求会被 apiSecurity 链匹配(因为 /api/logout 以 /api/ 开头)。此时 apiSecurity 链中的 LogoutFilter 处理登出,但 apiSecurity 链使用 httpBasic 认证,且用户此前通过 /web/** 的表单登录建立了 Session 认证。虽然 LogoutFilter 能执行 invalidateHttpSession() 使 Session 失效,但 apiSecurity 链的 httpBasic 配置和 csrf.disable() 意味着这个链的设计是无状态的。如果用户期望通过表单登录链的 Session 登出,而实际访问了 API 链的 logout URL,可能会因为 httpBasic 的认证机制差异导致行为不一致。
正确做法
- 登出 URL 应与登录链属于同一条
SecurityFilterChain,确保认证机制一致。 - 如果必须使用自定义登出 URL,确保它在授权规则中正确放行(或至少对已认证用户可访问)。
- 如果启用 CSRF,登出请求必须是 POST,并携带 CSRF Token。
@Bean
public SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
http
.securityMatcher("/web/**", "/logout") // 包含登出 URL
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/logout").permitAll()
.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.csrf(Customizer.withDefaults()) // CSRF 开启
.logout(logout -> logout
.logoutUrl("/logout") // 默认 POST,携带 CSRF Token
.logoutSuccessUrl("/login?logout")
);
return http.build();
}
<!-- Thymeleaf 表单登出(必须 POST + CSRF Token) -->
<form th:action="@{/logout}" method="post">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>
<button type="submit">登出</button>
</form>
面试考点:
LogoutFilter中的LogoutHandler和LogoutSuccessHandler有什么区别?登出时 Session 是在哪个 Handler 中失效的? 答:LogoutHandler是一组在登出时执行清理操作的处理器(责任链模式),包括SecurityContextLogoutHandler(清除SecurityContextHolder)、SessionInvalidationLogoutHandler(使 Session 失效)、CookieClearingLogoutHandler(删除 Cookie)等。LogoutSuccessHandler是在所有LogoutHandler执行完毕后,决定登出成功后的响应行为(如重定向到登录页或返回 JSON)。Session 失效由SessionInvalidationLogoutHandler(或SecurityContextLogoutHandler根据配置)在LogoutHandler链中执行,早于LogoutSuccessHandler的调用。