Spring Security 3.2.9 同时支持表单登录与Token认证的架构设计与实现
2026/6/16 8:59:54 网站建设 项目流程

1. 项目概述:为什么需要同时支持Form和Token登录?

在构建现代Web应用,特别是微服务架构下的应用时,我们常常会面临一个看似矛盾的需求:既要为传统的浏览器端用户提供表单登录(Form Login)的友好体验,又要为移动端App、第三方服务或前后端分离架构提供基于Token的无状态API认证。我最近在重构一个老项目的认证模块,核心要求就是将Spring Security 3.2.9(一个相对经典但功能完整的版本)改造为同时支持这两种模式。这不仅仅是加个配置那么简单,它涉及到对Spring Security过滤器链的深度理解、认证流程的定制,以及如何让两套看似独立的认证机制在同一套安全上下文中和谐共处。

简单来说,表单登录是给“人”用的,用户通过浏览器访问一个登录页面,输入用户名密码,服务器验证后创建一个Session,后续请求通过Session Cookie来维持登录状态。而Token登录(通常是JWT或OAuth2 Bearer Token)是给“程序”或“客户端”用的,客户端在首次认证后获得一个令牌(Token),后续在请求头中携带这个令牌来访问受保护的API,服务器无需维护会话状态。在一个系统中同时提供这两种能力,意味着你的应用既能服务于传统的Web门户,也能作为API服务器支撑移动端、小程序或第三方集成,极大地提升了系统的灵活性和适用范围。

2. 核心架构设计与思路拆解

2.1 理解Spring Security 3.2.9的认证流程核心

Spring Security 3.2.9虽然版本较早,但其核心架构——基于过滤器链(FilterChain)的请求拦截和处理机制——与后续版本一脉相承。要同时支持Form和Token,关键在于理解并干预其认证流程。

当请求到达时,会经过一个由多个Filter组成的链条。对于认证,最关键的两个过滤器是:

  1. UsernamePasswordAuthenticationFilter:默认处理/login(POST)请求,从表单参数中提取usernamepassword,尝试进行认证。成功后,会将认证信息(Authentication对象)存入SecurityContext,并通常重定向到默认成功页面。
  2. SecurityContextPersistenceFilter:位于链条较前位置,负责在每个请求开始时从Session中恢复SecurityContext(对于Form登录),在请求结束时将其保存回Session。

我们的目标是在这个链条中插入一个自定义的Token认证过滤器,让它能在UsernamePasswordAuthenticationFilter之前运行。这样,对于携带Token的API请求,我们的自定义过滤器会率先处理并完成认证,后续的Form登录过滤器就不会再介入;而对于普通的浏览器请求,因为没有Token,自定义过滤器会放行,由后续的Form登录过滤器或Session机制来处理。

2.2 方案选型:为何选择自定义过滤器而非OAuth2资源服务器?

在Spring Security的生态中,处理Token认证通常有几种方式:使用OAuth2ResourceServer(在更高版本中)、使用JwtAuthenticationFilter等。但在3.2.9版本中,对OAuth2和JWT的原生支持相对较弱。更关键的是,OAuth2资源服务器的配置通常倾向于只处理Token请求,它会默认拒绝没有Token的请求(返回401),这与我们“同时支持”的目标相悖。

因此,最直接、最可控的方案是自定义一个AuthenticationFilter,并将其集成到Spring Security的过滤器链中。这个过滤器的职责很明确:

  • 检查请求:判断请求头(如Authorization: Bearer <token>)或参数中是否携带了预定义的Token。
  • 验证Token:如果携带了Token,则对其进行解析和验证(例如验证JWT签名、检查有效期)。
  • 构建认证对象:验证通过后,根据Token中的信息(如用户名、权限)构建一个Authentication对象(通常是UsernamePasswordAuthenticationToken)。
  • 注入安全上下文:将这个Authentication对象设置到当前线程的SecurityContextHolder中,标志着用户已认证。
  • 放行请求:过滤器链继续,后续的授权逻辑可以正常进行。

这个自定义过滤器需要被放置在过滤器链中SecurityContextPersistenceFilter之后,但在UsernamePasswordAuthenticationFilterBasicAuthenticationFilter等默认认证过滤器之前。这样能确保Token认证优先级最高。

2.3 配置层面的核心挑战与解决思路

