前后端分离下的认证流程

大概描述下的认证流程

在前后端分离架构中,Spring Security 的认证流程与传统的单体应用有所不同

前后端分离认证的核心特点

  • 无状态:服务器不存储用户会话信息,依赖客户端携带的令牌(Token)进行身份验证
  • 跨域:前后端通常部署在不同域名下,需要处理 CORS(跨域资源共享)
  • 令牌交互:使用 JWT(JSON Web Token)等令牌代替传统的 Session-ID
  • 分离部署:前端(如 Vue/React)和后端(Spring Boot)独立部署,通过 RESTful API 通信

认证流程可以大概说为如下:

  • 首先,前端发起了登录的请求

    • 前端收集用户输入的用户名 / 密码,通过 POST 请求发送到后端的登录接口(如/api/login
  • 后端收到请求开始进行认证处理:

    • 用户名密码验证
      • Spring Security 的UsernamePasswordAuthenticationFilter拦截登录请求
      • 将用户名密码封装为UsernamePasswordAuthenticationToken对象
      • 调用AuthenticationManager进行认证
    • AuthenticationManager 认证过程
      • 委托UserDetailsServiceloadUserByUsername()方法查询用户信息(从数据库 / 缓存)
      • 对比查询到的用户密码(通常通过PasswordEncoder加密比对)
      • 认证成功:生成Authentication对象(包含用户信息和权限)
      • 认证失败:抛出BadCredentialsException等异常
  • 生成并且返回令牌(Token)

    • 认证成功后,后端生成 JWT 令牌(包含用户 ID、角色、过期时间等信息)
    • 令牌通过签名算法(如 HS256)加密,防止篡改
    • 后端将令牌返回给前端
  • 前端存储令牌

    • 前端将令牌存储在localStoragesessionStoragecookie
    • 通常会同时存储令牌过期时间,以便提前刷新令牌
  • 前端携带令牌访问受保护资源

    • 后续请求中,前端在 HTTP 请求头中携带令牌

      1
      2
      GET /api/user/info
      Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  • 后端验证令牌

    1. 令牌解析与验证
      • 自定义过滤器(如JwtAuthenticationFilter)拦截请求,提取Authorization头中的令牌
      • 验证令牌的签名有效性和过期时间
      • 解析令牌中的用户信息(如用户名、权限)
    2. 设置认证信息
      • 验证通过后,创建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

image-20250926144259096

SecurityContextHolder 是 Spring Security 存储用户 验证 细节的地方。Spring Security 并不关心SecurityContextHolder 是如何被填充的。如果它包含一个值,它就被用作当前认证的用户。

1
2
3
4
5
6
SecurityContext context = SecurityContextHolder.createEmptyContext();
Authentication authentication =
new TestingAuthenticationToken("username", "password", "ROLE_USER");
context.setAuthentication(authentication);

SecurityContextHolder.setContext(context);
  • 示例中,先创建空的 SecurityContext,然后构建 Authentication 对象(这里用 TestingAuthenticationToken 做测试,实际生产常用 UsernamePasswordAuthenticationToken,需传入用户详情、密码和权限等),将 Authentication 设置到 SecurityContext 后,再把 SecurityContext 放入 SecurityContextHolder,这样就完成了用户认证信息的存储,后续 Spring Security 可基于此进行授权等操作。要获取当前认证用户信息,可通过 SecurityContextHolder.getContext() 先拿到 SecurityContext,再获取其中的 Authentication,进而得到用户名、主体(Principal,通常是 UserDetails 实现类对象)、权限(Authorities)等。
1
2
3
4
5
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Object principal = authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
  • 默认使用 ThreadLocal 来存储 SecurityContext,这使得在同一线程中,SecurityContext 里的认证信息对该线程内的所有方法都是可用的,无需显式传递认证参数。并且,Spring Security 的 FilterChainProxy 会确保在处理完请求后清空 SecurityContext,避免线程安全问题。即使 SecurityContext 没有被明确地作为参数传递给这些方法。如果你注意在处理完当前委托人的请求后清除该线程,以这种方式使用 ThreadLocal 是相当安全的。Spring Security 的FilterChainProxy确保 SecurityContext 总是被清空。

  • 也可通过设置系统属性或调用其静态方法来改变存储策略,比如 MODE_GLOBAL(全局模式,适用于独立应用,所有线程共享安全上下文)、MODE_INHERITABLETHREADLOCAL(可继承线程本地模式,让安全线程创建的子线程也能继承相同的安全身份)。

SecurityContext

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.security.Principal;

public interface SecurityContext {
SecurityContext NONE = new SecurityContext() {
public Principal getPrincipal() {
return null;
}

public boolean isUserInRole(String role) {
return false;
}
};

Principal getPrincipal();

boolean isUserInRole(String role);
}
  • SecurityContextHolder 中获取,它包含了当前认证用户的 Authentication 对象,是连接 SecurityContextHolderAuthentication 的桥梁,用于存储当前认证用户的相关信息。

Authentication

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();

Object getCredentials();

Object getDetails();

Object getPrincipal();

boolean isAuthenticated();

void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException;
}
  • 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_USERROLE_ADMIN)和作用域(scope)等。可以通过 Authentication.getAuthorities() 方法获取 GrantedAuthority 实例的集合。

在基于用户名 / 密码的认证场景下,GrantedAuthority 实例通常由 UserDetailsService 加载。需要注意的是,GrantedAuthority 通常是应用范围的权限,并不特定于某个具体的域对象。如果要对特定域对象(如某个员工对象)进行权限控制,Spring Security 建议使用项目自身的域对象安全功能来实现,而不是过度依赖 GrantedAuthority,否则可能会因大量特定域对象权限而耗尽内存或导致认证验证耗时过长。

  • 表示在 Authentication 中授予给用户的一种权限,常见的如角色(ROLE_USERROLE_ADMIN)、作用域(scope)等,用于后续的授权判断。

AuthenticationManager

  • 定义了 Spring Security 的过滤器执行认证的 API,是认证的核心管理器。

  • AuthenticationManager 是定义 Spring Security 过滤器如何执行认证的核心 API。当与 Spring Security 的过滤器集成时,成功认证后,由调用 AuthenticationManager 的过滤器实例将认证后的 Authentication 对象设置到 SecurityContextHolder 中。如果不与 Spring Security 的过滤器集成,也可以直接操作 SecurityContextHolder 来设置认证信息,而无需使用 AuthenticationManager

  • ProviderManagerAuthenticationManager 最常见的实现类,它通过委托多个 AuthenticationProvider 来处理不同类型的认证逻辑。

