在 Web 应用程序中,安全认证与退出登录是保障用户账户安全的重要环节。本文将详细介绍如何在自定义认证场景下实现安全且友好的退出登录功能。
Spring Security 默认提供了退出登录功能,只需简单配置即可启用。在基于自定义认证的场景下,我们需要在安全配置中添加退出登录相关设置。下面将通过一个简单示例介绍退出登录的用法:
(1)登录页面,如下:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>自定义登录</title> </head> <body> <h1>自定义登录页面</h1> <!-- 显示错误信息 --> <div id="errorInfo" style="color:red;display:none;">用户名/密码错误</div> <script> if(location.href.indexOf("?error") !== -1) { document.getElementById("errorInfo").style.display = "block"; } </script> <form action="/login" method="post"> <p> 用户名:<input type="text" name="username" placeholder="请输入用户名" /> </p> <p> 密码:<input type="password" name="password" placeholder="请输入密码" /> </p> <p> <input type="submit" value="登录"/> </p> </form> </body> </html>
(2)修改登录成功页面,在登录成功页面添加退出链接按钮,实现退出功能。如下:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>登录成功</title> </head> <body> <h1>登录成功,欢迎光临!</h1> <p> <a href="/logout">退出登录</a> </p> </body> </html>
(3)编写 SecurityConfig 配置类,配置退出登录,如下:
package com.hxstrive.spring_security.config; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractAuthenticationFilterConfigurer; import org.springframework.security.core.Authentication; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import java.io.IOException; import static org.springframework.security.config.Customizer.withDefaults; /** * Spring Security 配置类 * @author hxstrive.com */ @Configuration public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) // formLogin 用于配置表单登录功能。 // permitAll() 表示允许所有用户访问表单登录相关的页面,例如登录页面、登录处理接口等。 .formLogin(e -> // 设置登录成功自动跳转到 /success 页面,true 表示自动重定向到该页面 e.defaultSuccessUrl("/success", true).permitAll()) // 退出登录配置 .logout(e -> e.logoutUrl("/logout") // 处理退出的地址 // 退出成功后,跳转的地址 .logoutSuccessUrl("/login?logout=success") ) // 用于启用 HTTP 基本认证,withDefaults() 表示使用默认的 HTTP 基本认证配置。 .httpBasic(withDefaults()); return http.build(); } }
上述配置说明:
logoutUrl("/logout") 指定退出登录的请求 URL
logoutSuccessUrl("/login?logout") 退出成功后跳转的页面
(4)启动服务,然后打开浏览器,访问 http://localhost:8080/hello 地址,由于所有请求都需要身份验证,你会看到一个登录页面。在登录页面输入用户名和密码,进入成功页面。如下图:
将鼠标移动到“退出登录”链接按钮,你会发现退出登录地址为“http://localhost:8080/logout”,点击“退出登录”,跳转到退出页面,如下图:
使用浏览器开发模式,查看“Log Out”按钮的页面数据,其实就是一个表单,如下图:
此时,继续点击“Log Out”确认退出,成功退出且跳转到登录页面,如下图:
到这里,简单的退出功能就实现了。
注意:页面没有样式,是因为 bootstrap.min.css 样式下载失败导致。
在某些场景下,我们需要在用户退出登录时执行一些自定义操作,例如记录日志、清理缓存或释放资源等。Spring Security 提供了 LogoutHandler 和 LogoutSuccessHandler 接口来满足这些需求。
LogoutHandler 是一个核心接口,用于处理用户退出登录时的清理操作。当用户发起 logout 请求时,Spring Security 会依次调用注册的 LogoutHandler 实现,执行诸如使会话无效、清除认证信息、撤销令牌等操作。
LogoutHandler 接口定义如下:
package org.springframework.security.web.authentication.logout; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.core.Authentication; public interface LogoutHandler { void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication); }
logout 方法的参数说明:
request:HTTP 请求对象
response:HTTP 响应对象
authentication:注销前的认证对象(可能为 null)
注意,Spring Security 提供了多个 LogoutHandler 的默认实现:
SecurityContextLogoutHandler 清除 SecurityContext,使当前会话无效。这是最常用的处理器。
CookieClearingLogoutHandler 清除指定的 Cookie(如记住我功能的 cookie)。
CsrfLogoutHandler 处理 CSRF 令牌的注销,通常与其他处理器配合使用。
HeaderWriterLogoutHandler 添加响应头(如清除认证信息的头)。
OAuth2AccessTokenLogoutHandler 用于 OAuth2 资源服务器,撤销访问令牌。
OidcClientInitiatedLogoutHandler 用于 OpenID Connect,处理客户端发起的注销。
LogoutSuccessHandler 是一个关键接口,用于处理用户成功注销后的操作。当用户完成注销流程(如清除会话、撤销令牌等)后,Spring Security 会调用注册的 LogoutSuccessHandler 实现,允许开发者自定义后续逻辑,如重定向到特定页面、返回 JSON 响应或记录审计日志。
LogoutSuccessHandler 接口定义如下:
package org.springframework.security.web.authentication.logout; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import org.springframework.security.core.Authentication; public interface LogoutSuccessHandler { void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException; }
onLogoutSuccess 方法的参数说明:
request:HTTP 请求对象
response:HTTP 响应对象
authentication:注销前的认证对象(可能为 null)
注意, Spring Security 中提供了两个主要的 LogoutSuccessHandler 默认实现:
SimpleUrlLogoutSuccessHandler 重定向到指定 URL(默认 /login?logout)。这是最常用的实现。
HttpStatusReturningLogoutSuccessHandler 返回指定 HTTP 状态码(如 200 OK),适用于 REST API 场景。
下面示例将介绍如何自定义 LogoutHandler 和 LogoutSuccessHandler 接口,实现自定义退出逻辑。
在正式写代码之前,还需要介绍 addLogoutHandler() 和 logoutSuccessHandler() 方法:
addLogoutHandler(LogoutHandler) 在 Spring Security 的默认登出处理器(如清除 Session、Cookie)之前执行,用于在登出过程中执行前置操作,可添加多个处理器,按顺序执行。
logoutSuccessHandler(LogoutSuccessHandler) 用于在登出成功后自定义响应,覆盖默认重定向逻辑。在所有登出处理器(包括默认和自定义)执行完成后调用。若登出过程中抛出异常(如 CSRF 验证失败),则不会触发。
示例代码如下:
package com.hxstrive.spring_security.config; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.Authentication; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.logout.LogoutHandler; import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; import java.io.IOException; import static org.springframework.security.config.Customizer.withDefaults; /** * Spring Security 配置类 * @author hxstrive.com */ @Configuration public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) // formLogin 用于配置表单登录功能。 // permitAll() 表示允许所有用户访问表单登录相关的页面,例如登录页面、登录处理接口等。 .formLogin(e -> // 设置登录成功自动跳转到 /success 页面,true 表示自动重定向到该页面 e.defaultSuccessUrl("/success", true).permitAll()) .logout(e -> e.logoutUrl("/logout") // 添加自定义退出逻辑 .addLogoutHandler(new LogoutHandler() { @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { System.out.println(authentication.getName() + " 已退出"); // 清理自定义缓存或资源 // 其他操作 } }) // 自定义退出成功处理逻辑 .logoutSuccessHandler(new LogoutSuccessHandler(){ @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 登录成功后重定向到 /login?logout=success 页面 response.sendRedirect("/login?logout=success"); } }) ) // 用于启用 HTTP 基本认证,withDefaults() 表示使用默认的 HTTP 基本认证配置。 .httpBasic(withDefaults()); return http.build(); } }
上述代码中,通过匿名内部类实现退出逻辑(addLogoutHandler)和退出成功(logoutSuccessHandler)后的处理逻辑。
在 Spring Security 中,默认启用了 CSRF(跨站请求伪造)保护,这意味着退出登录请求必须包含有效的 CSRF 令牌。
(1)对于传统表单提交,可以通过 Thymeleaf 等模板引擎自动注入 CSRF 令牌,如下:
<form action="#" th:action="@{/logout}" method="post"> <button type="submit">退出登录</button> </form>
注意:Thymeleaf 提供了 sec 命名空间(Security Dialect),专门处理 Spring Security 相关功能。当使用 Thymeleaf 的表单标签时,会自动检测 CSRF 保护是否启用,并在表单中注入隐藏字段。
(2)对于前后端分离应用,可以通过以下方式处理 CSRF:
a、 在登录成功时返回 CSRF 令牌给前端(默认情况下,CSRF 令牌绑定到用户会话(Session),会话过期后需重新获取)。
b、前端在发送退出请求时包含 CSRF 令牌,如下:
// 前端获取 CSRF 令牌示例 function logout() { const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content'); const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content'); fetch('/logout', { method: 'POST', headers: { [csrfHeader]: csrfToken } }) .then(response => { if (response.ok) { window.location.href = '/login'; } }); }
如果应用中启用了 Remember-Me(记住我)功能,退出登录时需要特别处理该功能的 Cookie,如下:
@Configuration public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) // formLogin 用于配置表单登录功能。 // permitAll() 表示允许所有用户访问表单登录相关的页面,例如登录页面、登录处理接口等。 .formLogin(e -> // 设置登录成功自动跳转到 /success 页面,true 表示自动重定向到该页面 e.defaultSuccessUrl("/success", true).permitAll()) .logout(e -> e.logoutUrl("/logout") // 删除 remember-me Cookie(看这里) .deleteCookies("JSESSIONID", "remember-me") // 添加自定义退出逻辑 .addLogoutHandler(new LogoutHandler() { @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { System.out.println(authentication.getName() + " 已退出"); // 清理自定义缓存或资源 // 其他操作 } }) // 自定义退出成功处理逻辑 .logoutSuccessHandler(new LogoutSuccessHandler(){ @Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 登录成功后重定向到 /login?logout=success 页面 response.sendRedirect("/login?logout=success"); } }) ) // 用于启用 HTTP 基本认证,withDefaults() 表示使用默认的 HTTP 基本认证配置。 .httpBasic(withDefaults()); return http.build(); } }
上述代码中,deleteCookies() 方法用于在用户登出时清除特定的 Cookie,增强安全性和用户体验。退出时请确保使用 deleteCookies() 方法清除与认证相关的 Cookie(如 JSESSIONID),防止会话劫持。
在用户登出过程中执行自定义清理操作(如清除缓存、记录日志、释放资源)。源码如下:
继续查看 createLogoutFilter(H http) 方法,该方法向 logoutHandlers 列表中添加了一个默认处理器。如下:
默认处理器为 SecurityContextLogoutHandler,如下:
默认处理的核心方法 logout 如下:
public class SecurityContextLogoutHandler implements LogoutHandler { //... // 成员变量 // 是否销毁HttpSession对象,默认为true private boolean invalidateHttpSession = true; /** * 当调用此 LogoutHandler 时,使 HttpSession 失效。默认为 true。 * @param invalidateHttpSession 如果希望会话失效,则为true(默认值); * 如果不希望会话失效,则为false。 */ public void setInvalidateHttpSession(boolean invalidateHttpSession) { this.invalidateHttpSession = invalidateHttpSession; } // 是否清除认证状态,默认为true private boolean clearAuthentication = true; /** * 如果为 true,则从 SecurityContext 中移除 Authentication,以防止并发请求出现问题。 * @param clearAuthentication 如果希望从 SecurityContext 中清除 Authentication}, * 则为 true(默认值);如果不应移除 Authentication,则为 false。 */ public void setClearAuthentication(boolean clearAuthentication) { this.clearAuthentication = clearAuthentication; } /** * 执行用户登出的核心逻辑,包含清除会话、安全上下文和持久化状态等操作 * * @param request HTTP请求对象,不能为null * @param response HTTP响应对象 * @param authentication 当前认证对象,可能为null(如匿名用户登出) */ @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // 校验请求对象非空 Assert.notNull(request, "HttpServletRequest required"); // 1. 使HTTP会话失效(如果配置了失效会话) if (this.invalidateHttpSession) { // 获取当前会话(不创建新会话) HttpSession session = request.getSession(false); if (session != null) { // 使会话失效,销毁所有会话属性 session.invalidate(); if (this.logger.isDebugEnabled()) { this.logger.debug(LogMessage.format("Invalidated session %s", session.getId())); } } } // 2. 清除SecurityContextHolder中的安全上下文(线程绑定的认证信息) SecurityContext context = this.securityContextHolderStrategy.getContext(); this.securityContextHolderStrategy.clearContext(); // 3. 清空安全上下文中的认证对象(可选操作) if (this.clearAuthentication) { context.setAuthentication(null); } // 4. 保存一个空的安全上下文到持久化存储(如Cookie或Session) // 这一步会覆盖原有上下文,确保后续请求不携带认证信息 SecurityContext emptyContext = this.securityContextHolderStrategy.createEmptyContext(); this.securityContextRepository.saveContext(emptyContext, request, response); } //... }
退出成功处理器,源码如下:
接着查看获取 LogoutSuccessHandler 的方法,源码如下:
上述源码中,如果 this.logoutSuccessHandler 为 null,则使用 this.createDefaultSuccessHandler() 方法创建一个默认的 LogoutSuccessHandler。源码如下:
上面代码,用于创建默认登出成功处理器的工厂方法,核心逻辑是根据是否存在默认登出处理映射,不存在,则返回 SimpleUrlLogoutSuccessHandler 处理器,否则,返回委托处理器 DelegatingLogoutSuccessHandler,委托处理器核心方法 onLogoutSuccess 如下:
@Override public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { for (Map.Entry<RequestMatcher, LogoutSuccessHandler> entry : this.matcherToHandler.entrySet()) { RequestMatcher matcher = entry.getKey(); if (matcher.matches(request)) { // 调用匹配的处理器,然后退出 // 不执行默认处理器 LogoutSuccessHandler handler = entry.getValue(); handler.onLogoutSuccess(request, response, authentication); return; } } // 调用默认处理器 if (this.defaultLogoutSuccessHandler != null) { this.defaultLogoutSuccessHandler.onLogoutSuccess(request, response, authentication); } }