前后端分离下的认证流程
大概描述下的认证流程
在前后端分离架构中,Spring Security 的认证流程与传统的单体应用有所不同
前后端分离认证的核心特点
- 无状态:服务器不存储用户会话信息,依赖客户端携带的令牌(Token)进行身份验证
- 跨域:前后端通常部署在不同域名下,需要处理 CORS(跨域资源共享)
- 令牌交互:使用 JWT(JSON Web Token)等令牌代替传统的 Session-ID
- 分离部署:前端(如 Vue/React)和后端(Spring Boot)独立部署,通过 RESTful API 通信
认证流程可以大概说为如下:
首先,前端发起了登录的请求
- 前端收集用户输入的用户名 / 密码,通过 POST
请求发送到后端的登录接口(如
/api/login)
- 前端收集用户输入的用户名 / 密码,通过 POST
请求发送到后端的登录接口(如
后端收到请求开始进行认证处理:
- 用户名密码验证:
- Spring Security
的
UsernamePasswordAuthenticationFilter拦截登录请求 - 将用户名密码封装为
UsernamePasswordAuthenticationToken对象 - 调用
AuthenticationManager进行认证
- Spring Security
的
- AuthenticationManager 认证过程:
- 委托
UserDetailsService的loadUserByUsername()方法查询用户信息(从数据库 / 缓存) - 对比查询到的用户密码(通常通过
PasswordEncoder加密比对) - 认证成功:生成
Authentication对象(包含用户信息和权限) - 认证失败:抛出
BadCredentialsException等异常
- 委托
- 用户名密码验证:
生成并且返回令牌(Token)
- 认证成功后,后端生成 JWT 令牌(包含用户 ID、角色、过期时间等信息)
- 令牌通过签名算法(如 HS256)加密,防止篡改
- 后端将令牌返回给前端
前端存储令牌
- 前端将令牌存储在
localStorage、sessionStorage或cookie中 - 通常会同时存储令牌过期时间,以便提前刷新令牌
- 前端将令牌存储在
前端携带令牌访问受保护资源
后续请求中,前端在 HTTP 请求头中携带令牌
1
2GET /api/user/info
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
后端验证令牌
- 令牌解析与验证:
- 自定义过滤器(如
JwtAuthenticationFilter)拦截请求,提取Authorization头中的令牌 - 验证令牌的签名有效性和过期时间
- 解析令牌中的用户信息(如用户名、权限)
- 自定义过滤器(如
- 设置认证信息:
- 验证通过后,创建
Authentication对象并设置到SecurityContextHolder - 后续的权限校验(如
@PreAuthorize注解)会基于此认证信息
- 验证通过后,创建
- 令牌解析与验证:
权限认证
- 一般情况下,有用户认证,也有权限校验
- Spring Security
的
FilterSecurityInterceptor根据配置的安全规则(如antMatchers)和用户权限进行校验- 校验通过:允许访问资源,返回数据给前端
- 校验失败:返回 403 Forbidden 错误
令牌刷新
- 当令牌即将过期时,前端可调用刷新接口(如
/api/refresh-token) - 后端验证旧令牌有效性后,生成新令牌并返回
- 前端用新令牌替换旧令牌,避免用户重新登录
- 当令牌即将过期时,前端可调用刷新接口(如
Servlet 认证架构中的各个组件
Spring Security 中 Servlet 认证架构围绕一系列核心组件协同工作,实现对 Servlet 应用的安全认证与授权。
当用户发起认证请求(如提交用户名和密码)时,请求会被 Spring Security
的过滤器(如
UsernamePasswordAuthenticationFilter)拦截。过滤器会创建一个包含用户凭证的
Authentication 对象(此时 isAuthenticated() 为
false),并将其传递给
AuthenticationManager(通常是
ProviderManager)。ProviderManager
会依次调用其内部的 AuthenticationProvider 进行认证。某个
AuthenticationProvider 会通过
UserDetailsService
加载用户信息(UserDetails),然后比对用户凭证(如密码)。如果认证成功,就会生成一个包含用户详细信息和权限的
Authentication 对象(此时 isAuthenticated() 为
true),并将该对象设置到 SecurityContext
中,再由 SecurityContextHolder
进行存储。后续在同一个线程中,就可以通过
SecurityContextHolder
获取到当前认证用户的信息,用于授权等操作。而当请求处理完成后,SecurityContextHolder
中的 SecurityContext 会被清空,以保证线程安全。
SecurityContextHolder
它是 Spring Security 存储认证用户信息的核心载体,内部包含
SecurityContext。
SecurityContextHolder 是 Spring Security 存储用户 验证
细节的地方。Spring Security 并不关心SecurityContextHolder
是如何被填充的。如果它包含一个值,它就被用作当前认证的用户。
1 | SecurityContext context = SecurityContextHolder.createEmptyContext(); |
- 示例中,先创建空的
SecurityContext,然后构建Authentication对象(这里用TestingAuthenticationToken做测试,实际生产常用UsernamePasswordAuthenticationToken,需传入用户详情、密码和权限等),将Authentication设置到SecurityContext后,再把SecurityContext放入SecurityContextHolder,这样就完成了用户认证信息的存储,后续 Spring Security 可基于此进行授权等操作。要获取当前认证用户信息,可通过SecurityContextHolder.getContext()先拿到SecurityContext,再获取其中的Authentication,进而得到用户名、主体(Principal,通常是UserDetails实现类对象)、权限(Authorities)等。
1 | SecurityContext context = SecurityContextHolder.getContext(); |
默认使用
ThreadLocal来存储SecurityContext,这使得在同一线程中,SecurityContext里的认证信息对该线程内的所有方法都是可用的,无需显式传递认证参数。并且,Spring Security 的FilterChainProxy会确保在处理完请求后清空SecurityContext,避免线程安全问题。即使SecurityContext没有被明确地作为参数传递给这些方法。如果你注意在处理完当前委托人的请求后清除该线程,以这种方式使用ThreadLocal是相当安全的。Spring Security 的FilterChainProxy确保SecurityContext总是被清空。也可通过设置系统属性或调用其静态方法来改变存储策略,比如
MODE_GLOBAL(全局模式,适用于独立应用,所有线程共享安全上下文)、MODE_INHERITABLETHREADLOCAL(可继承线程本地模式,让安全线程创建的子线程也能继承相同的安全身份)。
SecurityContext
1 | import java.security.Principal; |
- 从
SecurityContextHolder中获取,它包含了当前认证用户的Authentication对象,是连接SecurityContextHolder和Authentication的桥梁,用于存储当前认证用户的相关信息。
Authentication
1 | public interface Authentication extends Principal, Serializable { |
Authentication接口在 Spring Security 认证流程中扮演着双重重要角色:作为
AuthenticationManager的输入:当用户进行认证操作时,会将包含用户凭证(如用户名、密码等)的Authentication对象传递给AuthenticationManager,此时isAuthenticated()方法返回false,表示该Authentication还未经过认证。代表当前已认证用户当认证成功后,
SecurityContext中的Authentication对象会被更新,其中包含了当前已认证用户的详细信息,此时可通过SecurityContext获取该Authentication对象来获取用户信息。Authentication包含以下关键信息:principal:用于识别用户,在基于用户名 / 密码的认证场景中,通常是UserDetails接口的实例,该接口封装了用户的核心信息,如用户名、密码、权限等。credentials:通常是用户的密码等敏感凭证信息。为了安全,在用户认证成功后,这些凭证通常会被清除,防止泄露。authorities:是GrantedAuthority实例的集合,代表用户被授予的权限,如角色(ROLE_ADMIN)、作用域(scope)等,用于后续的授权判断。
GrantedAuthority
GrantedAuthority
实例表示用户被授予的高级权限,常见的如角色(ROLE_USER、ROLE_ADMIN)和作用域(scope)等。可以通过
Authentication.getAuthorities() 方法获取
GrantedAuthority 实例的集合。
在基于用户名 / 密码的认证场景下,GrantedAuthority
实例通常由 UserDetailsService
加载。需要注意的是,GrantedAuthority
通常是应用范围的权限,并不特定于某个具体的域对象。如果要对特定域对象(如某个员工对象)进行权限控制,Spring
Security 建议使用项目自身的域对象安全功能来实现,而不是过度依赖
GrantedAuthority,否则可能会因大量特定域对象权限而耗尽内存或导致认证验证耗时过长。
- 表示在
Authentication中授予给用户的一种权限,常见的如角色(ROLE_USER、ROLE_ADMIN)、作用域(scope)等,用于后续的授权判断。
AuthenticationManager
定义了 Spring Security 的过滤器执行认证的 API,是认证的核心管理器。
AuthenticationManager是定义 Spring Security 过滤器如何执行认证的核心 API。当与 Spring Security 的过滤器集成时,成功认证后,由调用AuthenticationManager的过滤器实例将认证后的Authentication对象设置到SecurityContextHolder中。如果不与 Spring Security 的过滤器集成,也可以直接操作SecurityContextHolder来设置认证信息,而无需使用AuthenticationManager。ProviderManager是AuthenticationManager最常见的实现类,它通过委托多个AuthenticationProvider来处理不同类型的认证逻辑。
ProviderManager
ProviderManager作为AuthenticationManager的主要实现,其工作机制如下:委托多个
AuthenticationProvider:ProviderManager内部维护了一个AuthenticationProvider列表。当进行认证时,它会依次委托这些AuthenticationProvider进行认证。每个AuthenticationProvider都有机会表明认证是成功、失败,或者自己无法处理该认证类型,从而让下游的AuthenticationProvider继续尝试。如果所有配置的AuthenticationProvider都无法进行认证,就会抛出ProviderNotFoundException,这是一种特殊的AuthenticationException,表明ProviderManager没有配置支持当前传入的Authentication类型。
支持多种认证类型:每个
AuthenticationProvider都专注于特定类型的认证。例如,一个AuthenticationProvider可能处理用户名 / 密码的认证,另一个可能处理 SAML 断言的认证。这样的设计使得ProviderManager在支持多种认证类型的同时,每个AuthenticationProvider又能专注于具体的认证逻辑,且对外只需要暴露一个AuthenticationManagerBean 即可。父级
AuthenticationManager:ProviderManager还可以配置一个可选的父级AuthenticationManager。当所有内部的AuthenticationProvider都无法进行认证时,会参考父级AuthenticationManager进行认证。父级可以是任何类型的AuthenticationManager,但通常也是ProviderManager的实例。这种父子结构在存在多个SecurityFilterChain实例的场景中很常见,不同的SecurityFilterChain可以共享父级AuthenticationManager来处理共同的认证部分,同时又能通过各自的ProviderManager处理不同的认证机制。
清除敏感凭证:默认情况下,
ProviderManager会尝试从认证成功后返回的Authentication对象中清除任何敏感的凭证信息(如密码),防止这些信息在HttpSession中保留过长时间,从而提高安全性。不过,当使用用户对象缓存来提高无状态应用性能时,这可能会引发问题。例如,如果Authentication包含对缓存中UserDetails实例的引用,而该实例的凭证已被删除,就无法再针对缓存的值进行认证。此时,可以考虑在缓存实现中或创建Authentication对象的AuthenticationProvider中制作对象副本,或者禁用ProviderManager上的eraseCredentialsAfterAuthentication属性。
AuthenticationProvider
AuthenticationProvider是 Spring Security
中用于执行特定类型认证的组件。ProviderManager(AuthenticationManager最常见的实现)会委托一个AuthenticationProvider列表来进行认证工作。专注于具体的认证逻辑实现
每个AuthenticationProvider都有机会判断认证是成功、失败,或者表示自己无法处理该认证类型,从而让后续的AuthenticationProvider继续尝试
。
认证流程中的角色
- 接收认证请求:当
ProviderManager接收到一个Authentication对象(包含用户提交的认证凭据,如用户名和密码)时,会依次调用配置好的AuthenticationProvider。例如,在基于用户名和密码的认证场景中,UsernamePasswordAuthenticationToken(实现了Authentication接口)会被传递给相关的AuthenticationProvider。 - 执行认证逻辑:每个
AuthenticationProvider需要实现Authentication authenticate(Authentication authentication)方法,在这个方法中编写具体的认证逻辑。比如,一个用于用户名密码认证的AuthenticationProvider会通过UserDetailsService加载用户信息,然后对比用户输入的密码与数据库中存储的密码是否匹配。如果匹配,则返回一个完全填充好的Authentication对象(包含用户详细信息、权限等,此时isAuthenticated()方法返回true);如果不匹配或者无法处理该认证请求,则可以抛出相应的异常或者返回null,表示认证失败或者自己无法处理,让下一个AuthenticationProvider继续尝试 。 - 支持多种认证类型:Spring Security
支持多种认证方式,通过不同的
AuthenticationProvider可以轻松实现扩展。除了常见的用户名密码认证,还可以有基于 OAuth 的认证、基于 SAML 的认证等。每个AuthenticationProvider专注于一种特定类型的认证,这样的设计使得系统在处理多种认证方式时更加灵活和可维护 。
AuthenticationEntryPoint
AuthenticationEntryPoint用于处理当用户未经过认证就试图访问受保护资源时的情况,它的主要职责是从客户端请求凭证,也就是决定采取何种方式让用户进行认证
。处理用户未认证时的引导工作
想象一下,当一个未认证的用户访问被 Spring Security
保护的资源(例如,配置了@PreAuthorize注解的方法或者通过http.authorizeRequests()设置了需要认证才能访问的
URL)时,Spring Security
的过滤器链会检测到用户未认证,进而触发AuthenticationEntryPoint
。
那么常见的处理方式差不多就是这样
- 重定向到登录页面:这是一种非常常见的方式。
AuthenticationEntryPoint可以将用户的请求重定向到应用的登录页面,让用户输入用户名和密码进行认证。例如,在基于表单的认证中,通常会将用户重定向到一个 HTML 登录表单页面。 - 返回特定的 HTTP
响应:对于前后端分离的应用,可能会返回一个带有
WWW - Authenticate头的 HTTP 401 Unauthorized 响应,告知客户端(如前端应用)用户需要进行认证,并可以在响应头中提供一些认证相关的信息,比如支持的认证方案(Bearer Token 等),前端接收到该响应后可以弹出认证对话框或者引导用户进行登录操作 。 - 其他自定义操作:开发人员可以根据实际需求自定义
AuthenticationEntryPoint的行为,例如记录未认证访问的日志信息,或者根据不同的请求来源采取不同的处理策略等 。
AbstractAuthenticationProcessingFilter
它是 Spring Security
中处理用户凭证认证的基础过滤器。所有
“基于凭证的认证”(比如用户名密码登录、OAuth2
登录等),都会通过它的子类(如
UsernamePasswordAuthenticationFilter
处理用户名密码登录)来触发认证流程。
它定义了认证的高层流程,协调各个组件之间的协作,比如拦截认证请求、调用
AuthenticationManager
进行认证、处理认证成功或失败的后续操作等。
其中下图的流程被一般情况下认作是基本的用户认证流程,下面解释了从用户提交凭证,到认证成功 / 失败后各组件的行为
用户提交凭证,创建
Authentication当用户提交认证凭证(比如表单里的用户名和密码)时:
AbstractAuthenticationProcessingFilter的子类(比如UsernamePasswordAuthenticationFilter)会从HttpServletRequest中提取凭证,并创建一个待认证的Authentication对象。- 示例:
UsernamePasswordAuthenticationFilter会提取username和password,然后创建UsernamePasswordAuthenticationToken(Authentication的实现类)。
交给
AuthenticationManager做认证创建好的
Authentication(此时是 “待认证” 状态,isAuthenticated()=false)会被传递给AuthenticationManager,由它来执行核心认证逻辑(比如调用UserDetailsService查用户、比对密码等)。认证失败的后续处理
如果
AuthenticationManager认证失败(比如密码错误、用户不存在):- 清空安全上下文:
SecurityContextHolder会被清空,确保不会残留错误的认证信息。 - “记住我” 功能回调:调用
RememberMeServices.loginFail()。如果没配置 “记住我”(Remember Me),这一步相当于空操作。 - 失败处理器回调:调用
AuthenticationFailureHandler,由它处理 “失败后该做什么”(比如跳转到登录页、返回 JSON 错误信息等)。
- 清空安全上下文:
步骤 4:认证成功的后续处理
如果
AuthenticationManager认证成功(此时Authentication变为 “已认证” 状态,isAuthenticated()=true):- 会话策略通知:
SessionAuthenticationStrategy会被通知 “有新的登录”,它负责处理会话相关的逻辑(比如限制同一用户多设备登录、更新会话信息等)。 - 设置安全上下文:把 “已认证”
的
Authentication设置到SecurityContextHolder中,这样后续请求就能通过SecurityContextHolder获取用户的认证信息。- 注意:如果需要 “把安全上下文持久化到未来请求”(比如存到 Session
里),需要显式调用
SecurityContextRepository#saveContext(这一步由SecurityContextHolderFilter等组件辅助完成)。
- 注意:如果需要 “把安全上下文持久化到未来请求”(比如存到 Session
里),需要显式调用
- “记住我” 功能回调:调用
RememberMeServices.loginSuccess()。如果没配置 “记住我”,这一步也是空操作。 - 发布认证成功事件:
ApplicationEventPublisher会发布InteractiveAuthenticationSuccessEvent事件,系统中其他组件可以监听这个事件,执行自定义逻辑(比如记录登录日志)。 - 成功处理器回调:调用
AuthenticationSuccessHandler,由它处理 “成功后该做什么”(比如跳转到首页、返回 JSON 成功信息等)。
- 会话策略通知:
基于表单登录的用户名密码验证
Spring Security提供了对通过HTML表单提供用户名和密码的支持。
首先,我们看到用户是如何被重定向到登录表单的。
上图建立在 SecurityFilterChain
图上。基于SecurityFilterChain
的工作流程就是了,展示了用户未认证时,Spring Security
如何将请求重定向到登录表单的完整流程
- 用户请求受保护资源
- 首先,一个用户向其未被授权的资源(
/private)发出一个未经认证的请求。
- 首先,一个用户向其未被授权的资源(
- 权限拦截器检测到没有认证
- 请求进入
SecurityFilterChain中的FilterSecurityInterceptor(权限拦截器):- 它会检查当前用户是否有访问
/private的权限。 - 由于用户未认证(没有登录),
FilterSecurityInterceptor会抛出AccessDeniedException(访问被拒绝异常)。
- 它会检查当前用户是否有访问
- 请求进入
- 异常转换过滤器捕获异常
ExceptionTranslationFilter(异常转换过滤器)会捕获AccessDeniedException:- 它是 Spring Security 中 专门处理 “安全相关异常” 的过滤器。
- 当检测到 “用户未认证导致的访问拒绝” 时,会触发
AuthenticationEntryPoint(认证入口点)。
AuthenticationEntryPoint重定向到登录页LoginUrlAuthenticationEntryPoint(AuthenticationEntryPoint的实现类)会执行重定向逻辑:- 它会向客户端返回一个 302 重定向响应,响应头中的
Location字段设置为登录页的 URL(例如/login)。 - 客户端收到 302
重定向响应后,会自动发起新的请求,访问登录页
GET /login。
- 它会向客户端返回一个 302 重定向响应,响应头中的
- 登录控制器返回登录表单
LoginController(处理登录页请求的控制器)接收到GET /login请求后,会返回登录表单页面(例如login.html),供用户输入用户名和密码。
那么上面我们知道了如果用户没有进行认证就访问受保护的资源是什么情况了,那么我们了解一下基于表单登录的用户名密码验证是如何进行的
和之前架构的图很类似,只不过是由架构上的组件替换成了实际上在这个流程起到真正作用的组件
其中,UsernamePasswordAuthenticationFilter作为核心的过滤器,专门处理
“用户名 + 密码”
表单登录的过滤器。当用户提交登录表单时,这个过滤器会拦截请求,触发后续的认证流程。
整个流程的核心是:通过
UsernamePasswordAuthenticationFilter 捕获登录请求,交给
AuthenticationManager 做认证,再根据 “成功 / 失败”
触发不同的组件完成后续逻辑。
- 用户提交用户名和密码,创建
AuthenticationUsernamePasswordAuthenticationFilter会从HttpServletRequest中提取username和password。- 然后创建一个待认证的
Authentication对象:UsernamePasswordAuthenticationToken(它是Authentication接口的实现类)。 - 此时的
UsernamePasswordAuthenticationToken是 “未认证” 状态(isAuthenticated()=false)。
- 交给
AuthenticationManager做认证- 创建好的
UsernamePasswordAuthenticationToken会被传递给AuthenticationManager,由它来执行核心认证逻辑:AuthenticationManager会调用UserDetailsService(用户详情服务),根据username查询数据库 / 缓存中的用户信息(UserDetails)。- 然后比对 “用户提交的密码” 和 “
UserDetails中存储的密码”(通常会用PasswordEncoder做加密比对)。
- 创建好的
- 认证失败的后续处理
- 如果
AuthenticationManager认证失败(比如密码错误、用户不存在):- 清空安全上下文:
SecurityContextHolder会被清空,确保不会残留错误的认证信息。 - “记住我” 功能回调:调用
RememberMeServices.loginFail()。如果没配置 “记住我”(Remember Me),这一步相当于空操作。 - 失败处理器回调:调用
AuthenticationFailureHandler,由它处理 “失败后该做什么”(比如跳转到登录页并提示 “密码错误”、返回 JSON 错误信息等)。
- 清空安全上下文:
- 如果
- 认证成功的后续处理
- 如果
AuthenticationManager认证成功(此时UsernamePasswordAuthenticationToken变为 “已认证” 状态,isAuthenticated()=true):- 会话策略通知:
SessionAuthenticationStrategy会被通知 “有新的登录”,它负责处理会话相关的逻辑(比如限制同一用户多设备登录、更新会话信息等)。 - 设置安全上下文:把 “已认证” 的
UsernamePasswordAuthenticationToken设置到SecurityContextHolder中,这样后续请求就能通过SecurityContextHolder获取用户的认证信息。- 注意:如果需要 “把安全上下文持久化到未来请求”(比如存到 Session
里),需要显式调用
SecurityContextRepository#saveContext(这一步由SecurityContextPersistenceFilter等组件辅助完成)。
- 注意:如果需要 “把安全上下文持久化到未来请求”(比如存到 Session
里),需要显式调用
- “记住我” 功能回调:调用
RememberMeServices.loginSuccess()。如果没配置 “记住我”,这一步也是空操作。 - 发布认证成功事件:
ApplicationEventPublisher会发布InteractiveAuthenticationSuccessEvent事件,系统中其他组件可以监听这个事件,执行自定义逻辑(比如记录登录日志)。 - 成功处理器回调:调用·
AuthenticationSuccessHandler,由它处理 “成功后该做什么”(比如跳转到首页、返回 JSON 成功信息等)。- 常见实现:
SimpleUrlAuthenticationSuccessHandler会重定向到 “之前被拦截的受保护资源”(由ExceptionTranslationFilter保存的请求)。
- 常见实现:
- 会话策略通知:
- 如果
默认情况下,Spring Security表单登录被启用。然而,只要提供任何基于Servlet的配置,就必须明确提供基于表单的登录。下面的例子显示了一个最小的、明确的Java配置。
1 | import org.springframework.context.annotation.Configuration; |
当登录页面在Spring Security配置中被指定时,你要负责渲染该页面。
下面的 Thymeleaf
模板产生一个符合 /login 的登录页面的HTML登录表单。
1 |
|
关于默认的HTML表单,有几个关键点。
- 表单应该以
post方法请求/login。 - 该表单需要包含 CSRF Token,Thymeleaf 会 自动包含。
- 该表单应在一个名为
username的参数中指定用户名。 - 表单应该在一个名为
password的参数中指定密码。 - 如果发现名为
error的HTTP参数,表明用户未能提供一个有效的用户名或密码。 - 如果发现名为
logout的HTTP参数,表明用户已经成功注销。
许多用户除了定制登录页面外,并不需要更多的东西。然而,如果需要的话,你可以通过额外的配置来定制前面显示的一切。
如果你使用Spring MVC,你需要一个控制器,将 GET /login
映射到我们创建的登录模板。下面的例子展示了一个最小的
LoginController。
1 |
|
基于 Basic HTTP Authentication 的用户验证
Basic HTTP 认证是一种简单的 HTTP
认证方式,通过在请求头中携带
Authorization: Basic <base64编码的用户名:密码>
来传递凭证。Spring Security 会拦截未认证的请求,触发 Basic
认证流程。
Basic HTTP 认证的核心是:通过 WWW-Authenticate
头触发客户端提交凭证,再通过 BasicAuthenticationFilter
拦截凭证并验证。它的优点是简单易实现,适合内部系统或测试环境;缺点是凭证
Base64
编码可被解码(并非加密),且凭证在每次请求中都要传递,安全性较低,生产环境通常会结合
HTTPS 使用。
HTTP基本认证是如何在Spring Security中工作的,我们之前貌似提到过,因为我看 Spring Security 的官方文档说了,所以我也简单的看一下了
用户请求受保护资源,触发权限拦截
客户端(Client)向 Spring Web 应用发起请求,访问一个需要认证才能访问的受保护资源(例如
GET /private):- 请求进入
SecurityFilterChain中的FilterSecurityInterceptor(权限拦截器)。 - 由于用户未认证,
FilterSecurityInterceptor会抛出AccessDeniedException(访问被拒绝异常)。
- 请求进入
异常转换过滤器触发 Basic 认证入口
ExceptionTranslationFilter(异常转换过滤器)捕获AccessDeniedException后:- 检测到 “用户未认证导致的访问拒绝”,会触发
BasicAuthenticationEntryPoint(Basic 认证的入口点)。
- 检测到 “用户未认证导致的访问拒绝”,会触发
返回
WWW-Authenticate头,要求客户端提交凭证BasicAuthenticationEntryPoint会向客户端返回一个 HTTP 401 Unauthorized 响应,并在响应头中添加:1
WWW-Authenticate: Basic realm="Realm Name"
realm是一个描述性的 “领域名称”,用于告知客户端 “需要在哪个范围下进行认证”。- 客户端收到这个响应后,会弹出凭证输入框(或由代码自动生成
Authorization头),要求用户输入用户名和密码。
当客户端收到 WWW-Authenticate
头时,它知道它应该用用户名和密码开始进行 Basic
认证。下面的图片显示了正在处理的用户名和密码的流程。
客户端提交 Basic 凭证,
BasicAuthenticationFilter拦截用户输入用户名和密码后,客户端会将凭证进行 Base64 编码(格式:
用户名:密码→ Base64 编码),并在请求头中添加,这个携带凭证的请求会被BasicAuthenticationFilter拦截。创建
Authentication,交给AuthenticationManager认证BasicAuthenticationFilter会从Authorization头中提取 Base64 编码的凭证,解码后得到用户名和密码,并创建UsernamePasswordAuthenticationToken(Authentication的实现类)。- 然后将
UsernamePasswordAuthenticationToken传递给AuthenticationManager,由它执行核心认证逻辑(比如调用UserDetailsService查用户、比对密码等)。
- 然后将
认证失败的后续处理
- 如果
AuthenticationManager认证失败(比如密码错误、用户不存在):- 清空安全上下文:
SecurityContextHolder会被清空。 - “记住我” 功能回调:调用
RememberMeServices(如果配置了 “记住我”)。 - 触发认证入口点:再次调用
BasicAuthenticationEntryPoint,让客户端重新提交凭证。
- 清空安全上下文:
- 如果
认证成功的后续处理
- 如果
AuthenticationManager认证成功(凭证有效):- 设置安全上下文:把 “已认证” 的
UsernamePasswordAuthenticationToken设置到SecurityContextHolder中。 - “记住我” 功能回调:调用
RememberMeServices(如果配置了 “记住我”)。 - 继续业务流程:请求会继续向下传递,访问原本请求的受保护资源(例如
GET /private)。
- 设置安全上下文:把 “已认证” 的
- 如果
默认情况下,Spring Security的HTTP Basic认证支持已经启用。然而,只要提供任何基于Servlet的配置,就必须明确提供HTTP Basic。
下面的例子显示了一个最小的、明确的配置。
1 | import org.springframework.context.annotation.Bean; |
Realm 名称会显示在浏览器的认证弹窗中,帮助用户识别需要哪个系统的凭证。
前后端分离的认证代码实现
用户认证成功的代码实现
我们先创建项目的新模块,把依赖导入好后,整体的模型和数据库什么的都用和之前基于数据库的用户认证的形式一样
所以,我们还是先要写继承UserDetailsService的CustomUserDetailsService来自定义用户详情服务
1 | package hbnu.project.separatedsecurity.security; |
好像没什么差别,因为我们还没写认证失败或者其他的内容,接下来我们继续写配置类
1 |
|
首先,前后端分离的核心痛点之一是 “Session 共享问题”,所以我们使用了 JWT 进行无状态认证,彻底摆脱对 Session 的依赖,这是前后端分离认证的标志性设计。
在
SecurityConfig的filterChain中,将JwtAuthenticationFilter添加到UsernamePasswordAuthenticationFilter之前:- 作用:每次请求都会先经过 JWT 过滤器,过滤器从请求头(如
Authorization: Bearer {token})中提取 JWT,验证有效性后解析用户信息,直接构建认证对象(Authentication),无需查询 Session。 - 服务端无需存储任何会话信息(无状态),前端只需在请求头携带 JWT 即可完成认证
- 作用:每次请求都会先经过 JWT 过滤器,过滤器从请求头(如
明确配置会话为 无状态(STATELESS):禁用 Spring Security 的 Session 创建逻辑,服务端不会为任何请求创建 Session,完全依赖 JWT 进行身份识别。
1 | .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) |
编写好对应的接口
1 | package hbnu.project.separatedsecurity.controller; |
把前端页面写好,这里我没有使用各种框架,毕竟是个小东西,拿 npx 直接跑了,但是这里 npx 注意跨域的问题
用户认证失败的代码实现
上述我们比较熟悉的用户认证,那么,认证失败了需要进行页面的跳转,认证失败次数过多要限制你的登录,这些内容都是要如何进行实现呢
那么要实现限制登陆,就要修改我们的实体类,添加相关的字段
1 |
|
编写一个认证失败处理服务,在这里进行认证失败后的逻辑
1 | /** |
我们需要添加检查账户状态的情况了,在loadUserByUsername方法中
1 | /** |
更新AuthController来集成认证失败处理,主要是更新登录方法,添加认证失败处理逻辑:
1 | /** |
前端懒得写了,就放在这里吧
用户注销处理的代码实现
简单的注销我们在之前其实已经有所展示了
1 |
|
这样其实是相对推荐的,如果你在用户注销的部分没什么需要处理的逻辑,这样性能好,无服务器端状态管理,而且符合符合JWT无状态设计原则,但是这样无法立即阻止被盗用的令牌,令牌可能会在过期之前被拿到然后重复使用
我们创建一个令牌黑名单,管理已注销的令牌,防止被重复使用
1 | /** |
更新JwtUtil以支持令牌唯一标识
1 | /** |
最后我们更新AuthController的注销方法,支持我们理解的注销
1 | /** |
那么,jwt令牌如果在黑名单里,如何才能被检测出来呢?所以我们需要在JWT过滤器中检查令牌状态:
1 | // 在JwtAuthenticationFilter的doFilterInternal方法中添加黑名单检查 |
如果再进一步,就可以引入 redis,这里为了保持简单就没用
请求未认证的代码实现
在 Spring Security 中,“请求未认证” 是指用户未登录(或登录状态失效)时访问需要认证的接口,此时需要通过 “自定义认证失败处理器” 或使用默认配置来统一处理响应(如返回标准化 JSON、跳转登录页等)。
核心实现思路是:拦截 “未认证” 事件,覆盖 Spring Security 的默认行为,返回符合业务需求的响应。
在 Spring Security 中,触发 “未认证” 的核心场景有 2 类,实现代码需覆盖这些场景:
- 匿名用户访问需要认证的接口:例如未登录用户访问
/api/user/info(该接口需authenticated()权限); - 已登录用户会话失效:例如 JWT 过期、Session 过期后,用户继续访问需要认证的接口。
Spring Security 对未认证请求的默认处理是:
- 若为 浏览器请求(如 GET
页面):自动跳转登录页(默认
/login); - 若为 API 请求(如 POST/JSON):返回
403 Forbidden或401 Unauthorized原生响应
因此,我们需要自定义实现,将未认证响应统一为
标准化格式(如 JSON 结构
{"code":401,"msg":"未登录或登录已过期","data":null})。
在Spring Security中,未认证请求的处理涉及以下几个关键组件:
- AuthenticationEntryPoint:处理未认证请求的入口点
- AccessDeniedHandler:处理已认证但权限不足的请求
- ExceptionTranslationFilter:异常转换过滤器
- JWT认证过滤器:自定义的JWT验证逻辑
Spring Security 中,处理 “未认证请求” 的核心接口是
AuthenticationEntryPoint,其作用是:当用户尝试访问需要认证的资源但未认证时,触发该接口的
commence() 方法,生成响应。
所以,我们需要实现 AuthenticationEntryPoint 接口
1 | /** |
我们也可以编写一个自定义访问拒绝处理器,返回更友好的信息
1 | /** |
然后,将自定义的 CustomUnauthorizedEntryPoint 注入
SecurityFilterChain,覆盖默认的未认证处理逻辑:
1 |
|
若项目使用 JWT 认证,未认证场景可能包含 “JWT 过期”“JWT 格式错误” 等细分情况,更新JWT认证过滤器以提供更详细的未认证处理:
1 | // 在JwtAuthenticationFilter中的关键部分 |
这样这个 jwt 的过滤器被注入之后,未认证的场景就会更加清晰
实现未认证处理时,需避免与 “权限不足” 混淆,两者的核心区别:
- 未认证(401):用户没登录(或登录失效),对应
AuthenticationEntryPoint; - 权限不足(403):用户已登录,但没有访问该接口的权限(如普通用户访问
/api/admin/delete),对应AccessDeniedHandler。
无论使用哪种方法,Spring Security 处理 “请求未认证” 的核心流程都是:
- 拦截未认证事件:通过
AuthenticationEntryPoint接口捕获 “未认证请求”; - 自定义响应格式:设置响应类型(JSON/HTML)、状态码(401)、响应体(标准化结构);
- 注入 Security 配置:在
SecurityFilterChain的exceptionHandling中关联自定义处理器。
其中,方法 1(实现 AuthenticationEntryPoint 接口) 是最推荐的和最常用的
跨越处理
上面我们只是简单的讲解了跨域相关的配置,那么什么是跨域处理,为什么需要跨域处理,什么时候使用跨域处理
在前后端分离架构成为主流的今天,“跨域” 是开发者绕不开的核心问题
前端(如 http://localhost:8080)和后端(如
http://localhost:8081)通常运行在不同域名 /
端口下,浏览器会触发 跨域资源共享(CORS)
限制,这并非后端拒绝响应,而是浏览器基于 “同源策略”
的安全保护机制。这时候我们需要进行跨域配置。
浏览器的同源策略
什么是浏览器的 同源策略(Same-Origin Policy),这是浏览器最核心的安全规则之一,也是跨域问题的 “根源”。
“同源” 指的是前端页面的 协议、域名、端口 三者完全一致。只要其中任意一项不同,就属于 “跨源”(即跨域)。
举个例子:假设前端页面地址为
http://localhost:8080,判断以下后端接口地址是否跨域:
| 后端接口地址 | 协议 | 域名 | 端口 | 是否跨域 | 原因分析 |
|---|---|---|---|---|---|
http://localhost:8080/api |
HTTP | localhost | 8080 | 否 | 协议、域名、端口完全一致 |
https://localhost:8080/api |
HTTPS | localhost | 8080 | 是 | 协议不同(HTTP vs HTTPS) |
http://127.0.0.1:8080/api |
HTTP | 127.0.0.1 | 8080 | 是 | 域名不同(localhost vs 127.0.0.1,即使指向同一 IP) |
http://localhost:8081/api |
HTTP | localhost | 8081 | 是 | 端口不同(8080 vs 8081) |
http://test.com:8080/api |
HTTP | test.com | 8080 | 是 | 域名不同(localhost vs test.com) |
浏览器为什么要限制跨域请求?核心是为了防止 “恶意网站窃取用户数据”。
举个场景:
- 你登录了银行网站
https://bank.com,浏览器会保存银行的登录 Cookie(包含你的身份信息); - 此时你不小心打开了一个恶意网站
https://evil.com,如果没有同源策略,evil.com可以发起请求到https://bank.com/transfer(转账接口),浏览器会自动携带银行的 Cookie,导致恶意转账。
同源策略通过限制 “非同源网站无法读取对方的 Cookie、LocalStorage 或发起请求”,从根源上阻断了这类攻击。
很多开发者误以为 “跨域时请求没发到后端”,这是一个常见误区。实际上:
- 跨域请求 会发送到后端,后端也会正常处理并返回响应;
- 但浏览器在接收到后端响应后,会检查 “后端是否允许当前前端域名访问”——
如果没有明确允许,浏览器会
拦截响应并丢弃,同时在控制台抛出跨域错误(如
Access to XMLHttpRequest at 'xxx' from origin 'xxx' has been blocked by CORS policy)。
这就好比:你(前端)想给朋友(后端)打电话,电话打通了(请求发送成功),朋友也说了话(后端返回响应),但中间的运营商(浏览器)因为没有 “通话许可”,把朋友的声音掐断了(拦截响应)。
跨域处理是前后端分离的必然需求
在传统单体应用中(如
JSP、Thymeleaf),前端页面和后端接口运行在同一域名 / 端口下(例如
http://localhost:8080
既返回页面,也提供接口),天然同源,无需处理跨域。
但前后端分离架构的核心是 “前端和后端独立部署”:
- 前端:通常部署在静态资源服务器(如 Nginx、CDN),地址可能是
https://frontend.com或http://localhost:8080(开发环境); - 后端:部署在应用服务器,地址可能是
https://backend.com或http://localhost:8081(开发环境)。
这种 “分离部署” 必然导致 协议、域名、端口三者至少有一个不同,跨域成为常态。如果不做跨域处理,前端无法获取后端接口的响应,整个业务流程(如登录、查询数据)会完全中断。
简单来说:没有跨域处理,前后端分离架构就无法落地。
要解决跨域问题,不能 “绕过” 同源策略(否则会破坏浏览器安全),而是要通过 CORS(Cross-Origin Resource Sharing,跨域资源共享)协议 让后端 “明确授权” 前端访问 —— 相当于后端给浏览器发了一张 “通行证”,告诉浏览器:“这个前端域名是安全的,允许它获取我的响应”。
CORS 协议的核心逻辑是:
- 前端发起跨域请求时,浏览器会在请求头中添加
Origin字段(值为前端域名,如http://localhost:8080); - 后端收到请求后,检查
Origin是否在 “允许的源” 列表中; - 若允许,后端在响应头中添加
Access-Control-*系列字段(如Access-Control-Allow-Origin: http://localhost:8080); - 浏览器收到响应后,检查是否有这些
Access-Control-*字段 —— 若有且符合规则,就放行响应;若无,则拦截响应并抛出跨域错误。
配置跨域配置
常见的跨域配置内容如下:
| 配置项 | 作用说明 | 常见取值示例 |
|---|---|---|
| 允许的源(Allowed Origins) | 限制 “哪些前端地址” 可以发起跨域请求(浏览器会校验请求的来源是否在列表中) | - 精确匹配:http://localhost:8080(仅允许本地 8080
端口的前端)-
模糊匹配:http://localhost:*(允许本地任意端口)-
全部允许:*(开发环境临时用,生产环境禁止,因会与
“允许凭证” 冲突) |
| 允许的请求方法(Allowed Methods) | 限制 “哪些 HTTP 方法” 可以跨域发起(如 GET/POST/PUT/DELETE 等) | GET, POST, PUT, DELETE, OPTIONS(覆盖 CRUD
和预检请求方法) |
| 允许的请求头(Allowed Headers) | 限制 “哪些自定义请求头” 可以携带(如 JWT 的 Authorization 头、自定义业务头) | - 全部允许:*(简化配置)-
精确列举:Authorization, Content-Type, X-Requested-With(生产环境推荐,更安全) |
| 暴露的响应头(Exposed Headers) | 控制 “哪些响应头” 可以被前端 JavaScript 读取(默认仅允许 Cache-Control 等少数头) | Content-Length, X-Total-Count(如分页场景下,前端需要读取响应头中的总条数) |
| 允许携带凭证(Allow Credentials) | 控制前端请求是否可以携带 “Cookie/Authorization 头” 等凭证信息 | - true:允许(如需要登录状态保持、携带 JWT
的场景,必须配置)-
false:不允许(默认值,适合无需身份验证的公开接口) |
| 预检请求缓存时间(Max Age) | 控制 “预检请求(OPTIONS 请求)” 的缓存时长(避免频繁发起 OPTIONS 请求,提升性能) | 3600(单位:秒,即 1
小时内同一跨域请求无需重复发预检) |
浏览器对 “非简单请求”(如带自定义头、PUT/DELETE 方法、请求体为 JSON 格式)会先发起一次 OPTIONS 预检请求,验证后端是否允许该跨域请求。上述核心配置项(尤其是 Allowed Methods/Headers)本质就是为预检请求提供 “允许凭证”
在实际配置跨域时,有两个核心概念需要掌握:简单请求与非简单请求,以及 预检请求(OPTIONS 请求)—— 这直接决定了跨域配置是否能覆盖所有场景。
简单请求:无需预检,直接发起
如果跨域请求满足以下所有条件,属于 “简单请求”,浏览器会直接发起请求,无需额外处理:
请求方法:仅限 GET、POST、HEAD 三种;
请求头:仅包含浏览器默认头(如
Accept、Accept-Language、Content-Type),且Content-Type仅限application/x-www-form-urlencoded、multipart/form-data、text/plain三种;无自定义请求头:不包含如
Authorization(JWT 头)、X-Token等自定义头。就像
1
2
3
4
5
6
7
8// 简单请求:POST 方法 + Content-Type: application/x-www-form-urlencoded
fetch('http://localhost:8081/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: 'username=admin&password=123456'
});对于简单请求,后端只需配置
Access-Control-Allow-Origin(允许的源)和Access-Control-Allow-Credentials(是否允许携带凭证)即可。
非简单请求:先预检,再发起
如果跨域请求不满足 “简单请求” 的条件,属于 “非简单请求”,浏览器会在发起真实请求前,先发起一次 预检请求(OPTIONS 请求)—— 相当于 “提前问后端:我要发这个请求,你允许吗?”。
常见的非简单请求场景:
请求方法为 PUT、DELETE、PATCH 等;
Content-Type为application/json(前后端分离常用的 JSON 格式请求体);请求头包含自定义字段(如
Authorization: Bearer {JWT})。就像
1
2
3
4
5
6
7
8
9// 非简单请求:POST 方法 + Content-Type: application/json + 自定义 Authorization 头
fetch('http://localhost:8081/api/user/info', {
method: 'POST',
headers: {
'Content-Type': 'application/json', // 非简单请求的 Content-Type
'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' // 自定义头
},
body: JSON.stringify({ id: 1 })
});
预检请求的流程:
- 浏览器发送 OPTIONS 请求到后端,请求头中包含:
Origin:前端域名;Access-Control-Request-Method:真实请求的方法(如 POST);Access-Control-Request-Headers:真实请求的自定义头(如 Authorization);
- 后端收到 OPTIONS
请求后,检查这些字段是否在允许列表中,然后在响应头中返回:
Access-Control-Allow-Origin:允许的前端域名;Access-Control-Allow-Methods:允许的请求方法(如 GET, POST, PUT, DELETE);Access-Control-Allow-Headers:允许的自定义头(如 Authorization, Content-Type);Access-Control-Max-Age:预检请求的缓存时间(如 3600 秒,1 小时内无需重复发预检);
- 浏览器收到预检响应后,若检查通过,才会发起真实的请求;若不通过,则直接拦截,不发起真实请求。
这就是为什么之前的跨域配置中需要包含 Allowed Methods 和
Allowed Headers—— 本质是为了给预检请求提供 “允许凭证”。
配置跨域配置容易产生的问题
在实际项目中,跨域配置很容易出现 “开发环境正常,生产环境报错” 的情况,核心是因为忽略了以下细节:
禁止使用 *
允许全部源(生产环境)
开发环境中,为了方便,可能会配置
Access-Control-Allow-Origin: *(允许所有前端域名访问),但生产环境必须改为
精确的前端域名(如
https://frontend.yourcompany.com)。
原因:* 与
“允许携带凭证”(Access-Control-Allow-Credentials: true)冲突
—— 如果后端配置了 *,同时前端请求携带了 Cookie 或
JWT,浏览器会直接拦截响应,因为 “允许所有源”+“携带凭证”
存在安全风险(任意网站都能发起请求并携带用户凭证)。
区分 “允许的源” 和 “暴露的响应头”
Access-Control-Allow-Origin:控制 “哪些前端能发请求”;Access-Control-Expose-Headers:控制 “前端能读取哪些后端响应头”。
默认情况下,前端只能读取后端响应头中的
Cache-Control、Content-Length、Content-Type
等少数字段。如果后端需要返回自定义响应头(如分页场景的
X-Total-Count,表示总条数),必须在
Access-Control-Expose-Headers 中明确配置,否则前端无法通过
response.headers.get('X-Total-Count') 获取该值。
确保 OPTIONS 请求不被拦截
预检请求(OPTIONS)是 “无业务含义” 的请求,后端不能将其当作普通请求拦截(如登录验证、权限校验)。
例如,在 Spring Security 中,需要明确放行 OPTIONS 请求:
1 | .authorizeHttpRequests(authz -> authz |
如果 OPTIONS 请求被拦截(返回 401 或 403),浏览器会认为预检失败,不会发起真实请求。
避免重复配置
跨域配置只需在 一个层级
生效(如要么后端代码配置,要么 Nginx 配置),不要同时在后端和 Nginx
中配置 Access-Control-* 字段 ——
重复配置可能导致响应头中出现多个相同字段(如多个
Access-Control-Allow-Origin),浏览器无法识别,从而拦截响应。