ProviderManager

  • ProviderManager 作为 AuthenticationManager 的主要实现,其工作机制如下:

    • 委托多个 AuthenticationProviderProviderManager 内部维护了一个 AuthenticationProvider 列表。当进行认证时,它会依次委托这些 AuthenticationProvider 进行认证。每个 AuthenticationProvider 都有机会表明认证是成功、失败,或者自己无法处理该认证类型,从而让下游的 AuthenticationProvider 继续尝试。如果所有配置的 AuthenticationProvider 都无法进行认证,就会抛出 ProviderNotFoundException,这是一种特殊的 AuthenticationException,表明 ProviderManager 没有配置支持当前传入的 Authentication 类型。

      image-20250926144342384
    • 支持多种认证类型:每个 AuthenticationProvider 都专注于特定类型的认证。例如,一个 AuthenticationProvider 可能处理用户名 / 密码的认证,另一个可能处理 SAML 断言的认证。这样的设计使得 ProviderManager 在支持多种认证类型的同时,每个 AuthenticationProvider 又能专注于具体的认证逻辑,且对外只需要暴露一个 AuthenticationManager Bean 即可。

    • 父级 AuthenticationManagerProviderManager 还可以配置一个可选的父级 AuthenticationManager。当所有内部的 AuthenticationProvider 都无法进行认证时,会参考父级 AuthenticationManager 进行认证。父级可以是任何类型的 AuthenticationManager,但通常也是 ProviderManager 的实例。这种父子结构在存在多个 SecurityFilterChain 实例的场景中很常见,不同的 SecurityFilterChain 可以共享父级 AuthenticationManager 来处理共同的认证部分,同时又能通过各自的 ProviderManager 处理不同的认证机制。

      image-20250926144404007
      image-20250926144410729
    • 清除敏感凭证:默认情况下,ProviderManager 会尝试从认证成功后返回的 Authentication 对象中清除任何敏感的凭证信息(如密码),防止这些信息在 HttpSession 中保留过长时间,从而提高安全性。不过,当使用用户对象缓存来提高无状态应用性能时,这可能会引发问题。例如,如果 Authentication 包含对缓存中 UserDetails 实例的引用,而该实例的凭证已被删除,就无法再针对缓存的值进行认证。此时,可以考虑在缓存实现中或创建 Authentication 对象的 AuthenticationProvider 中制作对象副本,或者禁用 ProviderManager 上的 eraseCredentialsAfterAuthentication 属性。

AuthenticationProvider

AuthenticationProvider是 Spring Security 中用于执行特定类型认证的组件。ProviderManagerAuthenticationManager最常见的实现)会委托一个AuthenticationProvider列表来进行认证工作。专注于具体的认证逻辑实现

每个AuthenticationProvider都有机会判断认证是成功、失败,或者表示自己无法处理该认证类型,从而让后续的AuthenticationProvider继续尝试 。

认证流程中的角色

  1. 接收认证请求:当ProviderManager接收到一个Authentication对象(包含用户提交的认证凭据,如用户名和密码)时,会依次调用配置好的AuthenticationProvider 。例如,在基于用户名和密码的认证场景中,UsernamePasswordAuthenticationToken(实现了Authentication接口)会被传递给相关的AuthenticationProvider
  2. 执行认证逻辑:每个AuthenticationProvider需要实现Authentication authenticate(Authentication authentication)方法,在这个方法中编写具体的认证逻辑。比如,一个用于用户名密码认证的AuthenticationProvider会通过UserDetailsService加载用户信息,然后对比用户输入的密码与数据库中存储的密码是否匹配。如果匹配,则返回一个完全填充好的Authentication对象(包含用户详细信息、权限等,此时isAuthenticated()方法返回true);如果不匹配或者无法处理该认证请求,则可以抛出相应的异常或者返回null,表示认证失败或者自己无法处理,让下一个AuthenticationProvider继续尝试 。
  3. 支持多种认证类型: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 进行认证、处理认证成功或失败的后续操作等。

其中下图的流程被一般情况下认作是基本的用户认证流程,下面解释了从用户提交凭证,到认证成功 / 失败后各组件的行为

image-20250926144817192
  • 用户提交凭证,创建 Authentication

    当用户提交认证凭证(比如表单里的用户名和密码)时:

    • AbstractAuthenticationProcessingFilter 的子类(比如 UsernamePasswordAuthenticationFilter)会从 HttpServletRequest 中提取凭证,并创建一个待认证的 Authentication 对象
    • 示例:UsernamePasswordAuthenticationFilter 会提取 usernamepassword,然后创建 UsernamePasswordAuthenticationTokenAuthentication 的实现类)。
  • 交给 AuthenticationManager 做认证

    创建好的 Authentication(此时是 “待认证” 状态,isAuthenticated()=false)会被传递给 AuthenticationManager,由它来执行核心认证逻辑(比如调用 UserDetailsService 查用户、比对密码等)。

  • 认证失败的后续处理

    如果 AuthenticationManager 认证失败(比如密码错误、用户不存在):

    1. 清空安全上下文SecurityContextHolder 会被清空,确保不会残留错误的认证信息。
    2. “记住我” 功能回调:调用 RememberMeServices.loginFail()。如果没配置 “记住我”(Remember Me),这一步相当于空操作。
    3. 失败处理器回调:调用 AuthenticationFailureHandler,由它处理 “失败后该做什么”(比如跳转到登录页、返回 JSON 错误信息等)。
  • 步骤 4:认证成功的后续处理

    如果 AuthenticationManager 认证成功(此时 Authentication 变为 “已认证” 状态,isAuthenticated()=true):

    1. 会话策略通知SessionAuthenticationStrategy 会被通知 “有新的登录”,它负责处理会话相关的逻辑(比如限制同一用户多设备登录、更新会话信息等)。
    2. 设置安全上下文:把 “已认证” 的Authentication设置到SecurityContextHolder中,这样后续请求就能通过SecurityContextHolder获取用户的认证信息。
      • 注意:如果需要 “把安全上下文持久化到未来请求”(比如存到 Session 里),需要显式调用 SecurityContextRepository#saveContext(这一步由 SecurityContextHolderFilter 等组件辅助完成)。
    3. “记住我” 功能回调:调用 RememberMeServices.loginSuccess()。如果没配置 “记住我”,这一步也是空操作。
    4. 发布认证成功事件ApplicationEventPublisher 会发布 InteractiveAuthenticationSuccessEvent 事件,系统中其他组件可以监听这个事件,执行自定义逻辑(比如记录登录日志)。
    5. 成功处理器回调:调用 AuthenticationSuccessHandler,由它处理 “成功后该做什么”(比如跳转到首页、返回 JSON 成功信息等)。

基于表单登录的用户名密码验证

Spring Security提供了对通过HTML表单提供用户名和密码的支持。

首先,我们看到用户是如何被重定向到登录表单的。

image-20250926150244145

上图建立在 SecurityFilterChain 图上。基于SecurityFilterChain 的工作流程就是了,展示了用户未认证时,Spring Security 如何将请求重定向到登录表单的完整流程

  • 用户请求受保护资源
    • 首先,一个用户向其未被授权的资源(/private)发出一个未经认证的请求。
  • 权限拦截器检测到没有认证
    • 请求进入 SecurityFilterChain 中的 FilterSecurityInterceptor(权限拦截器):
      • 它会检查当前用户是否有访问 /private 的权限。
      • 由于用户未认证(没有登录),FilterSecurityInterceptor 会抛出 AccessDeniedException(访问被拒绝异常)。
  • 异常转换过滤器捕获异常
    • ExceptionTranslationFilter(异常转换过滤器)会捕获 AccessDeniedException
      • 它是 Spring Security 中 专门处理 “安全相关异常” 的过滤器。
      • 当检测到 “用户未认证导致的访问拒绝” 时,会触发 AuthenticationEntryPoint(认证入口点)。
  • AuthenticationEntryPoint 重定向到登录页
    • LoginUrlAuthenticationEntryPointAuthenticationEntryPoint 的实现类)会执行重定向逻辑
      • 它会向客户端返回一个 302 重定向响应,响应头中的 Location 字段设置为登录页的 URL(例如 /login)。
      • 客户端收到 302 重定向响应后,会自动发起新的请求,访问登录页 GET /login
  • 登录控制器返回登录表单
    • LoginController(处理登录页请求的控制器)接收到 GET /login 请求后,会返回登录表单页面(例如 login.html),供用户输入用户名和密码。