security.xml(或Java Config)中配置时,我们需要解决几个关键问题:

  1. 禁用CSRF对API的影响:CSRF(跨站请求伪造)保护是Form登录场景的重要安全措施,但它会要求请求携带CSRF Token,这对于使用Token的无状态API来说是多余的,甚至会造成阻碍。我们需要配置Spring Security,对API路径(如/api/**)禁用CSRF保护,而对Web路径保持启用。
  2. 会话创建策略:对于Form登录,我们需要IF_REQUIRED(默认)或ALWAYS策略来创建和管理Session。对于Token API,我们期望是STATELESS策略,即不创建和使用Session。这可以通过为不同URL模式配置不同的<security:http>块,并设置各自的create-session属性来实现,但更常见的做法是在同一个配置中,通过session-managementsession-fixation-protection等属性进行全局管理,并对API请求在过滤器中显式设置为无状态。
  3. 认证入口点:当未认证用户访问受保护资源时,Spring Security需要知道如何响应。对于Web请求,通常是重定向到登录页(LoginUrlAuthenticationEntryPoint)。对于API请求,应该返回一个清晰的JSON格式的401错误,而不是重定向。这需要我们自定义一个AuthenticationEntryPoint,根据请求类型(检查Accept头或URL路径)来决定响应方式。

3. 核心细节解析与实操要点

3.1 自定义Token认证过滤器的实现细节

下面是一个JwtAuthenticationFilter的核心实现示例。这个过滤器继承自GenericFilterBean,以便于在Spring环境中获取配置属性。

import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.web.filter.GenericFilterBean; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class JwtAuthenticationFilter extends GenericFilterBean { private TokenService tokenService; // 自定义的Token解析、验证服务 private UserDetailsService userDetailsService; // Spring Security的用户详情服务 @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; // 1. 从请求中提取Token String authHeader = request.getHeader("Authorization"); String token = null; if (authHeader != null && authHeader.startsWith("Bearer ")) { token = authHeader.substring(7); // 去掉"Bearer "前缀 } // 也可以考虑从url参数中获取,如 ?token=xxx,但不如Header安全 String username = null; if (token != null) { try { // 2. 验证并解析Token username = tokenService.validateAndGetUsernameFromToken(token); } catch (Exception e) { // Token无效或过期,记录日志,但不抛出异常,继续过滤器链 // 后续的认证过滤器(如表单登录)会处理 logger.debug("Invalid JWT token: " + e.getMessage()); } } // 3. 如果Token有效,且当前上下文没有认证信息,则构建认证对象 if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 从数据库或缓存中加载用户详情(权限信息) UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); // 创建已认证的Token UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 4. 注入安全上下文 SecurityContextHolder.getContext().setAuthentication(authentication); // 5. (可选)对于无状态API,显式告知Security不要创建Session // request.setAttribute("__spring_security_session_mgmt_filter_applied", Boolean.TRUE); } // 6. 继续过滤器链 chain.doFilter(request, response); } // Setter方法用于依赖注入 public void setTokenService(TokenService tokenService) { this.tokenService = tokenService; } public void setUserDetailsService(UserDetailsService userDetailsService) { this.userDetailsService = userDetailsService; } }

关键点解析:

  • Bearer前缀:这是OAuth2规范中推荐的Token携带方式,已成为业界标准。过滤器需要识别并剥离这个前缀。
  • 异常处理:在Token解析失败时,我们选择静默失败(记录debug日志),而不是直接抛出异常或返回401。这是因为这个过滤器需要与Form登录共存。一个没有Token或Token无效的请求,很可能是一个普通的Web请求,应该留给后面的UsernamePasswordAuthenticationFilter或Session机制去处理。如果在此处直接中断,会破坏Form登录流程。
  • UserDetailsService:即使使用JWT,通常也需要从数据库或缓存中加载用户的最新权限信息。JWT里可以存储用户名和基础权限,但若权限模型复杂或可能动态变更,从持久化存储加载是更可靠的做法。这里体现了Token认证与后端用户体系的关联。
  • 无状态Session:注释掉的第5步展示了如何暗示Spring Security的SessionManagementFilter不要为这次请求创建Session。更正式的做法是在security.xml中为API路径配置单独的<http>块并设置create-session="stateless"

3.2 安全配置(security.xml)的关键整合

如何在XML配置中将这个自定义过滤器插入到正确的位置,是成功的关键。以下是一个简化的配置示例:

<beans:beans xmlns="http://www.springframework.org/schema/security" xmlns:beans="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security.xsd"> <!-- 定义自定义过滤器和相关Bean --> <beans:bean id="tokenService" class="com.yourcompany.security.JwtTokenService"/> <beans:bean id="jwtAuthenticationFilter" class="com.yourcompany.security.JwtAuthenticationFilter"> <beans:property name="tokenService" ref="tokenService"/> <beans:property name="userDetailsService" ref="userDetailsService"/> </beans:bean> <http pattern="/api/**" use-expressions="true"> <!-- 为API路径配置自定义过滤器链 --> <custom-filter ref="jwtAuthenticationFilter" before="FORM_LOGIN_FILTER"/> <!-- 关键:在FORM_LOGIN_FILTER之前插入 --> <intercept-url pattern="/api/**" access="isAuthenticated()" /> <!-- API都需要认证 --> <csrf disabled="true"/> <!-- API禁用CSRF --> <session-management session-fixation-protection="none"/> <!-- 可设置为无状态 --> <http-basic entry-point-ref="restAuthenticationEntryPoint"/> <!-- 自定义的API认证入口点,返回JSON 401 --> </http> <http use-expressions="true"> <!-- 默认的Web路径配置 --> <intercept-url pattern="/login*" access="permitAll" /> <intercept-url pattern="/**" access="isAuthenticated()" /> <form-login login-page="/login.html" default-target-url="/home" authentication-failure-url="/login.html?error=true" login-processing-url="/perform_login"/> <logout logout-url="/perform_logout" delete-cookies="JSESSIONID" /> <csrf/> <!-- Web端启用CSRF --> <!-- 注意:这里没有引用jwt过滤器,Web请求走默认流程 --> </http> <authentication-manager> <authentication-provider user-service-ref="userDetailsService"> <password-encoder ref="passwordEncoder"/> </authentication-provider> </authentication-manager> <!-- 定义自定义的认证入口点Bean --> <beans:bean id="restAuthenticationEntryPoint" class="com.yourcompany.security.RestAuthenticationEntryPoint"/> </beans:beans>

配置要点解析:

  • 两个<http>元素:这是实现URL模式差异化配置的核心。第一个<http>pattern="/api/**"匹配所有API请求,第二个没有pattern的作为默认配置匹配其他所有请求(Web请求)。
  • <custom-filter>before属性FORM_LOGIN_FILTER是Spring Security定义的一个过滤器位置常量,代表UsernamePasswordAuthenticationFilter。将我们的jwtAuthenticationFilter放在它之前,确保了Token认证的优先性。
  • 差异化的CSRF和Session策略:在API配置中明确禁用CSRF并配置无状态Session,而在Web配置中启用它们。这是实现“同时支持”的关键安全策略分离。
  • 自定义AuthenticationEntryPointrestAuthenticationEntryPoint这个Bean需要实现AuthenticationEntryPoint接口,在其commence方法中,设置响应状态码为401,内容类型为application/json,并写入一个JSON格式的错误信息。这样,未认证的API访问会得到友好的JSON提示,而不是跳转到HTML登录页。

3.3 Token的生成与验证服务(TokenService)

自定义过滤器依赖的TokenService是Token体系的核心。它负责生成(在登录成功后)和验证Token。

import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.Date; @Component public class JwtTokenService { @Value("${jwt.secret}") private String secret; // 签名密钥,务必保密且足够复杂 @Value("${jwt.expiration}") private Long expiration; // 过期时间,如3600000 (1小时) /** * 根据用户名生成Token */ public String generateToken(String username) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); return Jwts.builder() .setSubject(username) // 主题,通常放用户名 .setIssuedAt(now) // 签发时间 .setExpiration(expiryDate) // 过期时间 .signWith(SignatureAlgorithm.HS512, secret) // 签名算法和密钥 .compact(); } /** * 从Token中解析用户名 */ public String getUsernameFromToken(String token) { Claims claims = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); return claims.getSubject(); } /** * 验证Token是否有效 */ public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secret).parseClaimsJws(token); return true; } catch (Exception e) { // 签名无效、Token过期、格式错误等 return false; } } // 一个合并了验证和获取用户名的方法,供过滤器使用 public String validateAndGetUsernameFromToken(String token) throws Exception { if (!validateToken(token)) { throw new Exception("Invalid or expired JWT token"); } return getUsernameFromToken(token); } }

