Spring Security+JWT 轻松实现前后端分离的认证架构
见字如面,与大家分享实践中的经验与思考。
大部分软件应用在开发业务之前,都需要完成认证和授权的功能。所以选择一个功能强大,简单易用的框架非常重要,以便于满足各种安全相关的诉求。
Spring Security 是一个强大且易于使用的框架,对 servlet 和 reactive 应用程序都提供支持,提供了一套全面的安全解决方案,包括身份验证、授权、防止攻击等功能。
本文将基于SpringBooot 3.3.2 、Spring WebMvc 6.1.11 、 Spring Security 6.3.1 、jsonwebtoken 0.11.5。会先大量篇幅讲解原理,后讲述如何在实际项目中设计前后端分离的落地实践。如果嫌弃原理章节过长,可以直接跳到 前后端分离的认证架构设计模块。
Sping Security Overall 架构

要点说明:
客户端向应用程序发送一个请求,容器创建一个
FilterChain,其中包含Filter实例和Servlet,应该根据请求URI的路径来处理HttpServletRequest。在Spring MVC应用程序中,Servlet是DispatcherServlet的一个实例。一个
Servlet最多可以处理一个HttpServletRequest和HttpServletResponse。然而,可以使用多个Filter来完成不同的工作。由于一个
Filter只影响下游的Filter实例和Servlet,所以每个Filter的调用顺序是非常重要的。Spring 提供了一个名为
DelegatingFilterProxy的Filter实现,允许在 Servlet 容器的生命周期和 Spring 的ApplicationContext之间建立桥梁。DelegatingFilterProxy的另一个好处是,它允许延迟查找FilterBean实例。这一点很重要,因为在容器启动之前,容器需要注册Filter实例。然而, Spring 通常使用ContextLoaderListener来加载 Spring Bean,这在需要注册Filter实例之后才会完成。FilterChainProxy是 Spring Security 提供的一个特殊的Filter,允许通过SecurityFilterChain委托给许多Filter实例。由于FilterChainProxy是一个Bean,它通常被包裹在 DelegatingFilterProxy 中。SecurityFilterChain被 FilterChainProxy 用来确定当前请求应该调用哪些 Spring SecurityFilter实例。确定何时应该调用SecurityFilterChain方面提供了更大的灵活性。在Servlet容器中,Filter实例仅基于URL被调用。 然而,FilterChainProxy可以通过使用RequestMatcher接口,根据HttpServletRequest中的任何内容确定调用。有多个 SecurityFilterChain 的话,
FilterChainProxy决定应该使用哪个SecurityFilterChain。只有第一个匹配的SecurityFilterChain被调用。如果请求的URL是/api/messages/,它首先与/api/**的SecurityFilterChain_0模式匹配,所以只有SecurityFilterChain_0被调用,尽管它也与SecurityFilterChain_n匹配。如果请求的URL是/messages/,它与/api/**的SecurityFilterChain_0模式不匹配,所以FilterChainProxy继续尝试每个SecurityFilterChain。假设没有其他SecurityFilterChain实例相匹配,则调用SecurityFilterChain_n。Security Filter 是通过 SecurityFilterChain API 插入 FilterChainProxy 中的。Filter 的执行是有顺序的,可以查看
FilterOrderRegistration代码。也可以在控制台看到项目中所使用的 Security Filter 列表:
2024-09-05 15:56:47.152 DEBUG 41171 [] [yueji] [restartedMain] o.s.s.web.DefaultSecurityFilterChain Will secure any request with filters:
DisableEncodeUrlFilter,
WebAsyncManagerIntegrationFilter,
SecurityContextHolderFilter,
HeaderWriterFilter,
CorsFilter,
CsrfFilter,
LogoutFilter,
JwtAuthenticationFilter,
RequestCacheAwareFilter,
SecurityContextHolderAwareRequestFilter,
AnonymousAuthenticationFilter,
SessionManagementFilter,
CsrfCookieFilter,
ExceptionTranslationFilter,
AuthorizationFilterSpring Security 异常处理

1️⃣ 首先,
ExceptionTranslationFilter调用FilterChain.doFilter(request, response)来调用应用程序的其他部分。2️⃣ 如果用户没有被认证,或者是一个
AuthenticationException,那么就 开始认证。SecurityContextHolder 被清理掉。
HttpServletRequest被保存起来,这样一旦认证成功,它就可以用来重放原始请求。AuthenticationEntryPoint用于请求客户的凭证。例如,它可以重定向到一个登录页面或发送一个WWW-Authenticate头。
3️⃣ 否则,如果是
AccessDeniedException,那么就是 Access Denied。AccessDeniedHandler被调用来处理拒绝访问(access denied)。
ExceptionTranslationFilter 的伪代码看起来是这样的。

Spring Security 认证架构
SecurityContextHolder -
SecurityContextHolder是 Spring Security 存储认证用户细节的地方。SecurityContext - 是从
SecurityContextHolder获得的,包含了当前认证用户的Authentication(认证)。Authentication - 可以是
AuthenticationManager的输入,以提供用户提供的认证凭证或来自SecurityContext的当前用户。GrantedAuthority- 在
Authentication(认证)中授予委托人的一种权限(即role、scope等)。AuthenticationManager- 定义 Spring Security 的 Filter 如何执行 认证 的API。
ProviderManager - 最常见的
AuthenticationManager的实现。AuthenticationProvider- 由
ProviderManager用于执行特定类型的认证。用
AuthenticationEntryPoint请求凭证- 用于从客户端请求凭证(即重定向到登录页面,发送WWW-Authenticate响应,等等)。AbstractAuthenticationProcessingFilter - 一个用于认证的基本
Filter。这也让我们很好地了解了认证的高层流程以及各部分是如何协作的。
SecurityContextHolder

默认情况下,SecurityContextHolder 使用 ThreadLocal 来存储这些细节,这意味着 SecurityContext 对同一线程中的方法总是可用的,即使 SecurityContext 没有被明确地作为参数传递给这些方法。如果你注意在处理完当前委托人的请求后清除该线程,以这种方式使用 ThreadLocal 是相当安全的。Spring Security 的 FilterChainProxy确保 SecurityContext 总是被清空。
SecurityContext
SecurityContext 是从 SecurityContextHolder 中获得的。SecurityContext 包含一个 Authentication 对象。
Authentication
Authentication 接口在Spring Security中主要有两个作用。
对
AuthenticationManager的一个输入,用于提供用户为验证而提供的凭证。当在这种情况下使用时,isAuthenticated()返回false。代表当前认证的用户。你可以从 SecurityContext 中获得当前的
Authentication。
认证(Authentication)包含了:
principal: 识别用户。当用用户名/密码进行认证时,这通常是UserDetails的一个实例。credentials: 通常是一个密码。在许多情况下,这在用户被认证后被清除,以确保它不会被泄露。authorities:GrantedAuthority实例是用户被授予的高级权限。两个例子是角色(role)和作用域(scope)
GrantedAuthority
你可以从 Authentication.getAuthorities()法中获得 GrantedAuthority 实例。这个方法提供了一个 GrantedAuthority 对象的集合。例如 ROLE_ADMINISTRATOR 或 ROLE_HR_SUPERVISOR。
AuthenticationManager
AuthenticationManager 是定义 Spring Security 的 Filter 如何执行认证的API。返回的 认证(Authentication) 是由调用 AuthenticationManager 的控制器(即 Spring Security的 Filter 实例)在 SecurityContextHolder上设置的。
AuthenticationManager 最常见的实现是ProviderManager。
ProviderManager
ProviderManager是最常用的AuthenticationManager的实现。ProviderManager 委托给一个 List``AuthenticationProvider 实例。每个 AuthenticationProvider 都有机会表明认证应该是成功的、失败的,或者表明它不能做出决定并允许下游的 AuthenticationProvider 来决定。如果配置的 AuthenticationProvider 实例中没有一个能进行认证,那么认证就会以 ProviderNotFoundException 而失败,这是一个特殊的 AuthenticationException,表明 ProviderManager 没有被配置为支持被传入它的 Authentication 类型。

在实践中,每个 AuthenticationProvider 都知道如何执行特定类型的认证(Authentication)。例如,一个 AuthenticationProvider 可能能够验证一个用户名/密码,而另一个可能能够验证一个 SAML 断言。这让每个 AuthenticationProvider 在支持多种类型的认证的同时,可以做一种非常具体的认证类型,并且只暴露一个 AuthenticationManager Bean。
可以一个 ProviderManager 的实例,也可以多个 ProviderManager 实例可能共享同一个父级 AuthenticationManager。。

AuthenticationProvider
你可以在 ProviderManager 中注入多个 AuthenticationProvider实例。每个 AuthenticationProvider 都执行一种特定类型的认证。例如, DaoAuthenticationProvider支持基于用户名/密码的认证,而 JwtAuthenticationProvider 支持认证 JWT令牌。
AuthenticationEntryPoint 请求凭证
AuthenticationEntryPoint用于发送一个要求客户端提供凭证的HTTP响应。
有时,客户端会主动包含凭证(如用户名和密码)来请求资源。在这些情况下,Spring Security 不需要提供要求客户端提供凭证的HTTP响应,因为这些凭证已经被包括在内。
在其他情况下,客户端向他们未被授权访问的资源发出未经认证的请求。在这种情况下, AuthenticationEntryPoint 的实现被用来请求客户端的凭证。 AuthenticationEntryPoint 的实现可能会执行重定向到一个登录页面,用 WWW-Authenticate 头来响应,或采取其他行动。
AbstractAuthenticationProcessingFilter

AbstractAuthenticationProcessingFilter 被用作验证用户凭证的基础 Filter。在认证凭证之前,Spring Security 通常通过使用AuthenticationEntryPoint来请求凭证。
1️⃣ 当用户提交他们的凭证时,AbstractAuthenticationProcessingFilter 会从 HttpServletRequest 中创建一个要认证的Authentication。创建的认证的类型取决于 AbstractAuthenticationProcessingFilter 的子类。例如,UsernamePasswordAuthenticationFilter从 HttpServletRequest 中提交的 username 和 password 创建一个 UsernamePasswordAuthenticationToken。
2️⃣ 接下来,Authentication被传入 AuthenticationManager,以进行认证。
3️⃣ 如果认证失败,则为 Failure。
SecurityContextHolder 被清空。
RememberMeServices.loginFail被调用。如果没有配置记住我(remember me),这就是一个无用功。AuthenticationFailureHandler被调用。参见AuthenticationFailureHandler接口。
4️⃣ 如果认证成功,则为 Success。
SessionAuthenticationStrategy被通知有新的登录。参见SessionAuthenticationStrategy接口。Authentication 是在 SecurityContextHolder上设置的。如果你需要保存
SecurityContext以便在未来的请求中自动设置,必须显式调用SecurityContextRepository#saveContext。参见SecurityContextHolderFilter类。RememberMeServices.loginSuccess被调用。如果没有配置 remember me,这就是一个无用功。ApplicationEventPublisher发布一个InteractiveAuthenticationSuccessEvent事件。AuthenticationSuccessHandler被调用。参见AuthenticationSuccessHandler接口。
DaoAuthenticationProvider
DaoAuthenticationProvider是一个 AuthenticationProvider的实现,它使用 UserDetailsService和 PasswordEncoder来验证一个用户名和密码。

1️⃣ 读取用户名和密码的认证 Filter 将 UsernamePasswordAuthenticationToken 传递给 AuthenticationManager,它由 ProviderManager实现。
2️⃣ ProviderManager 被配置为使用一个 DaoAuthenticationProvider 类型的 AuthenticationProvider。
3️⃣ DaoAuthenticationProvider 从 UserDetailsService 中查找 UserDetails。
4️⃣ DaoAuthenticationProvider 使用 PasswordEncoder 来验证上一步返回的 UserDetails 上的密码。
5️⃣ 当认证成功时,返回的 Authentication是 UsernamePasswordAuthenticationToken 类型,并且有一个委托人(principal)是由配置的 UserDetailsService 返回的 UserDetails。最终,返回的 UsernamePasswordAuthenticationToken 被认证 Filter 设置在 SecurityContextHolder上。
认证持久化
通过 SecurityContextRepository实现的。SecurityContextRepository 的默认实现是 DelegatingSecurityContextRepository,它委托给:
HttpSessionSecurityContextRepositoryRequestAttributeSecurityContextRepository
在Spring Security 6 中,默认会使用DelegatingSecurityContextRepository将 SecurityContext 保存到多个 SecurityContextRepository 委托(delegate),并允许按指定顺序从任何委托(delegate)中检索。
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ...
.securityContext((securityContext) -> securityContext
.securityContextRepository(new DelegatingSecurityContextRepository(
new RequestAttributeSecurityContextRepository(),
new HttpSessionSecurityContextRepository()
))
);
return http.build();
}注意,如果使用的是前后端分离的项目,需要配置如下:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) {
http
// ...
.sessionManagement((session) -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}上述配置是将 SecurityContextRepository 为使用 NullSecurityContextRepository,同时也是为了防止请求被保存在会话中。这时后端没有存储认证信息,需要无状态 Token,类似于 JWT 的实现。
Spring Security 授权架构

Spring Security提供的最常见的 AuthorizationManager 是 AuthorityAuthorizationManager。它被配置为在当前 Authentication 中寻找一组给定的授权。如果 Authentication 包含任何配置的授权,它将返回 positive 的 AuthorizationDecision。否则,它将返回一个 negative 的 AuthorizationDecision。
另一个管理器是 AuthenticatedAuthorizationManager。它可以用来区分匿名、完全认证和记住我认证的用户。许多网站在Remember-me认证下允许某些有限的访问,但要求用户通过登录来确认他们的身份以获得完整的访问。
很明显,你也可以实现一个自定义的 AuthorizationManager,你可以把你想要的任何访问控制逻辑放在里面。它可能是针对你的应用程序的(与业务逻辑有关),也可能实现一些安全管理逻辑。
Spring Security 主要提供 授权 HTTP 请求 和 方法注解 两种方式进行授权处理,也可以自定义授权方式。具体参考官网:
JWT 简单理解
JSON Web Token(JWT): 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。JWT 被设计为紧凑且安全的,特别适用于前后端无状态认证的场景
JWT 总共有三个部分:JWT头(header) 、有效载荷(payload) 、签名(signature)
JWT头(header):
{"alg": "HS256","typ": "JWT"}alg:表示签名使用的算法,默认为HMAC SHA256(写为HS256)
typ:表示令牌的类型,JWT令牌统一写为JWT
有效载荷(payload):
有效载荷部分规定有如下七个默认字段供选择:
iss:发行人
exp:到期时间
sub:主题
aud:用户
nbf:在此之前不可用
iat:发布时间
jti:JWT ID用于标识该JWT注意: 除以上默认字段外,还可以自定义私有字段
签名(signature):
签名实际上是一个加密的过程,是对上面两部分数据通过指定的算法生成哈希,以确保数据不会被篡改。在计算出签名哈希后,JWT头,有效载荷和签名哈希的三个部分组合成一个字符串,每个部分用"."分隔,就构成整个JWT对象。
前后端分离的认证架构设计
在了解 Spring Security 的原理之后,我们需要搭建一个前后端分离的认证授权体系。并通过 JWT Token 完成无状态的认证。
前端发起登录:

前端发起接口请求: 使用 JWT token 访问后端资源,并且加入 CSRF 跨站伪造抵御

前端设计一个 api 请求工具类:


后端 HttpSecurity 核心配置:

最后,因为 JWT Token 是无状态的,无法做到踢出等功能,可以考虑将 token 存储在 数据库 或者 Redis 中。
附录
整体代码目录设计

建议使用 Lambda DSL
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/blog/**").permitAll()
.anyRequest().authenticated()
)
.formLogin(formLogin -> formLogin
.loginPage("/login")
.permitAll()
)
.rememberMe(Customizer.withDefaults());
return http.build();
}
}不使用 Lambda 方式:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests()
.requestMatchers("/blog/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login")
.permitAll()
.and()
.rememberMe();
return http.build();
}
}使用 Lambda DSL 的好处:
以前的方式不清楚什么对象被配置了,嵌套越深,越令人困惑
一致性
密码存储最佳实践
历史遗留问题:
许多应用程序使用旧的密码编码(password encode),不能轻易迁移。
密码存储的最佳实践将再次改变。
作为一个框架,Spring Security 不能频繁地进行破坏性的改变。
Spring Security 新版引入了 DelegatingPasswordEncoder,它通过以下方式解决了所有的问题。
确保通过使用当前的密码存储建议对密码进行编码。
允许验证现代和传统格式的密码。
允许在未来升级编码
所以它的密码格式为:
{id}encodedPassword源码中查看几乎兼容了现在主流的密码存储格式:
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new LdapShaPasswordEncoder());
encoders.put("MD4", new Md4PasswordEncoder());
encoders.put("MD5", new MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_5());
encoders.put("pbkdf2@SpringSecurity_v5_8", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v4_1());
encoders.put("scrypt@SpringSecurity_v5_8", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("SHA-1", new MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256", new MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new StandardPasswordEncoder());
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_2());
encoders.put("argon2@SpringSecurity_v5_8", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
return new DelegatingPasswordEncoder(encodingId, encoders);
}样例:
{bcrypt}$2a$10$dXJ3SW6G7P50lGmMkkmwe.20cQQubK3.HZWzG3YB1tlRy.fqvM/BG
{noop}password
{pbkdf2}5d923b44a6d129f3ddf3e3c8d29412723dcbde72445e8ef6bf3b508fbf17fa4ed4d6b99ca763d8dc
{scrypt}$e0801$8bWJaSu2IKSn9Z9kM+TPXfOc/9bdYSrN1oD9qfVThWEwdRTnO7re7Ei+fUZRJ68k9lTyuTeUp4of4g24hHnazw==$OAOec05+bXxvuu/1qZ6NUR+xQYvYv7BeL1QxwRpY5Pc=
{sha256}97cde38028ad898ebc02e690819fa220e88c62e0699403e94fff291cfffaf8410849f27605abcbc0注意:如果你之前项目使用的是 BCryptPasswordEncoder ,现在使用了 DelegatingPasswordEncoder,需要将旧密码加上前缀{bcrypt},不然无法解析密码。
CSRF 跨站请求伪造攻击
Spring Security 跨站请求伪造攻击的全面支持。简单理解为在已经登录的情况下,提交一个表单请求,如果这时外部通过 js 伪造相同请求,导致数据传输给了外部非法系统。
Spring Security 大致的原理是加入一个 CsrfToken 令牌,前端每次请求后端是通过 Http Header 或者 Attribute 属性传入该值,后端进行匹配,匹配失败,接口访问失败。如下图:

大部分项目都是前后端分离的,而且每次接口认证请求完成后,CsrfAuthenticationStrategy 会将令牌清空,所以大致有两种方式实现:
方案一:后端提供一个接口,让前端获取到 CSRF Token,类似于用户登录的 Access Token,但是缺点比较明显,每次请求前都需要获取一次,性能较差,而且影响面广
后端使用 Cookie 存储 CSRF Token,前端每次请求接口从 cookie 中取出,但是前端发起连续的 api 请求时,会导致拿不到 CSRF Token,可以加入一个
CsrfCookieFilter在SessionManagementFilter之后
以下是第二种方式的实现逻辑:
1)自定义Handler 和 Filter 类
package top.flyeric.yueji.app.infrastructure.config.security.csrf;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.CsrfTokenRequestHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;
import java.util.function.Supplier;
public class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler {
private final CsrfTokenRequestHandler delegate = new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) {
this.delegate.handle(request, response, csrfToken);
}
@Override
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
if (StringUtils.hasText(request.getHeader(csrfToken.getHeaderName()))) {
return super.resolveCsrfTokenValue(request, csrfToken);
}
return this.delegate.resolveCsrfTokenValue(request, csrfToken);
}
}
@Slf4j
public class CsrfCookieFilter extends OncePerRequestFilter {
private final CsrfTokenRepository csrfTokenRepository;
public CsrfCookieFilter(CsrfTokenRepository csrfTokenRepository) {
this.csrfTokenRepository = csrfTokenRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 从请求中获取 CSRF token
CsrfToken csrfToken = csrfTokenRepository.loadToken(request);
// 如果没有 CSRF token,则生成一个新的
if (csrfToken == null) {
csrfToken = csrfTokenRepository.generateToken(request);
csrfTokenRepository.saveToken(csrfToken, request, response);
}
log.info("[CsrfCookieFilter] add csrf token to cookie");
filterChain.doFilter(request, response);
}
}2)后端 Http Security 配置
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse();
http
.csrf(csrf -> csrf
.ignoringRequestMatchers("/auth/**")
.csrfTokenRepository(csrfTokenRepository)
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
)
//.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterAfter(new CsrfCookieFilter(csrfTokenRepository), SessionManagementFilter.class)
// 省略其他配置。。。
;
return http.build();
}
3)前端从 cookie 中获取 CSRF token,并在每次请求时放入 Header 中:

安全相关的HTTP相应头
Spring Security提供了一套默认的安全相关的HTTP响应头,以提供安全的默认值。例如:
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Content-Type-Options: nosniff
Strict-Transport-Security: max-age=31536000 ; includeSubDomains
X-Frame-Options: DENY
X-XSS-Protection: 0