那么上面我们知道了如果用户没有进行认证就访问受保护的资源是什么情况了,那么我们了解一下基于表单登录的用户名密码验证是如何进行的

image-20250926151735794

和之前架构的图很类似,只不过是由架构上的组件替换成了实际上在这个流程起到真正作用的组件

其中,UsernamePasswordAuthenticationFilter作为核心的过滤器,专门处理 “用户名 + 密码” 表单登录的过滤器。当用户提交登录表单时,这个过滤器会拦截请求,触发后续的认证流程。

整个流程的核心是:通过 UsernamePasswordAuthenticationFilter 捕获登录请求,交给 AuthenticationManager 做认证,再根据 “成功 / 失败” 触发不同的组件完成后续逻辑

  • 用户提交用户名和密码,创建 Authentication
    • UsernamePasswordAuthenticationFilter 会从 HttpServletRequest 中提取 usernamepassword
    • 然后创建一个待认证的 Authentication 对象UsernamePasswordAuthenticationToken(它是 Authentication 接口的实现类)。
    • 此时的 UsernamePasswordAuthenticationToken 是 “未认证” 状态(isAuthenticated()=false)。
  • 交给 AuthenticationManager 做认证
    • 创建好的 UsernamePasswordAuthenticationToken 会被传递给 AuthenticationManager,由它来执行核心认证逻辑
      • AuthenticationManager 会调用 UserDetailsService(用户详情服务),根据 username 查询数据库 / 缓存中的用户信息(UserDetails)。
      • 然后比对 “用户提交的密码” 和 “UserDetails 中存储的密码”(通常会用 PasswordEncoder 做加密比对)。
  • 认证失败的后续处理
    • 如果 AuthenticationManager 认证失败(比如密码错误、用户不存在):
      1. 清空安全上下文SecurityContextHolder 会被清空,确保不会残留错误的认证信息。
      2. “记住我” 功能回调:调用 RememberMeServices.loginFail()。如果没配置 “记住我”(Remember Me),这一步相当于空操作。
      3. 失败处理器回调:调用 AuthenticationFailureHandler,由它处理 “失败后该做什么”(比如跳转到登录页并提示 “密码错误”、返回 JSON 错误信息等)。
  • 认证成功的后续处理
    • 如果 AuthenticationManager 认证成功(此时 UsernamePasswordAuthenticationToken 变为 “已认证” 状态,isAuthenticated()=true):
      1. 会话策略通知SessionAuthenticationStrategy 会被通知 “有新的登录”,它负责处理会话相关的逻辑(比如限制同一用户多设备登录、更新会话信息等)。
      2. 设置安全上下文:把 “已认证” 的 UsernamePasswordAuthenticationToken 设置到 SecurityContextHolder 中,这样后续请求就能通过 SecurityContextHolder 获取用户的认证信息。
        • 注意:如果需要 “把安全上下文持久化到未来请求”(比如存到 Session 里),需要显式调用 SecurityContextRepository#saveContext(这一步由 SecurityContextPersistenceFilter 等组件辅助完成)。
      3. “记住我” 功能回调:调用 RememberMeServices.loginSuccess()。如果没配置 “记住我”,这一步也是空操作。
      4. 发布认证成功事件ApplicationEventPublisher 会发布 InteractiveAuthenticationSuccessEvent 事件,系统中其他组件可以监听这个事件,执行自定义逻辑(比如记录登录日志)。
      5. 成功处理器回调:调用·AuthenticationSuccessHandler,由它处理 “成功后该做什么”(比如跳转到首页、返回 JSON 成功信息等)。
        • 常见实现:SimpleUrlAuthenticationSuccessHandler 会重定向到 “之前被拦截的受保护资源”(由 ExceptionTranslationFilter 保存的请求)。

默认情况下,Spring Security表单登录被启用。然而,只要提供任何基于Servlet的配置,就必须明确提供基于表单的登录。下面的例子显示了一个最小的、明确的Java配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity // 启用Spring Security的Web安全支持
public class SecurityConfig {

// 定义安全过滤器链
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 配置授权规则
.authorizeHttpRequests(authorize -> authorize
// 所有请求都需要认证
.anyRequest().authenticated()
)
// 显式配置表单登录
.formLogin(form -> form
// 使用默认的登录页面和登录处理URL
// 默认登录页URL: /login
// 默认登录处理URL: /login
// 默认用户名参数: username
// 默认密码参数: password
// 默认登录成功后跳转页面: 之前请求的受保护页面或根路径
.permitAll() // 允许所有用户访问登录页
);

return http.build();
}
}

当登录页面在Spring Security配置中被指定时,你要负责渲染该页面。 下面的 Thymeleaf 模板产生一个符合 /login 的登录页面的HTML登录表单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head>
<title>Please Log In</title>
</head>
<body>
<h1>Please Log In</h1>
<div th:if="${param.error}">
Invalid username and password.</div>
<div th:if="${param.logout}">
You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<input type="text" name="username" placeholder="Username"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<input type="submit" value="Log in" />
</form>
</body>
</html>

关于默认的HTML表单,有几个关键点。

  • 表单应该以 post 方法请求 /login
  • 该表单需要包含 CSRF Token,Thymeleaf 会 自动包含
  • 该表单应在一个名为 username 的参数中指定用户名。
  • 表单应该在一个名为 password 的参数中指定密码。
  • 如果发现名为 error 的HTTP参数,表明用户未能提供一个有效的用户名或密码。
  • 如果发现名为 logout 的HTTP参数,表明用户已经成功注销。

许多用户除了定制登录页面外,并不需要更多的东西。然而,如果需要的话,你可以通过额外的配置来定制前面显示的一切。

如果你使用Spring MVC,你需要一个控制器,将 GET /login 映射到我们创建的登录模板。下面的例子展示了一个最小的 LoginController

1
2
3
4
5
6
7
@Controller
class LoginController {
@GetMapping("/login")
String login() {
return "login";
}
}

基于 Basic HTTP Authentication 的用户验证

Basic HTTP 认证是一种简单的 HTTP 认证方式,通过在请求头中携带 Authorization: Basic <base64编码的用户名:密码> 来传递凭证。Spring Security 会拦截未认证的请求,触发 Basic 认证流程。

Basic HTTP 认证的核心是:通过 WWW-Authenticate 头触发客户端提交凭证,再通过 BasicAuthenticationFilter 拦截凭证并验证。它的优点是简单易实现,适合内部系统或测试环境;缺点是凭证 Base64 编码可被解码(并非加密),且凭证在每次请求中都要传递,安全性较低,生产环境通常会结合 HTTPS 使用。