注意事项:

  • 密钥安全jwt.secret是签名的核心,必须足够复杂(建议使用256位以上的随机字符串),并且绝不能提交到版本库。应通过环境变量或配置服务器注入。
  • Token存储:生成Token后,在Form登录成功的处理器或专门的API登录接口中,需要将Token返回给客户端。服务器端不应存储已签发的JWT,这是JWT无状态的优势。但可以考虑将Token加入黑名单(如登出时)以实现即时失效,这需要引入Redis等缓存。
  • 信息载荷:除了用户名(subject),你还可以在JWT的claims中加入用户ID、角色等必要信息,减少验证时查询数据库的次数。但注意不要放入敏感信息,因为JWT的Payload是Base64编码,可以被解码查看。

4. 实操过程与核心环节实现

4.1 构建支持双模式登录的认证端点

我们需要一个统一的“登录”入口,它能根据客户端的不同,返回不同的结果。对于Web表单提交,Spring Security的UsernamePasswordAuthenticationFilter默认处理/login(POST)。对于API登录,我们需要额外提供一个端点,比如/api/auth/login

1. 自定义API登录控制器:

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtTokenService tokenService; @Autowired private UserDetailsService userDetailsService; @PostMapping("/login") public ResponseEntity<?> createAuthenticationToken(@RequestBody LoginRequest loginRequest) { // 1. 执行认证 Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); SecurityContextHolder.getContext().setAuthentication(authentication); // 2. 加载用户详情获取权限(可选,JWT中也可包含) final UserDetails userDetails = userDetailsService.loadUserByUsername(loginRequest.getUsername()); // 3. 生成JWT Token final String token = tokenService.generateToken(userDetails.getUsername()); // 4. 返回Token和用户基本信息 return ResponseEntity.ok(new JwtResponse(token, userDetails.getUsername(), userDetails.getAuthorities())); } // 简单的请求响应对象 public static class LoginRequest { private String username; private String password; // getters and setters } public static class JwtResponse { private final String token; private final String username; private final Collection<? extends GrantedAuthority> authorities; // constructor and getters } }

这个控制器手动调用了AuthenticationManager进行认证,与Form登录的后台逻辑一致。认证成功后,生成JWT Token并返回给客户端(如移动端)。

2. 配置Security使API登录端点可匿名访问:security.xml的API配置块中,需要确保/api/auth/login路径允许未认证访问。

<http pattern="/api/**" use-expressions="true"> <intercept-url pattern="/api/auth/login" access="permitAll" /> <intercept-url pattern="/api/**" access="isAuthenticated()" /> <!-- ... other config ... --> </http>

4.2 整合Form登录与Token生成的钩子

如果我们希望在用户通过传统的Web表单登录后,也能为其生成一个Token(例如用于后续的AJAX调用或移动端同步),我们可以定制一个AuthenticationSuccessHandler

@Component("customAuthenticationSuccessHandler") public class CustomAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { @Autowired private JwtTokenService tokenService; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { // 1. 调用父类方法,保持默认的重定向等行为 super.onAuthenticationSuccess(request, response, authentication); // 2. 生成Token String username = authentication.getName(); String token = tokenService.generateToken(username); // 3. 可以将Token放到Session、Cookie或者响应头中,供前端使用 // 例如,放到响应头中(注意前端JavaScript需要能读取) response.setHeader("X-Auth-Token", token); // 或者,如果登录请求是AJAX,可以返回JSON if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) { response.setContentType("application/json"); response.getWriter().write("{\"token\": \"" + token + "\"}"); return; // 避免重定向 } } }

然后在security.xml的Form登录配置中引用这个处理器:

<form-login login-page="/login.html" authentication-success-handler-ref="customAuthenticationSuccessHandler" ... />

这样,无论是通过表单还是API登录,用户都能获得一个Token,实现了认证状态的“双轨制”同步。

4.3 权限系统的统一处理

无论是通过Session认证的用户还是通过Token认证的用户,最终在SecurityContextHolder中都会持有一个Authentication对象。Spring Security的授权机制(如@PreAuthorize,hasRole())是基于这个对象中的GrantedAuthority集合进行判断的。因此,只要我们在UserDetailsService中正确加载了用户的权限信息,并设置到Authentication对象中(自定义过滤器和登录控制器中都做了这一步),那么后续的权限检查对两种认证方式是完全透明的,无需额外处理。

5. 常见问题与排查技巧实录

在实际整合过程中,我踩过不少坑,这里总结几个典型问题和解决方案。

5.1 问题:Token认证成功后,访问API仍返回401或重定向到登录页

排查思路:

  1. 检查过滤器顺序:这是最常见的原因。确保你的JwtAuthenticationFilter被正确添加到了<http pattern="/api/**">的配置中,并且位置在before="FORM_LOGIN_FILTER"。你可以通过开启Spring Security的Debug日志来查看过滤器链。
  2. 检查CSRF配置:确认在API的<http>配置中设置了<csrf disabled="true"/>。如果CSRF被启用,POST/PUT/DELETE等非GET请求会被要求携带CSRF Token,而你的API请求很可能没有。
  3. 检查Session策略冲突:如果API请求意外地创建了Session,可能会干扰无状态认证。确保API配置中<session-management session-fixation-protection="none"/>,或者在自定义过滤器中尝试阻止Session创建(如前文代码注释所示)。
  4. 验证Token本身:在过滤器中打印日志,确认Token被正确提取、解析,并且username不为空。检查TokenService的签名密钥和过期时间配置。

5.2 问题:Form登录和Token登录的用户权限不同步

场景:用户通过表单登录后,在后台修改了角色权限,但该用户之前获取的Token在有效期内依然持有旧的权限。解决方案

  • 缩短Token有效期:这是最简单的方法,降低权限不一致的窗口期。结合使用Refresh Token机制,在Access Token过期后,用Refresh Token获取新的Access Token(此时会重新加载用户权限)。
  • 在Token中嵌入版本号或时间戳:在用户权限变更时,更新一个存储在用户记录中的“权限版本号”或“最后修改时间”。将这个信息也编码到JWT的Claims中。在自定义过滤器验证Token时,不仅验证签名和过期时间,还要从数据库取出当前用户的“权限版本号”与Token中的进行比对,如果不一致,则判定Token失效。
  • 使用黑名单/白名单:在用户登出或权限变更时,将旧的Token ID加入Redis黑名单。在自定义过滤器验证Token时,增加一步黑名单检查。这增加了状态管理,但提供了最精细的控制。

5.3 问题:如何优雅地处理“登录失败”?

  • Form登录失败:Spring Security默认会重定向到authentication-failure-url。你可以配置一个自定义的AuthenticationFailureHandler,根据错误类型(如密码错误、用户锁定)返回不同的提示信息,或者记录登录失败次数用于防暴力破解。
  • API Token登录失败:在你的/api/auth/login端点中,authenticationManager.authenticate()调用会抛出AuthenticationException(如BadCredentialsException)。你需要用@ExceptionHandler或全局异常处理器捕获它,并返回结构化的JSON错误响应,例如:
    { "status": 401, "error": "Unauthorized", "message": "用户名或密码错误", "path": "/api/auth/login" }
    绝对不要在错误信息中透露是用户名不存在还是密码错误,这属于安全信息泄露。统一返回“认证失败”即可。

5.4 性能与安全考量

  • Token验证的性能:JWT的签名验证是计算密集型操作。对于每个API请求都进行完整的JWT解析和签名验证可能会成为性能瓶颈。可以考虑在验证通过后,将解析出的用户信息(如username)缓存在一个线程安全的缓存(如ConcurrentHashMap)中,Key为Token本身或Token的哈希值,并设置一个较短的缓存时间(如5分钟)。这样,在Token有效期内,同一Token的重复请求可以快速通过缓存验证。
  • 密钥轮换:出于安全考虑,JWT签名密钥应定期更换。在轮换期间,新旧密钥需要同时支持一段时间。可以在TokenService中维护一个密钥列表,验证时依次尝试。生成新Token时只使用最新密钥。
  • 防止Token泄露:务必使用HTTPS来传输Token。避免将Token存储在容易被XSS攻击获取的地方(如LocalStorage),可以考虑使用HttpOnly的Cookie,但这会与纯API客户端的用法产生矛盾,需要权衡。对于敏感操作,应要求二次认证。

整合Spring Security 3.2.9同时支持Form和Token登录,是一个深入理解Spring Security工作机制的绝佳实践。它要求你打破“非此即彼”的思维,从请求的生命周期和过滤器链的层面去设计认证流程。核心在于优先级控制差异化配置:让Token过滤器以高优先级处理API请求并阻止后续的Session相关流程,同时为Web请求保留完整的Form登录和Session管理流程。这种架构不仅满足了多样化的客户端需求,也为系统从传统单体应用向现代化微服务架构平滑演进奠定了基础。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询