HTTP基本认证是如何在Spring Security中工作的,我们之前貌似提到过,因为我看 Spring Security 的官方文档说了,所以我也简单的看一下了

image-20250926152831647
  • 用户请求受保护资源,触发权限拦截

    客户端(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 认证。下面的图片显示了正在处理的用户名和密码的流程。

image-20250926152908840
  • 客户端提交 Basic 凭证,BasicAuthenticationFilter 拦截

    用户输入用户名和密码后,客户端会将凭证进行 Base64 编码(格式:用户名:密码 → Base64 编码),并在请求头中添加,这个携带凭证的请求会被 BasicAuthenticationFilter 拦截。

  • 创建 Authentication,交给 AuthenticationManager 认证

    BasicAuthenticationFilter 会从 Authorization 头中提取 Base64 编码的凭证,解码后得到用户名和密码,并创建 UsernamePasswordAuthenticationTokenAuthentication 的实现类)。

    • 然后将 UsernamePasswordAuthenticationToken 传递给 AuthenticationManager,由它执行核心认证逻辑(比如调用 UserDetailsService 查用户、比对密码等)。
  • 认证失败的后续处理

    • 如果 AuthenticationManager 认证失败(比如密码错误、用户不存在):
      1. 清空安全上下文SecurityContextHolder 会被清空。
      2. “记住我” 功能回调:调用 RememberMeServices(如果配置了 “记住我”)。
      3. 触发认证入口点:再次调用 BasicAuthenticationEntryPoint,让客户端重新提交凭证。
  • 认证成功的后续处理

    • 如果 AuthenticationManager 认证成功(凭证有效):
      1. 设置安全上下文:把 “已认证” 的 UsernamePasswordAuthenticationToken 设置到 SecurityContextHolder 中。
      2. “记住我” 功能回调:调用 RememberMeServices(如果配置了 “记住我”)。
      3. 继续业务流程:请求会继续向下传递,访问原本请求的受保护资源(例如 GET /private)。

默认情况下,Spring Security的HTTP Basic认证支持已经启用。然而,只要提供任何基于Servlet的配置,就必须明确提供HTTP Basic。

下面的例子显示了一个最小的、明确的配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 配置所有请求都需要认证
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated()
)
// 显式启用HTTP Basic认证
.httpBasic(httpBasic -> httpBasic
// 可以在这里配置realm名称等属性
// .realmName("My Application Realm")
);

return http.build();
}
}

Realm 名称会显示在浏览器的认证弹窗中,帮助用户识别需要哪个系统的凭证。

前后端分离的认证代码实现

用户认证成功的代码实现

我们先创建项目的新模块,把依赖导入好后,整体的模型和数据库什么的都用和之前基于数据库的用户认证的形式一样

所以,我们还是先要写继承UserDetailsServiceCustomUserDetailsService来自定义用户详情服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package hbnu.project.separatedsecurity.security;

import hbnu.project.separatedsecurity.entity.User;
import hbnu.project.separatedsecurity.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.Collection;
import java.util.stream.Collectors;

/**
* 自定义用户详情服务
* 实现Spring Security的UserDetailsService接口
* 用于从数据库加载用户信息进行认证
*/
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

private final UserRepository userRepository;

/**
* 根据用户名加载用户详情
* @param username 用户名
* @return UserDetails 用户详情
* @throws UsernameNotFoundException 用户未找到异常
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从数据库查找用户
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户未找到: " + username));

// 将用户实体转换为Spring Security的UserDetails
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.disabled(!user.isEnabled())
.authorities(getAuthorities(user))
.build();
}

/**
* 获取用户权限集合
* @param user 用户实体
* @return 权限集合
*/
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
// 将用户的角色转换为Spring Security的权限
return user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList());
}
}

好像没什么差别,因为我们还没写认证失败或者其他的内容,接下来我们继续写配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final UserDetailsService userDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;

/**
* 配置密码编码器
* @return BCrypt密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

/**
* 配置认证提供者
* @return DaoAuthenticationProvider
*/
@Bean
public DaoAuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder());
return authProvider;
}

/**
* 配置认证管理器
* @param config 认证配置
* @return AuthenticationManager
* @throws Exception 配置异常
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}

/**
* 配置CORS(跨域资源共享)
* @return CORS配置源
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();

// 允许的源地址(前端地址)
configuration.setAllowedOriginPatterns(Arrays.asList(
"http://localhost:*",
"http://127.0.0.1:*",
"file://*"
));

// 允许的HTTP方法
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));

// 允许的请求头
configuration.setAllowedHeaders(Arrays.asList("*"));

// 允许发送凭证
configuration.setAllowCredentials(true);

// 预检请求的缓存时间
configuration.setMaxAge(3600L);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}

/**
* 配置安全过滤器链
* @param http HttpSecurity对象
* @return SecurityFilterChain
* @throws Exception 配置异常
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF保护(前后端分离不需要)
.csrf(AbstractHttpConfigurer::disable)

// 配置CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource()))

// 配置会话管理策略为无状态(使用JWT,不需要session)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

// 配置授权规则
.authorizeHttpRequests(authz -> authz
// 公开的端点,不需要认证
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/error").permitAll()

// 管理员端点,需要ADMIN角色
.requestMatchers("/api/admin/**").hasRole("ADMIN")

// 用户端点,需要USER或ADMIN角色
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")

// 其他所有请求都需要认证
.anyRequest().authenticated()
)

// 设置认证提供者
.authenticationProvider(authenticationProvider())

// 在UsernamePasswordAuthenticationFilter之前添加JWT过滤器
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}
  • 首先,前后端分离的核心痛点之一是 “Session 共享问题”,所以我们使用了 JWT 进行无状态认证,彻底摆脱对 Session 的依赖,这是前后端分离认证的标志性设计。

    • SecurityConfigfilterChain 中,将 JwtAuthenticationFilter 添加到 UsernamePasswordAuthenticationFilter 之前:

      • 作用:每次请求都会先经过 JWT 过滤器,过滤器从请求头(如 Authorization: Bearer {token})中提取 JWT,验证有效性后解析用户信息,直接构建认证对象(Authentication),无需查询 Session。
      • 服务端无需存储任何会话信息(无状态),前端只需在请求头携带 JWT 即可完成认证
  • 明确配置会话为 无状态(STATELESS):禁用 Spring Security 的 Session 创建逻辑,服务端不会为任何请求创建 Session,完全依赖 JWT 进行身份识别。

1
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

编写好对应的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
package hbnu.project.separatedsecurity.controller;

import hbnu.project.separatedsecurity.dto.ApiResponse;
import hbnu.project.separatedsecurity.dto.LoginRequest;
import hbnu.project.separatedsecurity.dto.LoginResponse;
import hbnu.project.separatedsecurity.util.JwtUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

/**
* 认证控制器
* 处理用户登录、登出等认证相关的请求
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;

/**
* 用户登录接口
* @param loginRequest 登录请求
* @return 登录响应
*/
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestBody LoginRequest loginRequest) {
try {
log.info("用户尝试登录: {}", loginRequest.getUsername());

// 创建认证令牌
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
loginRequest.getUsername(),
loginRequest.getPassword()
);

// 进行认证
Authentication authentication = authenticationManager.authenticate(authToken);

// 获取认证成功的用户详情
UserDetails userDetails = (UserDetails) authentication.getPrincipal();

// 生成JWT令牌
String jwt = jwtUtil.generateToken(userDetails);

// 获取用户角色
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(auth -> auth.startsWith("ROLE_") ? auth.substring(5) : auth)
.collect(Collectors.toList());

// 创建登录响应
LoginResponse loginResponse = new LoginResponse(jwt, userDetails.getUsername(), roles);

log.info("用户登录成功: {}, 角色: {}", userDetails.getUsername(), roles);

return ResponseEntity.ok(ApiResponse.success(loginResponse));

} catch (BadCredentialsException e) {
log.warn("用户登录失败 - 用户名或密码错误: {}", loginRequest.getUsername());
return ResponseEntity.badRequest()
.body(ApiResponse.error("用户名或密码错误"));
} catch (Exception e) {
log.error("登录过程中发生错误: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(ApiResponse.error("登录失败,请稍后重试"));
}
}

/**
* 用户登出接口
* 注:在JWT模式下,登出主要由前端处理(删除本地存储的token)
* 这里提供一个标准的登出端点
* @return 登出响应
*/
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout() {
// 在JWT模式下,服务端通常不需要处理登出逻辑
// 因为JWT是无状态的,前端删除token即可
// 如果需要实现token黑名单功能,可以在这里添加相关逻辑

log.info("用户登出");
return ResponseEntity.ok(ApiResponse.success("登出成功"));
}

/**
* 获取当前用户信息接口
* @param authentication 当前认证信息
* @return 用户信息
*/
@GetMapping("/me")
public ResponseEntity<ApiResponse<LoginResponse>> getCurrentUser(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("用户未认证"));
}

UserDetails userDetails = (UserDetails) authentication.getPrincipal();

// 获取用户角色
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(auth -> auth.startsWith("ROLE_") ? auth.substring(5) : auth)
.collect(Collectors.toList());

// 不返回新的token,只返回用户信息
LoginResponse response = new LoginResponse(null, userDetails.getUsername(), roles);

return ResponseEntity.ok(ApiResponse.success(response));
}
}

把前端页面写好,这里我没有使用各种框架,毕竟是个小东西,拿 npx 直接跑了,但是这里 npx 注意跨域的问题

image-20250926171349740
image-20250926171759361

用户认证失败的代码实现

上述我们比较熟悉的用户认证,那么,认证失败了需要进行页面的跳转,认证失败次数过多要限制你的登录,这些内容都是要如何进行实现呢

那么要实现限制登陆,就要修改我们的实体类,添加相关的字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Entity
@Table(name = "sys_user")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique = true, nullable = false, length = 50)
private String username;

@Column(nullable = false, length = 100)
private String password;

@Column(nullable = false)
private boolean enabled = true;

/**
* 账户是否未锁定
*/
@Column(name = "account_non_locked", nullable = false)
private boolean accountNonLocked = true;

/**
* 登录失败次数
*/
@Column(name = "failed_attempts", nullable = false)
private int failedAttempts = 0;

/**
* 账户锁定时间
*/
@Column(name = "lock_time")
private LocalDateTime lockTime;

@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "sys_user_role",
joinColumns = @JoinColumn(name = "user_id"),
inverseJoinColumns = @JoinColumn(name = "role_id")
)
private Set<Role> roles = new HashSet<>();
}

编写一个认证失败处理服务,在这里进行认证失败后的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/**
* 认证失败处理服务
* 处理登录失败次数统计和账户锁定逻辑
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthenticationFailureService {

private final UserRepository userRepository;

/**
* 最大失败尝试次数,超过此次数将锁定账户
*/
@Value("${app.security.max-failed-attempts:5}")
private int maxFailedAttempts;

/**
* 账户锁定时间(分钟)
*/
@Value("${app.security.lock-duration-minutes:30}")
private int lockDurationMinutes;

/**
* 处理认证失败
* @param username 用户名
*/
@Transactional
public void onAuthenticationFailure(String username) {
User user = userRepository.findByUsername(username);
if (user == null) {
log.warn("尝试登录不存在的用户: {}", username);
return;
}

// 增加失败次数
int attempts = user.getFailedAttempts() + 1;
user.setFailedAttempts(attempts);

log.warn("用户 {} 登录失败,当前失败次数: {}", username, attempts);

// 检查是否需要锁定账户
if (attempts >= maxFailedAttempts) {
user.setAccountNonLocked(false);
user.setLockTime(LocalDateTime.now());
log.warn("用户 {} 登录失败次数过多,账户已被锁定", username);
}

userRepository.save(user);
}

/**
* 处理认证成功,重置失败次数
* @param username 用户名
*/
@Transactional
public void onAuthenticationSuccess(String username) {
User user = userRepository.findByUsername(username);
if (user != null && user.getFailedAttempts() > 0) {
// 重置失败次数
user.setFailedAttempts(0);
user.setLockTime(null);
log.info("用户 {} 登录成功,重置失败次数", username);
userRepository.save(user);
}
}

/**
* 检查账户是否被锁定
* @param username 用户名
* @return 是否被锁定
*/
public boolean isAccountLocked(String username) {
User user = userRepository.findByUsername(username);
if (user == null) {
return false;
}

// 如果账户未锁定,直接返回false
if (user.isAccountNonLocked()) {
return false;
}

// 检查锁定时间是否已过期
if (user.getLockTime() != null) {
LocalDateTime unlockTime = user.getLockTime().plusMinutes(lockDurationMinutes);
if (LocalDateTime.now().isAfter(unlockTime)) {
// 锁定时间已过期,自动解锁
unlockAccount(username);
return false;
}
}

return true;
}

/**
* 解锁账户
* @param username 用户名
*/
@Transactional
public void unlockAccount(String username) {
User user = userRepository.findByUsername(username);
if (user != null) {
user.setAccountNonLocked(true);
user.setFailedAttempts(0);
user.setLockTime(null);
userRepository.save(user);
log.info("用户 {} 账户已解锁", username);
}
}

/**
* 获取账户剩余锁定时间(分钟)
* @param username 用户名
* @return 剩余锁定时间,如果未锁定返回0
*/
public long getRemainingLockTime(String username) {
User user = userRepository.findByUsername(username);
if (user == null || user.isAccountNonLocked() || user.getLockTime() == null) {
return 0;
}

LocalDateTime unlockTime = user.getLockTime().plusMinutes(lockDurationMinutes);
LocalDateTime now = LocalDateTime.now();

if (now.isAfter(unlockTime)) {
return 0;
}

return java.time.Duration.between(now, unlockTime).toMinutes();
}

/**
* 获取用户当前失败次数
* @param username 用户名
* @return 失败次数
*/
public int getFailedAttempts(String username) {
User user = userRepository.findByUsername(username);
return user != null ? user.getFailedAttempts() : 0;
}

/**
* 获取最大允许失败次数
* @return 最大失败次数
*/
public int getMaxFailedAttempts() {
return maxFailedAttempts;
}
}

我们需要添加检查账户状态的情况了,在loadUserByUsername方法中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 根据用户名加载用户详情
* @param username 用户名
* @return UserDetails 用户详情
* @throws UsernameNotFoundException 用户未找到异常
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 从数据库查找用户
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户未找到: " + username));

// 检查账户是否被锁定
boolean accountLocked = authenticationFailureService.isAccountLocked(username);

// 将用户实体转换为Spring Security的UserDetails
return org.springframework.security.core.userdetails.User.builder()
.username(user.getUsername())
.password(user.getPassword())
.disabled(!user.isEnabled())
.accountLocked(accountLocked) // 设置账户锁定状态
.authorities(getAuthorities(user))
.build();
}

更新AuthController来集成认证失败处理,主要是更新登录方法,添加认证失败处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/**
* 用户登录接口
* @param loginRequest 登录请求
* @return 登录响应
*/
@PostMapping("/login")
public ResponseEntity<ApiResponse<LoginResponse>> login(@RequestBody LoginRequest loginRequest) {
String username = loginRequest.getUsername();

try {
log.info("用户尝试登录: {}", username);

// 检查账户是否被锁定
if (authenticationFailureService.isAccountLocked(username)) {
long remainingTime = authenticationFailureService.getRemainingLockTime(username);
log.warn("用户账户被锁定: {}, 剩余时间: {} 分钟", username, remainingTime);
return ResponseEntity.badRequest()
.body(ApiResponse.error("账户已被锁定,请在 " + remainingTime + " 分钟后重试"));
}

// 创建认证令牌
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(username, loginRequest.getPassword());

// 进行认证
Authentication authentication = authenticationManager.authenticate(authToken);

// 认证成功,重置失败次数
authenticationFailureService.onAuthenticationSuccess(username);

// 获取认证成功的用户详情
UserDetails userDetails = (UserDetails) authentication.getPrincipal();

// 生成JWT令牌
String jwt = jwtUtil.generateToken(userDetails);

// 获取用户角色
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(auth -> auth.startsWith("ROLE_") ? auth.substring(5) : auth)
.collect(Collectors.toList());

// 创建登录响应
LoginResponse loginResponse = new LoginResponse(jwt, userDetails.getUsername(), roles);

log.info("用户登录成功: {}, 角色: {}", userDetails.getUsername(), roles);

return ResponseEntity.ok(ApiResponse.success(loginResponse));

} catch (LockedException e) {
log.warn("用户账户被锁定: {}", username);
long remainingTime = authenticationFailureService.getRemainingLockTime(username);
return ResponseEntity.badRequest()
.body(ApiResponse.error("账户已被锁定,请在 " + remainingTime + " 分钟后重试"));
} catch (BadCredentialsException e) {
log.warn("用户登录失败 - 用户名或密码错误: {}", username);

// 记录认证失败
authenticationFailureService.onAuthenticationFailure(username);

// 获取当前失败次数和最大允许次数
int failedAttempts = authenticationFailureService.getFailedAttempts(username);
int maxAttempts = authenticationFailureService.getMaxFailedAttempts();
int remainingAttempts = maxAttempts - failedAttempts;

String errorMessage = "用户名或密码错误";
if (remainingAttempts > 0) {
errorMessage += ",还可尝试 " + remainingAttempts + " 次";
} else {
errorMessage = "登录失败次数过多,账户已被锁定";
}

return ResponseEntity.badRequest()
.body(ApiResponse.error(errorMessage));
} catch (Exception e) {
log.error("登录过程中发生错误: {}", e.getMessage(), e);
return ResponseEntity.internalServerError()
.body(ApiResponse.error("登录失败,请稍后重试"));
}
}

前端懒得写了,就放在这里吧

用户注销处理的代码实现

简单的注销我们在之前其实已经有所展示了

1
2
3
4
5
6
7
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout() {
// 在JWT模式下,服务端通常不需要处理注销逻辑
// 因为JWT是无状态的,前端删除token即可
log.info("用户登出");
return ResponseEntity.ok(ApiResponse.success("登出成功"));
}

这样其实是相对推荐的,如果你在用户注销的部分没什么需要处理的逻辑,这样性能好,无服务器端状态管理,而且符合符合JWT无状态设计原则,但是这样无法立即阻止被盗用的令牌,令牌可能会在过期之前被拿到然后重复使用

我们创建一个令牌黑名单,管理已注销的令牌,防止被重复使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* JWT令牌黑名单服务
* 管理已注销的令牌,防止被重复使用
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenBlacklistService {

private final RedisTemplate<String, String> redisTemplate;

// 黑名单key前缀
private static final String BLACKLIST_PREFIX = "jwt:blacklist:";

/**
* 将令牌添加到黑名单
* @param jti JWT令牌的唯一标识符
* @param expirationTime 令牌过期时间
*/
public void addToBlacklist(String jti, LocalDateTime expirationTime) {
try {
String key = BLACKLIST_PREFIX + jti;

// 计算令牌剩余有效时间
Duration remaining = Duration.between(LocalDateTime.now(), expirationTime);

if (remaining.isPositive()) {
// 只在令牌未过期时添加到黑名单
redisTemplate.opsForValue().set(key, "revoked", remaining.toSeconds(), TimeUnit.SECONDS);
log.info("令牌已添加到黑名单: {}, 剩余时间: {} 秒", jti, remaining.toSeconds());
}
} catch (Exception e) {
log.error("添加令牌到黑名单失败: {}", e.getMessage(), e);
}
}

/**
* 检查令牌是否在黑名单中
* @param jti JWT令牌的唯一标识符
* @return 是否被撤销
*/
public boolean isTokenRevoked(String jti) {
try {
String key = BLACKLIST_PREFIX + jti;
return redisTemplate.hasKey(key);
} catch (Exception e) {
log.error("检查令牌黑名单状态失败: {}", e.getMessage(), e);
return false; // 异常时默认允许访问
}
}

/**
* 清理过期的黑名单条目(Redis TTL会自动处理,这里是备用方法)
*/
public void cleanupExpiredTokens() {
// Redis的TTL机制会自动清理过期的key
// 这个方法可以用于手动清理或统计
log.debug("黑名单清理任务执行完成");
}
}

更新JwtUtil以支持令牌唯一标识

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 生成带有唯一标识的JWT令牌
* @param userDetails 用户详情
* @return JWT令牌
*/
public String generateToken(UserDetails userDetails) {
Map<String, Object> claims = new HashMap<>();

// 添加用户角色信息
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
claims.put("roles", authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList()));

// 生成唯一标识符
String jti = UUID.randomUUID().toString();
claims.put("jti", jti);

return createToken(claims, userDetails.getUsername());
}

/**
* 从令牌中获取JTI(JWT ID)
* @param token JWT令牌
* @return JTI
*/
public String getJtiFromToken(String token) {
return getClaimFromToken(token, claims -> claims.get("jti", String.class));
}

/**
* 获取令牌过期时间
* @param token JWT令牌
* @return 过期时间
*/
public LocalDateTime getExpirationDateFromToken(String token) {
Date expiration = getExpirationDateFromToken(token);
return expiration.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
}

最后我们更新AuthController的注销方法,支持我们理解的注销

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 用户注销接口
* @param request HTTP请求对象,用于获取JWT令牌
* @return 注销响应
*/
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(HttpServletRequest request) {
try {
// 从请求头中获取JWT令牌
String authHeader = request.getHeader("Authorization");

if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);

// 验证令牌有效性
if (jwtUtil.validateToken(token)) {
String username = jwtUtil.getUsernameFromToken(token);
String jti = jwtUtil.getJtiFromToken(token);
LocalDateTime expirationTime = jwtUtil.getExpirationDateFromToken(token);

// 将令牌添加到黑名单(如果使用黑名单机制)
// tokenBlacklistService.addToBlacklist(jti, expirationTime);

log.info("用户 {} 成功登出,令牌ID: {}", username, jti);
return ResponseEntity.ok(ApiResponse.success("登出成功"));
}
}

// 即使没有有效令牌,也返回成功(安全考虑)
log.info("用户登出请求(无有效令牌)");
return ResponseEntity.ok(ApiResponse.success("登出成功"));

} catch (Exception e) {
log.error("登出过程中发生错误: {}", e.getMessage(), e);
return ResponseEntity.ok(ApiResponse.success("登出成功")); // 仍然返回成功
}
}

那么,jwt令牌如果在黑名单里,如何才能被检测出来呢?所以我们需要在JWT过滤器中检查令牌状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 在JwtAuthenticationFilter的doFilterInternal方法中添加黑名单检查

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {

final String requestTokenHeader = request.getHeader("Authorization");

String username = null;
String jwtToken = null;

if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
jwtToken = requestTokenHeader.substring(7);
try {
username = jwtUtil.getUsernameFromToken(jwtToken);

// 检查令牌是否在黑名单中(如果启用黑名单机制)
String jti = jwtUtil.getJtiFromToken(jwtToken);
if (tokenBlacklistService.isTokenRevoked(jti)) {
log.warn("检测到已撤销的令牌: {}", jti);
chain.doFilter(request, response);
return;
}

} catch (IllegalArgumentException e) {
log.error("无法获取JWT令牌", e);
} catch (ExpiredJwtException e) {
log.error("JWT令牌已过期", e);
}
}

// 继续原有的认证逻辑...
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

if (jwtUtil.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}

chain.doFilter(request, response);
}

如果再进一步,就可以引入 redis,这里为了保持简单就没用

请求未认证的代码实现

在 Spring Security 中,“请求未认证” 是指用户未登录(或登录状态失效)时访问需要认证的接口,此时需要通过 “自定义认证失败处理器” 或使用默认配置来统一处理响应(如返回标准化 JSON、跳转登录页等)。

核心实现思路是:拦截 “未认证” 事件,覆盖 Spring Security 的默认行为,返回符合业务需求的响应

在 Spring Security 中,触发 “未认证” 的核心场景有 2 类,实现代码需覆盖这些场景:

  1. 匿名用户访问需要认证的接口:例如未登录用户访问 /api/user/info(该接口需 authenticated() 权限);
  2. 已登录用户会话失效:例如 JWT 过期、Session 过期后,用户继续访问需要认证的接口。

Spring Security 对未认证请求的默认处理是:

  • 若为 浏览器请求(如 GET 页面):自动跳转登录页(默认 /login);
  • 若为 API 请求(如 POST/JSON):返回 403 Forbidden401 Unauthorized 原生响应

因此,我们需要自定义实现,将未认证响应统一为 标准化格式(如 JSON 结构 {"code":401,"msg":"未登录或登录已过期","data":null})。

在Spring Security中,未认证请求的处理涉及以下几个关键组件:

  1. AuthenticationEntryPoint:处理未认证请求的入口点
  2. AccessDeniedHandler:处理已认证但权限不足的请求
  3. ExceptionTranslationFilter:异常转换过滤器
  4. JWT认证过滤器:自定义的JWT验证逻辑

Spring Security 中,处理 “未认证请求” 的核心接口是 AuthenticationEntryPoint,其作用是:当用户尝试访问需要认证的资源但未认证时,触发该接口的 commence() 方法,生成响应。

所以,我们需要实现 AuthenticationEntryPoint 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
/**
* 自定义认证入口点
* 处理未认证请求,返回统一格式的JSON响应
*/
@Slf4j
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {

log.warn("未认证访问尝试: {} {}, 错误: {}",
request.getMethod(), request.getRequestURI(), authException.getMessage());

// 设置响应内容类型和状态码
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

// 构造错误响应
ApiResponse<Void> errorResponse = ApiResponse.error("认证失败,请先登录");

// 根据不同的异常类型提供不同的错误信息
String errorMessage = determineErrorMessage(authException, request);
errorResponse = ApiResponse.error(errorMessage);

// 写入响应
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
response.getWriter().flush();
}

/**
* 根据异常类型和请求信息确定错误消息
* @param authException 认证异常
* @param request HTTP请求
* @return 错误消息
*/
private String determineErrorMessage(AuthenticationException authException, HttpServletRequest request) {
String requestURI = request.getRequestURI();

// 检查是否是JWT相关错误
if (authException.getMessage() != null) {
if (authException.getMessage().contains("expired")) {
return "登录已过期,请重新登录";
}
if (authException.getMessage().contains("invalid")) {
return "认证信息无效,请重新登录";
}
if (authException.getMessage().contains("malformed")) {
return "认证格式错误,请重新登录";
}
}

// 根据访问的接口类型返回不同消息
if (requestURI.contains("/admin/")) {
return "需要管理员权限,请使用管理员账户登录";
}
if (requestURI.contains("/user/")) {
return "需要用户权限,请先登录";
}

return "认证失败,请先登录";
}
}

我们也可以编写一个自定义访问拒绝处理器,返回更友好的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* 自定义访问拒绝处理器
* 处理已认证但权限不足的请求
*/
@Slf4j
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String username = authentication != null ? authentication.getName() : "anonymous";

log.warn("权限不足访问尝试: 用户={}, 请求={} {}, 错误={}",
username, request.getMethod(), request.getRequestURI(), accessDeniedException.getMessage());

// 设置响应内容类型和状态码
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);

// 构造错误响应
String errorMessage = determineAccessDeniedMessage(request, authentication);
ApiResponse<Void> errorResponse = ApiResponse.error(errorMessage);

// 写入响应
String jsonResponse = objectMapper.writeValueAsString(errorResponse);
response.getWriter().write(jsonResponse);
response.getWriter().flush();
}

/**
* 根据请求和用户信息确定访问拒绝消息
* @param request HTTP请求
* @param authentication 认证信息
* @return 错误消息
*/
private String determineAccessDeniedMessage(HttpServletRequest request, Authentication authentication) {
String requestURI = request.getRequestURI();

if (requestURI.contains("/admin/")) {
return "访问被拒绝:需要管理员权限";
}

if (authentication != null) {
String roles = authentication.getAuthorities().toString();
return String.format("访问被拒绝:当前权限 %s 不足以访问此资源", roles);
}

return "访问被拒绝:权限不足";
}
}

然后,将自定义的 CustomUnauthorizedEntryPoint 注入 SecurityFilterChain,覆盖默认的未认证处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final UserDetailsService userDetailsService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomAccessDeniedHandler customAccessDeniedHandler;

// ... 其他配置 ...

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 禁用CSRF保护
.csrf(AbstractHttpConfigurer::disable)

// 配置CORS
.cors(cors -> cors.configurationSource(corsConfigurationSource()))

// 配置会话管理策略为无状态
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

// 配置异常处理
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(customAuthenticationEntryPoint) // 未认证处理
.accessDeniedHandler(customAccessDeniedHandler) // 权限不足处理
)

// 配置授权规则
.authorizeHttpRequests(authz -> authz
// 公开的端点,不需要认证
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/error").permitAll()

// 管理员端点,需要ADMIN角色
.requestMatchers("/api/admin/**").hasRole("ADMIN")

// 用户端点,需要USER或ADMIN角色
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")

// 其他所有请求都需要认证
.anyRequest().authenticated()
)

// 设置认证提供者
.authenticationProvider(authenticationProvider())

// 在UsernamePasswordAuthenticationFilter之前添加JWT过滤器
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}

若项目使用 JWT 认证,未认证场景可能包含 “JWT 过期”“JWT 格式错误” 等细分情况,更新JWT认证过滤器以提供更详细的未认证处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 在JwtAuthenticationFilter中的关键部分

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {

final String requestTokenHeader = request.getHeader("Authorization");
final String requestURI = request.getRequestURI();

// 跳过不需要认证的路径
if (isPublicPath(requestURI)) {
chain.doFilter(request, response);
return;
}

String username = null;
String jwtToken = null;
String errorMessage = null;

// 检查Authorization头
if (requestTokenHeader == null) {
log.debug("请求缺少Authorization头: {}", requestURI);
errorMessage = "缺少认证信息";
} else if (!requestTokenHeader.startsWith("Bearer ")) {
log.debug("Authorization头格式错误: {}", requestURI);
errorMessage = "认证格式错误";
} else {
jwtToken = requestTokenHeader.substring(7);
try {
username = jwtUtil.getUsernameFromToken(jwtToken);
} catch (IllegalArgumentException e) {
log.error("无法获取JWT令牌: {}", e.getMessage());
errorMessage = "认证信息格式错误";
} catch (ExpiredJwtException e) {
log.error("JWT令牌已过期: {}", e.getMessage());
errorMessage = "登录已过期";
} catch (MalformedJwtException e) {
log.error("JWT令牌格式错误: {}", e.getMessage());
errorMessage = "认证信息无效";
} catch (Exception e) {
log.error("JWT令牌验证失败: {}", e.getMessage());
errorMessage = "认证验证失败";
}
}

// 如果有错误,设置错误属性供AuthenticationEntryPoint使用
if (errorMessage != null) {
request.setAttribute("jwt.error", errorMessage);
}

// 如果用户名有效且当前没有认证信息,进行认证
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);

if (jwtUtil.validateToken(jwtToken, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

log.debug("用户 {} 认证成功", username);
} else {
log.debug("用户 {} 令牌验证失败", username);
request.setAttribute("jwt.error", "令牌验证失败");
}
}

chain.doFilter(request, response);
}

/**
* 检查是否是公开路径
* @param requestURI 请求URI
* @return 是否是公开路径
*/
private boolean isPublicPath(String requestURI) {
String[] publicPaths = {
"/api/auth/",
"/api/public/",
"/error"
};

for (String publicPath : publicPaths) {
if (requestURI.startsWith(publicPath)) {
return true;
}
}
return false;
}

这样这个 jwt 的过滤器被注入之后,未认证的场景就会更加清晰

实现未认证处理时,需避免与 “权限不足” 混淆,两者的核心区别:

  • 未认证(401):用户没登录(或登录失效),对应 AuthenticationEntryPoint
  • 权限不足(403):用户已登录,但没有访问该接口的权限(如普通用户访问 /api/admin/delete),对应 AccessDeniedHandler

无论使用哪种方法,Spring Security 处理 “请求未认证” 的核心流程都是:

  1. 拦截未认证事件:通过 AuthenticationEntryPoint 接口捕获 “未认证请求”;
  2. 自定义响应格式:设置响应类型(JSON/HTML)、状态码(401)、响应体(标准化结构);
  3. 注入 Security 配置:在 SecurityFilterChainexceptionHandling 中关联自定义处理器。

其中,方法 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.comhttp://localhost:8080(开发环境);
  • 后端:部署在应用服务器,地址可能是 https://backend.comhttp://localhost:8081(开发环境)。

这种 “分离部署” 必然导致 协议、域名、端口三者至少有一个不同,跨域成为常态。如果不做跨域处理,前端无法获取后端接口的响应,整个业务流程(如登录、查询数据)会完全中断。

简单来说:没有跨域处理,前后端分离架构就无法落地

要解决跨域问题,不能 “绕过” 同源策略(否则会破坏浏览器安全),而是要通过 CORS(Cross-Origin Resource Sharing,跨域资源共享)协议 让后端 “明确授权” 前端访问 —— 相当于后端给浏览器发了一张 “通行证”,告诉浏览器:“这个前端域名是安全的,允许它获取我的响应”。

CORS 协议的核心逻辑是:

  1. 前端发起跨域请求时,浏览器会在请求头中添加 Origin 字段(值为前端域名,如 http://localhost:8080);
  2. 后端收到请求后,检查 Origin 是否在 “允许的源” 列表中;
  3. 若允许,后端在响应头中添加 Access-Control-* 系列字段(如 Access-Control-Allow-Origin: http://localhost:8080);
  4. 浏览器收到响应后,检查是否有这些 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 三种;

  • 请求头:仅包含浏览器默认头(如 AcceptAccept-LanguageContent-Type),且 Content-Type 仅限 application/x-www-form-urlencodedmultipart/form-datatext/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-Typeapplication/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 })
    });

预检请求的流程:

  1. 浏览器发送 OPTIONS 请求到后端,请求头中包含:
    • Origin:前端域名;
    • Access-Control-Request-Method:真实请求的方法(如 POST);
    • Access-Control-Request-Headers:真实请求的自定义头(如 Authorization);
  2. 后端收到 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 小时内无需重复发预检);
  3. 浏览器收到预检响应后,若检查通过,才会发起真实的请求;若不通过,则直接拦截,不发起真实请求。

这就是为什么之前的跨域配置中需要包含 Allowed MethodsAllowed 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-ControlContent-LengthContent-Type 等少数字段。如果后端需要返回自定义响应头(如分页场景的 X-Total-Count,表示总条数),必须在 Access-Control-Expose-Headers 中明确配置,否则前端无法通过 response.headers.get('X-Total-Count') 获取该值。

确保 OPTIONS 请求不被拦截

预检请求(OPTIONS)是 “无业务含义” 的请求,后端不能将其当作普通请求拦截(如登录验证、权限校验)。

例如,在 Spring Security 中,需要明确放行 OPTIONS 请求:

1
2
3
4
.authorizeHttpRequests(authz -> authz
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 放行所有 OPTIONS 请求
.anyRequest().authenticated()
)

如果 OPTIONS 请求被拦截(返回 401 或 403),浏览器会认为预检失败,不会发起真实请求。

避免重复配置

跨域配置只需在 一个层级 生效(如要么后端代码配置,要么 Nginx 配置),不要同时在后端和 Nginx 中配置 Access-Control-* 字段 —— 重复配置可能导致响应头中出现多个相同字段(如多个 Access-Control-Allow-Origin),浏览器无法识别,从而拦截响应。