Spring Security实战教程（十八）安全日志与审计：关键操作追踪与风险预警
1. 前言在我们日常开发系统的安全架构中，仅仅通过认证和授权往往不足以满足合规、溯源、风险预警等需求。企业级系统必须记录并分析 “谁在什么时候对什么资源做了什么操作”，并结合规则引擎识别异常行为，及时告警。安全审计作为企业防护的最后一道防线，能有效追踪异常行为、还原攻击链、满足合规要求。本文笔者将带领大家基于 Spring Security 构建完整的安全审计系统，实现关键操作追踪与实时风险预警。2. Spring Security 审计架构设计2.1 系统架构我们常见的安全审计应该具备以下基础：以笔者目前所在公司使用的系统架构如下图：2.2 审计日志四要素要素说明示例主体操作执行者用户 ID、IP 地址客体被操作对象数据 ID、接口路径动作操作类型登录、删除、授权变更环境操作上下文时间、设备、地理位置2.3 技术栈选择事件采集：Spring AOP + ApplicationEvent存储方案：MySQL + MyBatis‑Plus（中小型应用适用）或 Elasticsearch + Logstash（大型应用 + 实时检索）风险分析：Drools 规则引擎可视化：Grafana 监控看板笔者为简化演示，本文以 MySQL + MyBatis‑Plus 为存储方案。鉴于之前的子模块代码中，我们已经集成好了 MySQL + MyBatis‑Plus，在开始之前复用之前的子模块，需在 pom.xml 中引入：<dependency> <groupId>org.drools</groupId> <artifactId>drools-core</artifactId> <version>9.44.0.Final</version> </dependency> <dependency> <groupId>org.kie</groupId> <artifactId>kie-spring</artifactId> <version>7.74.1.Final</version> </dependency>3. 数据库与实体设计CREATE TABLE audit_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50), action VARCHAR(100), resource VARCHAR(200), timestamp DATETIME, duration BIGINT );@Data @TableName("audit_log") public class AuditLog { @TableId(type = IdType.AUTO) private Long id; private String username; private String action; private String resource; private LocalDateTime timestamp; private Long duration; } // Mapper @Mapper public interface AuditLogMapper extends BaseMapper<AuditLog> {}4. 事件采集：Spring AOP + 自定义注解4.1 自定义注解@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Audit { String action(); String resource(); }4.2 AOP 切面实现@Aspect @Component public class AuditAspect { @Autowired private ApplicationEventPublisher publisher; @Pointcut("@annotation(audit)") public void auditPoint(Audit audit) {} @Around("auditPoint(audit)") public Object around(ProceedingJoinPoint jp, Audit audit) throws Throwable { long start = System.currentTimeMillis(); Object result = jp.proceed(); long duration = System.currentTimeMillis() - start; MethodSignature ms = (MethodSignature) jp.getSignature(); String username = ((UserDetails) SecurityContextHolder.getContext() .getAuthentication().getPrincipal()).getUsername(); AuditEvent evt = new AuditEvent(username, audit.action(), audit.resource(), duration, LocalDateTime.now()); publisher.publishEvent(evt); return result; } }说明：上述代码中，可补充捕获异常并发布失败事件。4.3 事件类public record AuditEvent(String username, String action, String resource, long duration, LocalDateTime timestamp) {}4.4 事件监听并保存日志@Component @RequiredArgsConstructor public class AuditListener { private final AuditLogMapper mapper; private final KieContainer kieContainer; private final Counter riskCounter; // Prometheus counter @EventListener public void handle(AuditEvent evt) { AuditLog log = new AuditLog(null, evt.username(), evt.action(), evt.resource(), evt.timestamp(), evt.duration()); mapper.insert(log); var session = kieContainer.newKieSession(); session.insert(log); var fired = session.fireAllRules(); session.dispose(); if (fired > 0) riskCounter.increment(); } }5. Drools 动态规则引擎5.1 Drools 配置@Configuration public class DroolsConfig { @Bean public KieContainer kieContainer() { KieServices ks = KieServices.Factory.get(); KieFileSystem kfs = ks.newKieFileSystem(); kfs.write(ResourceFactory.newClassPathResource("rules/audit-risk.drl")); ks.newKieBuilder(kfs).buildAll(); return ks.newKieContainer( ks.getRepository().getDefaultReleaseId()); } }5.2 规则文件 audit-risk.drlrule "Slow Audit" when $log: AuditLog(duration > 1000) then System.out.println("风险预警：操作耗时过长 by " + $log.getUsername()); end6. 监控指标采集与可视化6.1 添加监控依赖并启用 Actuator<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>6.2 配置 application.ymlmanagement: endpoints: web: exposure: include: prometheus,health6.3 指标配置@Configuration public class MetricsConfig { @Bean public Counter riskCounter(MeterRegistry reg) { return Counter.builder("audit.risk.count") .description("Number of risky audit events") .register(reg); } }Prometheus 采集 /actuator/prometheus，Grafana 仪表板配置查询：audit_risk_count。7. 控制器示例完成上述所有操作后，可编写一个 Controller 进行相关测试：@RestController @RequestMapping("/api") @RequiredArgsConstructor public class DemoController { @Audit(action = "ACCESS", resource = "demoEndpoint") @GetMapping("/demo") public String demo() { return "OK"; } }8. 结语本章节我们以最简单的一个入门示例，主要讲解了：利用 MyBatis‑Plus 实现高效、自动化地操作 MySQL 审计日志表；通过 Spring AOP + ApplicationEvent 实现操作日志透明采集；使用 Drools 规则引擎实现可配置、实时化的风险判断；集成 Prometheus + Grafana 实现可视化监控，从流量、耗时、风险事件维度全方位审计系统行为。这套方案可按需扩展为审计超级系统，支持敏感操作追踪、异常报警、人机判定等功能，适合企业级复杂场景。后续可添加 CI/CD 自动部署 Drools 规则、Grafana 警报等高级功能。
Spring Security实战教程（十七）企业级安全方案设计 - 多因素认证（MFA）实现
1. 前言在微服务与分布式架构日益普及的今天，传统的单一凭证（用户名+密码） 已经难以满足企业对于身份验证的高安全性需求。多因素认证（Multi‑Factor Authentication，简称 MFA） 通过“用户知道的东西”（如密码）+“用户拥有的东西”（如动态验证码）或“用户自身的一部分”（如指纹）三种因素的组合，大幅提升了系统防护能力。比如我们常见的 GitHub、腾讯云等就开启了 MFA。GitHub 开启 MFA 后可以使用 Authenticator 应用扫描，而腾讯云则需要短信验证码来进行校验。本章节笔者将带领大家深入解析 MFA，并基于 Spring Security 6，结合 MySQL 与 MyBatis-Plus，从理论到实战，快速构建一套企业级的 MFA 认证方案。2. 为什么需要多因素认证？传统认证的风险密码脆弱性：大部分的数据泄露源于弱密码或重复密码。撞库攻击：黑客利用泄露的密码库尝试登录其他系统。钓鱼攻击：伪造登录页面窃取用户凭证。MFA 的核心优势多因素认证（MFA）通过多种不同类别的凭证来共同完成身份验证，显著提升安全性：Something you know（你知道的东西）：用户名与密码、PIN 码等；Something you have（你拥有的东西）：手机收到的 OTP、应用令牌（Authenticator）等；Something you are（你自身的一部分）：生物特征（指纹、面部识别等）。当密码被破解或泄露后，如果没有第二因素（如手机动态验证码），攻击者依然无法登录。常见多因素认证实现方案认证方式安全性用户体验实施成本短信验证码★★★★★★☆★★★TOTP（如 Google Authenticator）★★★☆★★★☆★★☆生物识别★★★☆★★★★★★★★本方案选择 TOTP：平衡安全性与实施成本，兼容 Google Authenticator 等标准应用。3. 多因素认证的核心原理以 TOTP（Time‑based One‑Time Password） 为例：服务端生成用户专属密钥（Secret Key），并在用户首次登录或在安全设置中心将其展示给用户（通常通过二维码形式扫描到 Google Authenticator、Authy 等应用中）。手机端应用（如 Google Authenticator）基于 Secret Key 与当前时间戳，通过 HMAC‑SHA1 算法计算出 6 位动态验证码。用户登录时，输入用户名+密码（第 1 因素），若校验通过，跳转到 MFA 验证页面，要求输入手机上展示的 6 位动态验证码（第 2 因素）。服务端验证客户端提交的动态验证码是否与基于相同 Secret Key 和当前时间戳计算出的值一致。若一致，则认为通过 MFA，登录成功；否则，拒绝登录或提示重试。整个流程中，只有用户掌握 Secret Key（存在手机应用中），且需实时生成动态验证码。即使攻击者获得了用户名+密码，没有手机和 Secret Key，也无法通过第二因素验证。4. 系统架构与流程设计本章节以单体 Spring Boot 应用演示 MFA 流程，生产环境可拆分成独立的认证服务（Auth Service）与业务服务（Resource Service），二者均依赖集中管理的用户与 MFA 数据库。关键流程：❶ MFA 初始化后台管理员或用户注册时，系统为用户生成一对 RSA 密钥（可选）或仅生成 TOTP Secret，保存至用户表中。将生成的 Secret 以二维码或明文形式呈现给用户，用户通过 Google Authenticator 等扫描或手动录入。❷ 第一步：用户名+密码登录用户提交用户名+密码，Service 层校验密码（结合 BCrypt）。校验成功后，将用户标记为“已通过第一步认证”，并生成一个短期令牌（可存放到 session 或 JWT）表示“待 MFA”状态，重定向到 MFA 验证页。❸ TOTP 验证用户在 MFA 验证页中输入 6 位动态验证码，提交后，后台从数据库中取出该用户的 Secret，通过 TOTP 算法生成当前时刻的合法验证码，进行比对。若校验通过，则完成整个登录流程，Spring Security 将真正的 Authentication 对象置入 SecurityContext 中，登录成功，跳转到首页；否则，提示错误并重试。❹ 完整流程图5. Spring Security 整合 MFA 实现根据前面的章节我们已经整合好了 mysql + mybatis 等的项目案例，我们继续追加子模块，引入 Google Authenticator 兼容 TOTP 实现：com.warrenstrange:googleauth:1.5.0。5.1 引入依赖下面以 pom.xml 为例，列出主要依赖： <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.warrenstrange</groupId> <artifactId>googleauth</artifactId> <version>1.5.0</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <scope>provided</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>5.2 用户实体类（User.java）使用 Lombok @Data 简化 getter/setter，mfaEnabled 与 mfaSecret 字段分别表示该用户是否启用 MFA 及其对应的 TOTP 密钥。@Data @TableName("users") public class User { @TableId(type = IdType.AUTO) private Long id; private String username; private String password; private Boolean enabled; private Boolean mfaEnabled; private String mfaSecret; private LocalDateTime createdAt; private LocalDateTime updatedAt; }5.3 用户 Mapper@Mapper public interface UserMapper extends BaseMapper<User> { // 如果需要自定义 SQL，可在此处声明 }5.4 TOTP 工具类（Google Authenticator 兼容）我们将使用 com.warrenstrange.googleauth.GoogleAuthenticator 来生成并验证动态验证码（TOTP）。public class TotpUtils { private static final GoogleAuthenticator gAuth = new GoogleAuthenticator(); /** * 为用户生成一个新的 TOTP 密钥（Base32 编码格式） * * @return Base32 编码的密钥 */ public static String generateSecretKey() { GoogleAuthenticatorKey key = gAuth.createCredentials(); return key.getKey(); } /** * 验证用户提交的 TOTP 码是否合法（基于用户的 Secret Key） * * @param secretKey Base32 编码的 TOTP 密钥 * @param code 用户提交的 6 位验证码 * @return true 如果校验通过；false 否则 */ public static boolean verifyTotp(String secretKey, int code) { return gAuth.authorize(secretKey, code); } /** * 将 Base32 编码的密钥转换为 Hex，若业务需要展示给前端 URI 可用该方法 */ public static String getHexKey(String base32Secret) { Base32 codec = new Base32(); byte[] bytes = codec.decode(base32Secret); return Hex.encodeHexString(bytes); } /** * 生成在 Google Authenticator 中添加账户的二维码 URI * * @param username 用户名 * @param secret Base32 编码密钥 * @param issuer 应用或企业名称，比如 "MyCompany" * @return otpauth://totp/issuer:username?secret=SECRET&issuer=issuer */ public static String getOtpAuthURL(String username, String secret, String issuer) { return String.format( "otpauth://totp/%s:%s?secret=%s&issuer=%s", issuer, username, secret, issuer ); } }说明：generateSecretKey()：生成一个新的 Base32 格式秘钥，用于 TOTP 绑定。verifyTotp(secretKey, code)：校验用户提交的 6 位 TOTP 码是否与当前时刻计算值匹配。getOtpAuthURL(...)：方便在前端生成二维码，让用户用 Google Authenticator 扫描。5.5 Service 层：用户与 MFA 逻辑我们封装用户管理与 MFA 相关的业务逻辑到 UserService。IUserService.java（接口）public interface IUserService { User findByUsername(String username); void register(User user); void enableMfa(Long userId); boolean verifyTotp(Long userId, int code); }UserServiceImpl.java（实现）@Service public class UserServiceImpl implements IUserService { @Autowired private UserMapper userMapper; private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); @Override public User findByUsername(String username) { return userMapper.selectOne(new QueryWrapper<User>().eq("username", username)); } @Override public void register(User user) { // 加密密码 user.setPassword(passwordEncoder.encode(user.getPassword())); user.setEnabled(true); user.setMfaEnabled(false); user.setMfaSecret(null); userMapper.insert(user); } @Override public void enableMfa(Long userId) { // 为用户生成 TOTP Secret 并更新 User u = userMapper.selectById(userId); String secret = TotpUtils.generateSecretKey(); u.setMfaSecret(secret); u.setMfaEnabled(true); userMapper.updateById(u); } @Override public boolean verifyTotp(Long userId, int code) { User u = userMapper.selectById(userId); if (u == null || !u.getMfaEnabled() || u.getMfaSecret() == null) { return false; } return TotpUtils.verifyTotp(u.getMfaSecret(), code); } }说明：register(User)：用户注册时将密码加密存库，初始不启用 MFA。enableMfa(Long)：为指定用户生成 TOTP Secret，更新到数据库，并将 mfaEnabled 标记为 true。verifyTotp(Long, int)：验证用户提交的 TOTP 码是否正确。5.6 安全配置（SecurityConfig.java）Spring Security 6 中，我们需要覆盖默认的认证流程，实现分为两步的 MFA 登录。思路如下：自定义 AuthenticationProvider：首先校验用户名+密码，如果用户启用了 MFA，就抛出一个自定义异常（MfaRequiredException），在 AuthenticationFailureHandler 中捕获并重定向到 MFA 验证页。在 MFA 验证页中，用户提交 TOTP 码后，我们自定义一个 MfaAuthenticationFilter，从 session 中读取“待 MFA”状态的用户信息，再调用 Service 校验 TOTP。如果通过，则直接构建最终的 UsernamePasswordAuthenticationToken 并置入 SecurityContext。5.6.1 自定义异常 MfaRequiredExceptionpublic class MfaRequiredException extends AuthenticationException { private final String username; public MfaRequiredException(String msg, String username) { super(msg); this.username = username; } public String getUsername() { return username; } }5.6.2 自定义 AuthenticationProvider/** * 第一步：校验用户名 + 密码 * 如果用户启用 MFA，则抛出 MfaRequiredException，后续由 MfaAuthenticationFilter 处理 */ @Component public class CustomAuthenticationProvider implements AuthenticationProvider { @Autowired private IUserService userService; private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { String username = authentication.getName(); String password = (String) authentication.getCredentials(); User user = userService.findByUsername(username); if (user == null || !user.getEnabled()) { throw new BadCredentialsException("用户名或密码错误"); } if (!passwordEncoder.matches(password, user.getPassword())) { throw new BadCredentialsException("用户名或密码错误"); } // 如果用户启用了 MFA，则抛出自定义异常，提示进行第二步验证 if (Boolean.TRUE.equals(user.getMfaEnabled())) { throw new MfaRequiredException("MFA 验证必需", username); } // 未启用 MFA 则直接构建 Authentication return new UsernamePasswordAuthenticationToken( username, null, Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")) ); } @Override public boolean supports(Class<?> authentication) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); } }说明：如果用户开启 mfaEnabled，校验密码后不直接登录，而是通过抛出异常告知后续过滤器进行 MFA 验证。5.6.3 自定义 MFA 过滤器/** * 该过滤器负责处理 /mfa-verify POST 请求， * 从 session 中获取待验证用户名，校验用户提交的 TOTP 码。 */ @Component public class MfaAuthenticationFilter extends AbstractAuthenticationProcessingFilter { private final IUserService userService; public MfaAuthenticationFilter(IUserService userService) { super(new AntPathRequestMatcher("/mfa-verify", "POST")); this.userService = userService; // 不让 Spring Security 为我们阻止 CSRF，示例中 CSRF 已关闭 } @Override public Authentication attemptAuthentication( HttpServletRequest request, HttpServletResponse response ) throws IOException, ServletException { // 应用前端将用户名暂存到 sessionAttribute： "MFA_USER" String username = (String) request.getSession().getAttribute("MFA_USER"); if (username == null) { throw new RuntimeException("会话中找不到待 MFA 用户"); } // 获取用户提交的 TOTP 码 String codeStr = request.getParameter("code"); if (codeStr == null || codeStr.isEmpty()) { throw new RuntimeException("TOTP 码不能为空"); } int code; try { code = Integer.parseInt(codeStr); } catch (NumberFormatException e) { throw new RuntimeException("TOTP 码格式不正确"); } // 从数据库校验 TOTP User user = userService.findByUsername(username); boolean valid = userService.verifyTotp(user.getId(), code); if (!valid) { throw new RuntimeException("TOTP 验证失败"); } // 验证成功，构建真正的 Authentication 对象 UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( username, null, Collections.singletonList(() -> "ROLE_USER") ); return auth; } @Override protected void successfulAuthentication( HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult ) throws IOException, ServletException { // 将最终的 Authentication 填入 SecurityContext SecurityContextHolder.getContext().setAuthentication(authResult); // 登录成功后清除 session 中的 MFA 用户标志 request.getSession().removeAttribute("MFA_USER"); // 跳转到首页 response.sendRedirect("/"); } @Override protected void unsuccessfulAuthentication( HttpServletRequest request, HttpServletResponse response, AuthenticationException failed ) throws IOException, ServletException { // 验证失败，跳回 MFA 验证页面 response.setContentType(MediaType.TEXT_PLAIN_VALUE); response.getWriter().write("MFA 验证失败：" + failed.getMessage()); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); } }5.6.4 自定义 AuthenticationFailureHandler/** * 处理第一步用户名/密码登录失败或触发 MFA 的情况 */ @Component public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure( HttpServletRequest request, HttpServletResponse response, AuthenticationException exception ) throws IOException, ServletException { // 如果是 MfaRequiredException，重定向到 /mfa 页面，并将用户名存入 session if (exception instanceof MfaRequiredException) { String username = ((MfaRequiredException) exception).getUsername(); request.getSession().setAttribute("MFA_USER", username); // 重定向到 MFA 验证页面 response.sendRedirect("/mfa"); } else { // 普通登录失败，重定向回 /login?error response.sendRedirect("/login?error=true"); } } }说明：当 CustomAuthenticationProvider 抛出 MfaRequiredException 时，说明用户通过密码校验但需要第二步 MFA，此时将“待 MFA”用户名写入 session，并重定向到 MFA 验证页面 /mfa。普通失败（如密码错误）则带上 ?error=true 重定向回登录页。5.6.5 安全配置 SecurityConfig.java将 CustomAuthenticationProvider 注册到 Spring Security，替代默认的用户名/密码校验逻辑。配置表单登录，登录失败由 CustomAuthenticationFailureHandler 处理。通过 Session 保持“待 MFA”状态直到第二步完成。/** * 核心安全配置： * 1. 注入自定义 AuthenticationProvider * 2. 配置表单登录和 MfaAuthenticationFilter */ @Configuration public class SecurityConfig { @Autowired private CustomAuthenticationProvider customAuthenticationProvider; @Autowired private MfaAuthenticationFilter mfaAuthenticationFilter; @Autowired private CustomAuthenticationFailureHandler customFailureHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationConfiguration authConfig) throws Exception { // 禁用 CSRF 简化示例 http.csrf(csrf -> csrf.disable()); // 使用自定义 AuthenticationProvider 替换默认的 DaoAuthenticationProvider http.authenticationProvider(customAuthenticationProvider); // 1. 首先，配置表单登录 http.authorizeHttpRequests(auth -> auth .requestMatchers("/login", "/register", "/css/**", "/js/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") // 与表单提交 action 保持一致 .failureHandler(customFailureHandler) .defaultSuccessUrl("/", true) ); // 2. 注册 MFA 过滤器，它要在 UsernamePasswordAuthenticationFilter 之后执行 http.addFilterAfter(mfaAuthenticationFilter, org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class); // 3. Session 管理：MFA 过程中会话保持 http.sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ); // 4. 未授权时返回 401 http.exceptionHandling(ex -> ex.authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")) ); return http.build(); } // 若需手动获取 AuthenticationManager，可使用以下 Bean @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } }关键点：将 CustomAuthenticationProvider 注册到 Spring Security。使用 CustomAuthenticationFailureHandler 处理第一步登录失败逻辑。在 UsernamePasswordAuthenticationFilter 之后添加 MfaAuthenticationFilter，专门处理 /mfa-verify 请求。5.7 控制器：登录、MFA 验证、注册与秘钥初始化我们需要提供几个页面和对应的 Controller：/login：自定义登录页面（用户名+密码）/register：用户注册页面/mfa：MFA 验证页面，用户输入 6 位 TOTP 码/mfa-verify：MFA 验证提交接口，由 MfaAuthenticationFilter 处理/enable-mfa：在用户登录后打开此接口可为用户生成 TOTP Secret，并展示二维码@Controller public class AuthController { @Autowired private IUserService userService; /** * 登录页（第一步：用户名 + 密码） */ @GetMapping("/login") public String loginPage(@RequestParam(required = false) String error, Model model) { model.addAttribute("error", error != null); return "login"; } /** * 注册页（仅示例） */ @GetMapping("/register") public String registerPage() { return "register"; } @PostMapping("/register") public String doRegister(@RequestParam String username, @RequestParam String password) { User u = new User(); u.setUsername(username); u.setPassword(password); userService.register(u); return "redirect:/login"; } /** * MFA 验证页：用户输入动态验证码 */ @GetMapping("/mfa") public String mfaPage(HttpSession session, Model model) { String username = (String) session.getAttribute("MFA_USER"); if (username == null) { // 无待验证用户，跳到登录页 return "redirect:/login"; } model.addAttribute("username", username); return "mfa"; } /** * 启用 MFA：登录后用户请求此接口可获取 TOTP Secret 与二维码 URL */ @GetMapping("/enable-mfa") public String enableMfa(Authentication authentication, Model model) { if (authentication == null || !authentication.isAuthenticated()) { return "redirect:/login"; } String username = authentication.getName(); User u = userService.findByUsername(username); if (u.getMfaEnabled()) { model.addAttribute("message", "MFA 已启用"); return "home"; } // 为用户生成秘钥并开启 MFA userService.enableMfa(u.getId()); u = userService.findByUsername(username); // 刷新 String secret = u.getMfaSecret(); String otpAuthURL = TotpUtils.getOtpAuthURL(username, secret, "MyCompany"); model.addAttribute("otpAuthURL", otpAuthURL); model.addAttribute("secret", secret); return "enable-mfa"; } @GetMapping("/") public String homePage() { return "home"; } }说明：GET /enable-mfa：已登录用户访问此接口，系统调用 userService.enableMfa(...) 为其生成并保存 TOTP Secret，并展示二维码 URL。5.8 页面模板（Thymeleaf）为简化，以下示例仅为最基本表单。生产环境可加入更丰富的样式与 JS 验证。login.html 登录 登录 用户名: 密码: 用户名或密码错误 登录 没有账号？注册 register.html 注册 注册 用户名: 密码: 注册 已有账号？登录 mfa.html MFA 验证 多因素认证 用户 ，请输入手机应用上的 6 位动态验证码： 动态验证码: 验证 enable-mfa.html 启用 MFA 启用多因素认证 (MFA) 请使用 Google Authenticator 或其他兼容 TOTP 的应用扫描下方二维码，或使用秘钥手动添加： OTPAuth URL: Secret Key: 设置完成后，请退出重新登录并输入动态验证码。 home.html 首页 欢迎来到系统 您已成功登录（且通过 MFA 验证）。 启用 MFA（如果尚未启用） 退出登录 注意：如果需要在页面中展示二维码，可以使用前端 QRCode.js 等库，将 otpAuthURL 渲染为二维码。6. 总结与落地建议本文从“为什么需要多因素认证”入手，讲解了基于 TOTP 的 MFA 核心原理，并详细演示了如何在 Spring Security 6 中分两步完成登录与 MFA 验证的流程。关键点回顾：第一步：用户名+密码自定义 AuthenticationProvider，校验用户名与密码；若用户启用 MFA，则抛出 MfaRequiredException，并将用户名暂存到 Session。第二步：TOTP 验证在数据库 users 表中增加 mfa_enabled 与 mfa_secret 字段；Service 层通过 Google Authenticator 兼容库生成并验证动态验证码；MyBatis-Plus 简化了实体与 Mapper 的开发。实际生产环境推荐二维码展示与绑定：在 /enable-mfa 页面使用前端二维码生成库（如 qrcode.js）将 otpAuthURL 渲染为二维码图片，方便用户扫码。密钥保护：mfa_secret 为敏感数据，建议对其进行数据库加密存储或使用 KMS 等专用系统保护。备份码与恢复：当用户手机丢失时，可预先生成一组一次性“恢复码”，用户在绑定 MFA 时妥善保存，避免无法登录。登录失败锁定：对于连续多次 TOTP 验证失败的账户，可暂时锁定或触发告警，防止暴力破解。SSL/TLS 强制：确保所有页面（尤其是登录与 MFA 页面）使用 HTTPS，防止中间人攻击截获验证码。会话超时与防并发：可考虑对“待 MFA”状态的会话设置合理的超时时间（如 2 分钟），超时后必须重新进行第一步登录。通过上面的设计与实现，企业级应用即可在原有用户名+密码的基础上，平滑地接入基于 TOTP 的多因素认证，大幅提升系统安全性，抵御常见的账户破解与钓鱼风险。如果你在实践过程中有任何疑问或更好的扩展思路，欢迎在评论区留言，最后希望大家 一键三连 给博主一点点鼓励！
Spring Security实战教程（十六）微服务间安全通信 - JWT 令牌传递与校验机制
1. 前言在微服务架构中，服务间的安全通信至关重要。为了解决不同服务之间的认证与授权问题，常常使用 JSON Web Token (JWT) 作为令牌传递机制。JWT 是一种轻量级的令牌格式，包含丰富的用户身份信息，并且可以被服务端验证。利用 Spring Security 6 和 JWT，我们可以轻松实现服务间的安全通信，确保只有经过授权的客户端才能发起服务间请求。在前面的第 9 章节中，笔者详细介绍了 JWT + Spring Security 的整合，本文仅对 JWT 进行简要介绍。更多详细内容感兴趣的小伙伴可回顾 Spring Security 实战教程（九）前后端分离认证实战 - JWT+SpringSecurity 无缝整合。本文将带领大家快速了解如何使用 Spring Security 6 配合 JWT 实现服务间的安全通信。微服务间 JWT 通信核心流程2. 配置 Spring Security 6 与 Nimbus-JOSE-JWT本章节笔者使用 Nimbus-JOSE-JWT 作为 JWT 处理库。2.1 引入依赖 <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency> <dependency> <groupId>com.nimbusds</groupId> <artifactId>nimbus-jose-jwt</artifactId> <version>9.37</version> </dependency>spring-boot-starter-oauth2-resource-server 提供了 Spring Security 中资源服务器的支持。nimbus-jose-jwt 提供了生成和验证 JWT 的功能。关键依赖说明依赖项作用必需场景spring-boot-starter-security安全框架核心所有安全场景必须spring-boot-starter-oauth2-resource-serverOAuth2 资源服务器支持JWT 验证必需nimbus-jose-jwtJWT 编码/解码实现JWT 处理必需2.2 配置公钥和私钥为了使用 RSA 进行 JWT 签名和验证，我们需要配置私钥（用于签名）和公钥（用于验证）。可以将密钥存储为 PEM 格式的文件，并在 Spring Boot 配置中加载这些密钥。生成公私钥对首先，生成一个 RSA 公私钥对：# 生成私钥（private.pem） openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048 # 提取公钥（public.pem） openssl rsa -pubout -in private.pem -out public.pem将密钥加载到 Spring Boot将私钥和公钥存储为文件，并在配置中加载它们：# application.yml 配置公钥和私钥路径 spring: security: oauth2: resourceserver: jwt: public-key-location: classpath:public.pem private-key-location: classpath:private.pem此处我们将密钥文件放在 resources 目录下，并通过配置 public-key-location 和 private-key-location 来加载它们。也可指定系统上的其他目录3. JWT 生成和验证3.1 生成 JWT 令牌创建一个方法，通过 RSA 私钥签名 JWT 令牌。使用 Nimbus-JOSE-JWT 生成带有签名的 JWT。import com.nimbusds.jose.*; import com.nimbusds.jose.crypto.RSASSASigner; import com.nimbusds.jwt.JWTClaimsSet; import com.nimbusds.jwt.SignedJWT; import java.security.PrivateKey; import java.util.Date; public class JwtUtils { private final PrivateKey privateKey; public JwtUtils(PrivateKey privateKey) { this.privateKey = privateKey; } public String createJWT(String subject) throws Exception { JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() .subject(subject) .issueTime(new Date()) .expirationTime(new Date(System.currentTimeMillis() + 3600000)) // 1 小时过期 .build(); SignedJWT signedJWT = new SignedJWT( new JWSHeader(JWSAlgorithm.RS256), claimsSet ); RSASSASigner signer = new RSASSASigner(privateKey); signedJWT.sign(signer); return signedJWT.serialize(); } }该方法根据传入的用户名（subject）生成一个 JWT 令牌，并使用 RSA 私钥进行签名。生成的 JWT 将包含基本声明 （如 subject、issueTime 和 expirationTime）。3.2 验证 JWT 令牌验证 JWT 令牌需要使用 RSA 公钥来验证其签名。以下是使用 Nimbus-JOSE-JWT 库验证 JWT 的代码：import com.nimbusds.jose.*; import com.nimbusds.jose.crypto.RSASSAVerifier; import com.nimbusds.jwt.SignedJWT; import java.security.PublicKey; public class JwtUtils { private final PublicKey publicKey; public JwtUtils(PublicKey publicKey) { this.publicKey = publicKey; } public boolean validateJWT(String token) throws Exception { SignedJWT signedJWT = SignedJWT.parse(token); RSASSAVerifier verifier = new RSASSAVerifier(publicKey); return signedJWT.verify(verifier); } }该方法解析并验证传入的 JWT 令牌，使用 RSA 公钥验证签名的合法性。签名有效时返回 true，否则返回 false。4. 配置 Spring Security 资源服务器使用 Spring Security 配置资源服务器，确保每个请求都携带有效的 JWT 令牌，并使用 RSA 公钥验证令牌。@Configuration public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeRequests(auth -> auth .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.decoder(jwtDecoder()))); return http.build(); } @Bean public JwtDecoder jwtDecoder() throws Exception { RSAPublicKey publicKey = (RSAPublicKey) KeyFactory.getInstance("RSA") .generatePublic(new X509EncodedKeySpec(Files.readAllBytes(Paths.get("classpath:public.pem")))); return NimbusJwtDecoder.withPublicKey(publicKey).build(); } }在上述配置中，我们指定了 JwtDecoder，使用 NimbusJwtDecoder 加载 RSA 公钥，并为每个请求验证 JWT 令牌。通过 oauth2ResourceServer(oauth2 -> oauth2.jwt()) 配置，Spring Security 会自动处理 JWT 校验。5. 服务间的 JWT 令牌传递与校验5.1 服务 A 生成 JWT服务 A 在用户登录后生成 JWT，并将其返回给客户端：@RestController public class AuthController { private final JwtUtils jwtUtils; public AuthController(JwtUtils jwtUtils) { this.jwtUtils = jwtUtils; } @PostMapping("/login") public String login(@RequestBody LoginRequest loginRequest) throws Exception { if ("user".equals(loginRequest.getUsername()) && "password".equals(loginRequest.getPassword())) { return jwtUtils.createJWT(loginRequest.getUsername()); } throw new UnauthorizedException("Invalid credentials"); } }5.2 客户端发送 JWT客户端将 JWT 令牌放入请求的 Authorization 头部：public class RestClient { public static String getProtectedResource(String token) { HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + token); HttpEntity<String> entity = new HttpEntity<>(headers); RestTemplate restTemplate = new RestTemplate(); ResponseEntity<String> response = restTemplate.exchange( "http://localhost:8081/api/protected", HttpMethod.GET, entity, String.class ); return response.getBody(); } }5.3 服务 B 校验 JWT服务 B 验证 JWT 令牌，确保请求来自合法的客户端：@RestController @RequestMapping("/api") public class ProtectedResourceController { @GetMapping("/protected") public ResponseEntity<String> getProtectedData() { return ResponseEntity.ok("This is a protected resource!"); } }6. 结语通过本章节的介绍，相信小伙伴们已经掌握了如何使用 JWT 与 RSA 非对称加密在 Spring Security 6 中实现 JWT 令牌传递与校验机制。非对称加密的优势在于公钥可以公开分发，而私钥仅由服务端持有，从而增强了系统的安全性。当然，本章节未引入 OpenFeign 作为服务间调用工具，仅使用了最原始的 RestTemplate 进行其他服务的测试请求。小伙伴们可根据实际需求进行调整和完善，进一步提升系统的安全性。
Spring Security实战教程（十五）快速集成 GitHub 与 Gitee 的社交登录
1. 前言在微服务与前后端分离架构中，第三方社交登录已成为提升用户体验的重要功能。社交登录可以有效降低用户注册成本，同时利用第三方平台的账号体系，实现快速认证与信息获取。Spring Security 6 作为 Java 生态中的安全框架，通过 OAuth2 协议简化了第三方认证的集成流程。本章节笔者将通过完整代码案例，讲解如何基于 Spring Security 6 实现 GitHub 和 Gitee 的社交登录功能。2. 原理分析回顾上一章节介绍的 OAuth 2.0 授权码流程（最新 Spring Security 实战教程（十四）OAuth2.0 精讲 - 四种授权模式与资源服务器搭建）：其主要流程包括：跳转授权：客户端（我们的网站）将用户重定向至第三方授权服务器的授权端点，携带 client_id、redirect_uri、scope 等参数。用户登录并同意：用户在第三方平台完成登录后，同意授权给客户端指定权限。回调获取授权码：授权服务器重定向回客户端注册的 redirect_uri，并在查询参数中附带 code。交换令牌：客户端后端使用 code、client_secret 等向授权服务器的令牌端点发起 POST 请求，获取 access_token。获取用户信息：客户端携带 access_token 调用用户信息端点，解析并登录或注册用户。3. 开发准备3.1 初始化项目继续基于之前的项目构建子项目 social-login-spring-security，pom 文件引入项目依赖：<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-client</artifactId> </dependency> </dependencies>GitHub/Gitee 开发者账号：用于注册 OAuth 应用。3.2 注册 GitHub OAuth 应用访问 GitHub 设置 → Developer settings → OAuth Apps → New OAuth App，填写应用名、主页 URL、授权回调 URL。填写应用信息：默认的重定向 URI 模板为 {baseUrl}/login/oauth2/code/{registrationId}。registrationId 是 ClientRegistration 的唯一标识符。例如 GitHub 固定写法则为：{baseUrl}/login/oauth2/code/github。获取应用程序 ID，生成应用程序密钥：保存好生成的 Client ID 与 Client Secret。3.3 注册 Gitee OAuth 应用登录 Gitee，访问 Gitee 应用管理，创建新应用。提交创建应用后会得到 Client ID 和 Client Secret。4. 实战案例完成上述准备工作后，我们开始对项目进行配置。4.1 配置 application.yml# 最新 Spring Security 实战教程（十五）快速集成 GitHub 与 Gitee 的社交登录 spring: application: name: social-login-spring-security security: oauth2: client: registration: github: client-id: YOUR_CLIENT_ID client-secret: YOUR_CLIENT_SECRET scope: user:email redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" authorization-grant-type: authorization_code client-name: github gitee: client-id: YOUR_CLIENT_ID client-secret: YOUR_CLIENT_SECRET scope: user_info client-name: Gitee authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" provider: gitee provider: gitee: authorization-uri: https://gitee.com/oauth/authorize token-uri: https://gitee.com/oauth/token user-info-uri: https://gitee.com/api/v5/user user-name-attribute: name server: port: 80894.2 自定义用户信息处理GitHub 和 Gitee 返回的用户数据结构不同，需统一处理：public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> { @Override public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); OAuth2User oAuth2User = delegate.loadUser(userRequest); String registrationId = userRequest.getClientRegistration().getRegistrationId(); Map<String, Object> attributes = oAuth2User.getAttributes(); // 根据平台解析用户信息 if ("github".equals(registrationId)) { return new DefaultOAuth2User( oAuth2User.getAuthorities(), attributes, "login" // GitHub 的用户名字段 ); } else if ("gitee".equals(registrationId)) { return new DefaultOAuth2User( oAuth2User.getAuthorities(), attributes, "name" // Gitee 的用户名字段 ); } throw new OAuth2AuthenticationException("Unsupported platform"); } }4.3 SecurityConfig 配置@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/", "/public/**").permitAll() .anyRequest().authenticated() ) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService()) ) ) .csrf(AbstractHttpConfigurer::disable); return http.build(); } @Bean public OAuth2UserService<OAuth2UserRequest, OAuth2User> customOAuth2UserService() { return new CustomOAuth2UserService(); } }4.4 控制器与页面编写自定义的登录页，授权登录后返回授权的用户信息。@Controller public class IndexController { @GetMapping("/") public String index(Model model, @RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient, @AuthenticationPrincipal OAuth2User oauth2User) { model.addAttribute("userName", oauth2User.getName()); model.addAttribute("clientName", authorizedClient.getClientRegistration().getClientName()); model.addAttribute("userAttributes", oauth2User.getAttributes()); // TODO 可以增加与项目用户注册、绑定等业务 service return "index"; } @GetMapping("/user") @ResponseBody public Map<String, Object> userInfo(OAuth2AuthenticationToken authentication) { OAuth2User user = authentication.getPrincipal(); return Map.of( "username", user.getName(), "authorities", user.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()) ); } }Thymeleaf 模板页面： Spring Security - OAuth 2.0 Login User: OAuth 2.0 Login with Spring Security You are successfully logged in via the OAuth 2.0 Client User Attributes: : 5. 测试验证访问首页：http://localhost:8089，跳转至 Spring Security 默认登录页。注：应用回调地址如果填写的是 localhost，本地访问项目也需保持 localhost 访问；若使用 127.0.0.1 会导致回调地址不一致，出现回调地址错误的异常。选择登录方式：点击 GitHub 或 Gitee 按钮，跳转至对应授权页面。GitHub 授权页 Gitee 授权页 授权登录：同意授权后，自动跳转回应用，显示用户信息。访问 http://localhost:8089/user 显示经过处理的用户信息。{ "authorities": [ "OAUTH2_USER", "SCOPE_user:email" ], "username": "授权返回的用户名" }6. 常见问题与扩展回调地址不匹配：确保 redirect-uri 与注册应用时填写的一致。跨域问题：前端分离项目需配置 CorsFilter。用户信息字段缺失：检查 user-info-uri 和 user-name-attribute 配置。7. 总结通过 Spring Security 6 的 OAuth2 Client 模块，开发者可以快速集成主流社交登录功能。本章节案例以 GitHub 和 Gitee 为例，展示了从应用注册到用户信息处理的完整流程，适用于需要第三方登录的 Web 应用场景。后续章节笔者将继续扩展支持微信、支付宝、QQ、微博等更多平台，进一步提升用户体验。
Spring Security实战教程（十四）OAuth2.0 精讲 - 四种授权模式与资源服务器搭建
1. 前言（OAuth2 简介）目前 OAuth2.0 已成为现代应用认证授权的标准，从单点登录到微服务架构都依赖其构建安全通道。我们常见的微信、QQ、微博等应用均有使用。OAuth 2.0（Open Authorization）是一种开放的授权框架，允许应用在不暴露用户密码的前提下，安全地代表用户访问第三方服务上的受保护资源。本章节，笔者将带领大家深入解析四种核心授权模式，并基于 Spring Security 6 手把手搭建安全的资源服务器，助您掌握分布式系统的认证精髓。注：部分介绍参考了官方文档：https://docs.spring.io/spring-security/reference/servlet/oauth2/index.html，小伙伴们可自行阅读并查阅官方示例代码。2. OAuth2 的角色根据 RFC 6749 标准，OAuth 2.0 定义了四种角色，每个角色在授权流程中承担不同职责：资源所有者（Resource Owner）：通常是终端用户，拥有受保护资源。客户端（Client）：向用户请求授权，代表用户访问资源的应用，通常是一个 Web 或移动应用。授权服务器（Authorization Server）：认证资源所有者身份并颁发访问令牌。资源服务器（Resource Server）：托管资源，根据令牌的有效性和权限范围决定是否允许访问。为帮助小伙伴们更好理解，笔者用一个现实生活中的场景（小区门禁系统）来类比 OAuth2.0 的工作原理，直观理解其角色与四种授权模式。场景：小区门禁系统假设你住在一个需要刷卡或扫码进入的小区，门禁系统要求验证身份后才能开门。以下是 OAuth2.0 的四个角色映射：资源所有者（Resource Owner）：住户（你），你是小区住户，拥有进出小区的权限。客户端（Client）：快递员或访客，需要临时进入小区的人或设备（如快递员的小程序）。授权服务器（Authorization Server）：物业中心，负责验证住户身份，并发放临时通行权限（令牌）。资源服务器（Resource Server）：小区门禁闸机，接收令牌（如二维码或门禁卡），验证后决定是否放行。OAuth2 的核心逻辑你（住户）不想直接给快递员门禁卡或密码，而是通过物业中心生成一个临时二维码（令牌），限制其权限（例如：仅允许进入 1 次，或仅在 30 分钟内有效）。令牌由物业中心发放，你可以随时在物业系统中吊销它。3. OAuth 2.0 四种授权模式不同场景下，OAuth 2.0 定义了四种核心授权模式，各有适用范围和安全考量。3.1 授权码模式（Authorization Code Grant）授权码模式，指第三方应用先申请一个授权码，然后再用该码获取令牌。 这种方式最常用、最复杂，也是最安全的，适用于那些有后端的 Web 应用。授权码通过前端传送，令牌则储存在后端，且所有与资源服务器的通信都在后端完成。这样的前后端分离，可避免令牌泄漏。场景：适用于安全的服务端 Web 应用，例如电商网站在结算时需要访问支付平台 API。流程：客户端引导用户浏览器跳转到授权服务器授权端点；用户登录并同意后，授权服务器重定向携带 code 到客户端回调 URL；客户端使用该 code 向令牌端点申请访问令牌，并在请求中携带客户端凭证。优点：令牌通过后端交换，避免在浏览器中暴露，安全性较高。现实生活模拟场景 还是以小区门禁系统为例：快递员通过「小区快递 App」申请进入权限。快递员点击 App「申请进入小区」，App 跳转到物业的授权页面。你（住户）在物业 App 上接收到授权通知，并同意授权（例如：允许快递员在 30 分钟内进入小区 1 次）。物业中心生成一个授权码返回给快递员的快递 App。快递 App 用授权码向物业中心换取一个临时二维码（令牌）。快递员用二维码扫码进入小区门禁（资源服务器）。3.2 隐式模式（Implicit Grant）场景：传统的单页应用（SPA）或无法安全存储客户端密钥的纯前端应用。流程：用户同意授权后，授权服务器直接在重定向 URI 的 fragment 中返回访问令牌，无需第二次交换。缺点：令牌暴露在浏览器，易受截取攻击。注：Spring Security 6 已移除隐式模式支持，建议使用 PKCE 增强的授权码模式。// Spring Security 6 已移除隐式模式支持 http.oauth2Client(oauth2 -> oauth2 .authorizationCodeGrant(Customizer.withDefaults()) )现实生活模拟场景 访客临时申请进入小区：访客在门禁屏幕上点击「申请临时通行」。直接跳转到物业授权页面，你登录并同意授权。物业中心直接返回一个临时二维码（令牌）到门禁屏幕的 URL 中。访客用二维码扫码进入，但二维码仅在 5 分钟内有效。3.3 客户端凭证模式（Client Credentials Grant）凭证式模式，也叫客户端模式，适用于没有前端的命令行应用，即在命令行下请求令牌。 这种方式给出的令牌是针对第三方应用的，而不是针对用户的，即可能多个用户共享同一个令牌。场景：应用间 M2M 通信，如后台微服务之间相互调用私有 API。流程：客户端直接向令牌端点发送 grant_type=client_credentials 请求，并在 HTTP Basic Auth 中提供客户端 ID/密钥，即可获取访问令牌。特点：不涉及用户，适合服务账户场景，配置简单且安全性可控。现实生活模拟场景 物业巡逻车需要进出小区所有区域：物业巡逻车用自己的工牌（客户端 ID 和密钥）向物业中心申请令牌。物业中心验证工牌后，发放一个万能通行令牌（可进入所有区域）。巡逻车凭令牌自由进出小区门禁。3.4 资源所有者密码凭证模式（Resource Owner Password Credentials Grant）密码式模式：如果你高度信任某个应用，RFC 6749 也允许用户把用户名和密码直接告诉该应用，该应用使用你的密码申请令牌。 这种方式需要用户给出自己的用户名/密码，风险较大，因此只适用于其他授权方式都无法采用的情况，且必须是用户高度信任的应用。场景：高度信任的客户端（如官方移动端应用）与授权服务器共属一个安全域时，可直接收集用户名和密码。流程：客户端将用户名、密码与自身凭证一起 POST 到令牌端点，授权服务器验证后颁发令牌。风险：客户端需处理用户敏感凭证，安全性最低，通常仅在无法采用授权码模式时作为后备方案。现实生活模拟场景 你的家人定期出入小区（物业通过你的账号密码给其他人授权）：你直接将门禁账号密码授权给家人。家人用你的账号密码向物业中心申请长期有效的通行令牌。物业返回令牌，家人凭令牌进出小区。3.5 总结模式门禁场景类比权限范围风险授权码模式快递员通过 App 申请临时通行限时、限次低（令牌不暴露）隐式模式访客直接扫码获得短期通行超短时效中（URL 暴露令牌）密码模式自家家人长期通行长期有效高（需信任设备）客户端凭证模式物业巡逻车自由通行全权限中（客户端可控）4. 授权服务器与资源服务器搭建讲解了 OAuth2 的四种授权模式，下面我们来实现授权服务器与资源服务器的搭建。❶ 引入依赖在 Spring Boot 3 + Spring Security 6 项目中，添加以下依赖即可快速构建授权服务器与资源服务器：<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>❷ 授权服务器配置使用 OAuth2AuthorizationServerConfigurer 和基于内存的 RegisteredClientRepository 快速配置：@Configuration public class AuthServerConfig { @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("web-client") .clientSecret("{noop}web-secret") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri("http://localhost:8080/login/oauth2/code/web-client") .scope("read") .build(); return new InMemoryRegisteredClientRepository(client); } @Bean public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurer<HttpSecurity> configurer = new OAuth2AuthorizationServerConfigurer<>(); http.apply(configurer) .and() .exceptionHandling(ex -> ex.authenticationEntryPoint( new LoginUrlAuthenticationEntryPoint("/login") )); return http.build(); } }该配置启用了授权码与客户端凭证模式，并自动暴露 /oauth2/authorize、/oauth2/token 等端点。❸ 资源服务器配置在 application.yml 中指定授权服务器的 issuer-uri：spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9000并通过 Lambda DSL 定义安全规则：@Configuration public class ResourceServerConfig { @Bean public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public").permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); } }此时，所有 /api/** 请求均需携带合法的 JWT 访问令牌，否则会返回 401/403 错误。❹ 完整示例代码结合上述讲解，完整代码展示如下：@SpringBootApplication public class OAuth2ServerApplication { public static void main(String[] args) { SpringApplication.run(OAuth2ServerApplication.class, args); } // 注册客户端 @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient appClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("app-client") .clientSecret("{noop}app-secret") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri("http://localhost:8080/login/oauth2/code/app-client") .scope("read") .build(); return new InMemoryRegisteredClientRepository(appClient); } // 授权服务器安全链 @Bean @Order(1) public SecurityFilterChain authServerFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurer<HttpSecurity> oauth2Config = new OAuth2AuthorizationServerConfigurer<>(); http.apply(oauth2Config) .and() .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/token")) .exceptionHandling(ex -> ex.authenticationEntryPoint( new LoginUrlAuthenticationEntryPoint("/login") )); return http.build(); } // 资源服务器安全链 @Bean @Order(2) public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception { http .securityMatcher("/api/**") .authorizeHttpRequests(auth -> auth.anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); return http.build(); } }在此示例中，同一应用既扮演授权服务器，也作为资源服务器提供 /api/** 保护接口，启动后即可体验完整的 OAuth 2.0 流程。结语：构建开放安全的认证生态通过本章节的讲解与实战示例，相信小伙伴们已掌握 OAuth 2.0 的核心概念、四大角色、四种授权模式及其在 Spring Security 6 中的配置方式。后续可根据业务需求，引入 PKCE、刷新令牌、动态客户端注册等高级特性，进一步提升系统安全性与可扩展性。
Spring Security实战教程（十三）会话管理机制 - 并发控制与会话固定攻击防护
1. 前言在 Web 应用安全体系中，会话管理是认证授权后的重要防线。攻击者常通过会话劫持与会话固定突破系统边界，而业务系统则面临并发滥用带来的资源风险。Spring Security 的会话管理模块由 SessionManagementFilter 与一系列 SessionAuthenticationStrategy 共同协作，负责在用户登录或访问受保护资源时执行统一的会话检查与策略。默认情况下，框架允许单个用户拥有无限多个并发会话，而在每次登录时会执行会话固定保护策略，将旧 Session ID 迁移到新 Session 中，以防止攻击者利用已有的 Session ID 进行劫持。在本章节，笔者将基于 Spring Security 6，深入解析会话管理的安全实践。2. 会话固定攻击防护原理2.1 攻击概述会话固定（Session Fixation）指攻击者诱导受害者在已知的 Session ID 下登录，随后持该 ID 进行未授权操作。Spring Security 通过会话固定保护策略，在每次用户登录后刷新 Session ID 来防御此类攻击。攻击流程解析2.2 Spring Security 防御策略SessionManagementFilter 在认证成功后，会调用相应的 SessionAuthenticationStrategy 执行上述策略。migrateSession() 在底层通过 HttpServletRequest.changeSessionId() 或复制属性到新 Session 来实现 ID 刷新，有效防止攻击者使用固定 ID 进行入侵。配置样例@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 启用会话固定防护，采用迁移会话策略 .sessionManagement(session -> session .sessionFixation(fix -> fix.migrateSession() // 默认：保留旧数据，生成新 Session ID // fix.changeSessionId() // 仅变更会话 ID // fix.newSession() // 完全新建 Session，不保留任何属性 // fix.none() // 禁用保护（不推荐） ) ) .authorizeHttpRequests(auth -> auth .anyRequest().authenticated() ); return http.build(); } }配置说明none：不做任何处理，不推荐使用changeSessionId：认证后仅变更会话 IDnewSession：创建全新、干净的 Session，不复制原属性migrateSession（默认）：创建新 Session 并复制原有属性，保留必要数据同时更换 ID，防范攻击者利用旧 ID3. 并发会话控制方案当同一账号在多处登录时，可能带来数据冲突、授权混乱甚至资源浪费。Spring Security 通过并发会话控制，可限制用户的最大在线会话数，并在超过限制时选择“踢出旧会话”或“拒绝新登录”两种策略。3.1 为什么要限制并发会话？防止账号共享：同一账号在多处登录可能意味着凭证泄露防范会话劫持：一旦旧会话被劫持，可通过限制数量自动踢出旧会话3.2 核心组件SessionRegistry：维护用户与其活跃 Session 的映射ConcurrentSessionControlAuthenticationStrategy：在用户登录时检查并发会话数量，超过上限可抛出 SessionAuthenticationException 或踢出最早会话HttpSessionEventPublisher：侦听 HttpSessionEvent，在 Session 销毁时同步更新 SessionRegistry3.3 配置样例@Configuration @EnableWebSecurity public class SecurityConfig { // 1. 注册 Session 事件监听器，使 Spring Security 能及时感知会话生命周期事件 @Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .sessionManagement(session -> session .maximumSessions(1) // 同一用户仅允许 1 个会话 .maxSessionsPreventsLogin(true) // true：超出拒绝后续登录；false：踢出最早会话 .expiredUrl("/login?expired") // 会话过期后重定向地址 ); return http.build(); } }4. 测试验证方案为演示并发控制与会话固定攻击防护功能，笔者借鉴官方样例代码稍作调整进行演示。官方样例代码：https://github.com/spring-projects/spring-security-samples/tree/main/servlet/spring-boot/java/session-management/maximum-sessions❶ 确保引入 spring-security-test<dependencies> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> </dependency> </dependencies>❷ 编写配置类@Configuration @EnableWebSecurity public class SessionSecurityConfig { @Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .anyRequest().authenticated() ) .formLogin(withDefaults()) .sessionManagement(session -> session .sessionFixation(sf -> sf.migrateSession()) .maximumSessions(1) .maxSessionsPreventsLogin(true) .expiredUrl("/login?expired") ); return http.build(); } @Bean public UserDetailsService userDetailsService() { UserDetails user = User.withDefaultPasswordEncoder() .username("user") .password("password") .roles("USER") .build(); return new InMemoryUserDetailsManager(user); } }❸ 编写测试接口@RestController public class HomeController { @GetMapping("/") public String hello() { return "hello"; } }❹ 编写测试类import com.toher.springsecurity.demo.session.management.DemoSessionApplication; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @SpringBootTest(classes = DemoSessionApplication.class) @AutoConfigureMockMvc public class SessionConcurrencyTest { @Autowired private MockMvc mockMvc; @Test void testConcurrentLogin() throws Exception { // 第一次登录 MvcResult mvcResult = mockMvc.perform(formLogin()) .andExpect(authenticated()) .andReturn(); MockHttpSession firstLoginSession = (MockHttpSession) mvcResult.getRequest().getSession(); mockMvc.perform(get("/").session(firstLoginSession)) .andExpect(authenticated()); // 第二次登录将被拒绝 mockMvc.perform(formLogin()).andExpect(authenticated()); // 验证第一次会话已失效 mockMvc.perform(get("/").session(firstLoginSession)) .andExpect(unauthenticated()); } }❺ 并行运行测试观察控制台信息，可调整 .maxSessionsPreventsLogin(true) 值以测试第二次登录是拒绝还是踢出最早会话。第一次登录请求 测试请求获取数据 再次登录被拒绝，返回登录页 ❻ 会话固定攻击模拟# 获取初始会话 ID curl -I http://localhost:8080/login Set-Cookie: JSESSIONID=12345; Path=/; HttpOnly # 使用固定会话 ID 尝试认证 curl -X POST http://localhost:8080/login \ -H "Cookie: JSESSIONID=12345" \ -d "username=admin&password=123456" # 验证响应是否生成新会话 ID Set-Cookie: JSESSIONID=67890; Path=/; HttpOnly以该 ID 发起登录请求，登录后检查返回的 Set-Cookie 中 Session ID 是否已更换，验证 ID 刷新策略。5. 会话事件监听器当会话失效时，可借助会话事件监听器执行相应操作（如日志记录）：@Component public class SessionActivityListener implements ApplicationListener<SessionDestroyedEvent> { @Override public void onApplicationEvent(SessionDestroyedEvent event) { event.getSecurityContexts().forEach(context -> { AuditLog.log("会话终止", context.getAuthentication().getName(), "原因: " + event.getSessionId()); }); } }6. 结语通过本章节的讲解与实战示例，结合 sessionFixation().migrateSession() 与 maximumSessions(...) 等配置，相信大家已掌握 Spring Security 的并发会话控制与会话固定防护策略，能够为应用构建坚实的会话安全防线。
Spring Security实战教程（十二）CORS安全配置 - 跨域请求的安全详解
1. 前言在日常开发中，跨域资源共享（CORS）已成为前端与后端分离架构的必备能力。正确配置 CORS 是后端安全边界的重要一环。但安全与便利的天平往往难以把握——过于宽松的配置可能导致敏感数据泄露，过于严格的策略又会影响正常业务交互。本文我们继续《Spring Security 实战教程》，讲解 CORS 安全配置。2. 背景与原理概述跨域请求是指浏览器在一个域（Origin A）加载的脚本访问另一个域（Origin B）的资源。为保护用户数据，浏览器默认禁止跨域访问，除非服务器在响应头中明确允许。CORS 机制通过 Access-Control-Allow-* 系列头部，告诉浏览器何种跨域请求被允许，从而实现安全的跨域通信。2.1 预检请求全流程当发起“简单请求”时（如 GET/POST 且只有简单头部），浏览器会直接带上 Cookie 等凭证；对复杂请求（如 PUT/DELETE、携带自定义头部）则先发起一次预检（OPTIONS）请求，确认服务器同意后才发送实际请求。 2.2 关键响应头说明Access-Control-Allow-* 响应头主要包括以下几个：响应头安全意义示例值Access-Control-Allow-Origin允许的源列表https://your-domain.comAccess-Control-Allow-Methods允许的HTTP方法GET,POST,PUTAccess-Control-Allow-Headers允许的请求头Content-Type,AuthorizationAccess-Control-Max-Age预检结果缓存时间（秒）864003. Spring MVC 中的 CORS 支持Spring Framework 自 4.2 起在 MVC 层面引入了对 CORS 的原生支持，可通过全局配置或注解方式开启。全局配置（WebMvcConfigurer）@Configuration public class CorsGlobalConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") // 应用到所有 /api 路径 .allowedOrigins("https://your-domain.com") // 允许的源 .allowedMethods("GET", "POST", "PUT") // 允许的方法 .allowedHeaders("Content-Type", "Authorization") .exposedHeaders("X-Total-Count") .allowCredentials(true) // 允许携带 Cookie .maxAge(3600); // 预检结果缓存 1 小时 } }注解方式（@CrossOrigin）@RestController @RequestMapping("/users") @CrossOrigin( origins = "https://your-domain.com", methods = {RequestMethod.GET, RequestMethod.POST}, allowCredentials = "true", maxAge = 1800 ) public class UserController { @GetMapping public List<User> list() { … } }4. Spring Security 安全配置方案尽管 Spring MVC 已处理了 CORS，但如果启用了 Spring Security，必须在 Spring Security 配置中显式开启 CORS 支持，否则安全过滤器会在 MVC 之前拦截预检请求，导致跨域失败。全局安全配置@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors .configurationSource(corsConfigurationSource()) ) .authorizeHttpRequests(auth -> auth .anyRequest().authenticated() ); // 其他安全配置... return http.build(); } @Bean CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("https://your-domain.com")); config.setAllowedMethods(List.of("GET", "POST")); config.setAllowedHeaders(List.of("Authorization", "Content-Type")); config.setExposedHeaders(List.of("X-Custom-Header")); config.setMaxAge(3600L); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }5. 安全强化最佳实践5.1 动态源配置方案很多时候我们并不希望相关配置是写死在代码里的，可以构建 CorsConfigurationSource 来实现动态源配置方案，这里以读取环境变量举例：@Bean CorsConfigurationSource corsConfigurationSource(Environment env) { CorsConfiguration config = new CorsConfiguration(); // 从环境变量读取允许的源 String allowedOrigins = env.getProperty("cors.allowed.origins", ""); config.setAllowedOrigins(Arrays.asList(allowedOrigins.split(","))); config.applyPermitDefaultValues(); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; }5.2 安全头信息增强@Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .cors(withDefaults()) .headers(headers -> headers .httpStrictTransportSecurity(hsts -> hsts .includeSubDomains(true) .preload(true) .maxAgeInSeconds(63072000) ) .xssProtection(xss -> xss .headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK) ) .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'") ) ); return http.build(); }6. 前后端联合调试方案假设前端部署在 https://frontend.app，后端 API 接口在 https://your-domain.com：后端全局配置（见第4节 Spring Security 安全配置方案）允许该前端域名。前端请求示例：// 带凭证的跨域请求 fetch('https://your-domain.com/data', { method: 'GET', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` } }) .then(response => { console.log('Allowed Headers:', response.headers.get('Access-Control-Allow-Headers')); });先发起 OPTIONS https://your-domain.com/data，带上 Origin: https://frontend.app。后端返回：Access-Control-Allow-Origin: https://frontend.app Access-Control-Allow-Credentials: true Access-Control-Allow-Methods: GET,POST,PUT,DELETE Access-Control-Allow-Headers: Content-Type,Authorization 7. 常见安全隐患与对策7.1 风险矩阵错误配置潜在风险解决方案Access-Control-Allow-Origin: *敏感数据泄露动态白名单机制允许全部HTTP方法未授权操作风险最小权限原则暴露敏感响应头信息泄露风险严格控制 exposedHeaders缺失 Vary: Origin 头缓存投毒攻击自动添加 Vary 头7.2 生产环境检查清单禁用 allowedOrigins("*")设置合理的 maxAge 值（建议 ≤ 1 小时）严格限制 allowedHeaders 范围启用 allowCredentials 时必须指定具体源结合 CSRF 保护关键操作8. 结语CORS 配置的本质是在便利性与安全性之间寻找平衡点。通过 Spring Security 的精细化配置，我们可以：建立清晰边界：精确控制可交互的客户端范围实施最小授权：按需开放必要的 HTTP 方法和头部实现动态防御：根据环境变化自动调整安全策略构建纵深防御：与 CSRF、CSP 等安全机制形成合力# 生产环境推荐配置示例 cors.allowed.origins=https://web-client.com,https://mobile-client.com cors.max-age=3600 cors.exposed-headers=X-Request-ID通过本文内容，相信小伙伴们已经可以在 Spring Boot + Spring Security 项目中，按需灵活地设定跨域安全边界，既满足前后端分离的开发需求，又确保系统不被非授权域滥用。让您的应用将拥有更健壮的跨域防护能力。
1. 前言在前面学习的章节中，相信大家一定看过一个配置.csrf()。回忆一下之前使用Spring Security默认页登录的时候，该配置Spring Security默认开启，主要用于CSRF防护。如果你现在还不了解什么是CSRF防护，没关系，通过本章节，博主带着大家一起深入学习这个知识点～2. CSRF攻击原理跨站请求伪造（CSRF）是一种利用受信任用户的身份，诱使用户在已登录的应用中执行非预期操作的攻击手段。当用户在某个站点（如银行）登录并持有有效Session Cookie后，攻击者可通过精心构造的请求（例如隐藏在图片或表单中的POST请求）在用户不知情的情况下向该站点发起请求，并携带用户的Cookie，从而完成诸如转账、修改邮箱等敏感操作。2.1 攻击原理图解用户访问了A站点，获得了Session或Cookie后，用户不经意间访问到了恶意网站，此刻恶意网站伪造对A站点的危险请求2.2 攻击示例下面示例展示了一个最常见的CSRF攻击场景：用户登录了https://bank.com后，攻击者在自己的网站https://evil.com上放置如下HTML片段： 3. Spring Security防御机制解析3.1 同步令牌模式（Synchronizer Token Pattern）同步令牌模式是Spring Security的默认方案，服务器在渲染每个需要保护的表单页面时，向用户Session中存入一个随机生成的Token，并在表单中以隐藏字段输出；提交时，服务器验证该字段与Session中的Token是否一致，若不匹配则拒绝请求。此模式能有效防止CSRF攻击，因为攻击者无法从第三方域读取到该随机Token。核心防御流程服务端生成随机Token（每个Session唯一）Token嵌入HTML表单的隐藏字段或HTTP头客户端提交请求时必须携带有效Token服务端校验Token合法性3.2 双重提交Cookie（Double Submit Cookie）服务器在首次响应页面时，通过Set-Cookie设置一个随机Token，同时在页面中通过脚本将该Token读出并写入一个请求头（或隐藏表单字段）。服务器接收请求后，比较Cookie中的Token与请求中携带的Token是否一致。由于浏览器同源策略不能让第三方域读取Cookie，攻击者无法同步两个值。3.3 SameSite Cookie属性浏览器支持在Set-Cookie响应头中声明SameSite属性，用来限制Cookie在跨站请求时是否发送。设置为Strict或Lax模式，可从源头上阻止大部分CSRF请求。SameSite=Strict：绝不在第三方请求中发送该Cookie；SameSite=Lax：仅允许在“安全”的跨站GET导航中发送。4. 实战代码示例这里我们将针对上述三种防护机制，进行相关代码演示。4.1 在Spring Security中启用CSRF防护Spring Security默认开启CSRF保护，采用的是同步令牌模式。下面展示如何在单体、前后分离中集成。❶ Thymeleaf模板中集成@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf // 可自定义CsrfTokenRepository，例如 CookieCsrfTokenRepository.withHttpOnlyFalse() ) .authorizeHttpRequests(auth -> auth .anyRequest().authenticated() ) .formLogin(withDefaults()); return http.build(); } }在Thymeleaf页面中添加隐藏字段，设置Token： Transfer 在控制器中，Spring Security自动会在每次POST请求时校验表单中的${_csrf.token}与Session中的令牌是否匹配，不匹配则抛出InvalidCsrfTokenException。❷ 前后端分离适配方案下面演示在前后分离中的适配，前端在请求前初始化时获取CSRF Token：// 自定义CSRF令牌处理器 public class SpaCsrfTokenRequestHandler extends CsrfTokenRequestAttributeHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> csrfToken) { // 将CSRF Token暴露给前端JavaScript CsrfToken token = csrfToken.get(); if (token != null) { response.setHeader(token.getHeaderName(), token.getToken()); } } } // SecurityConfig.java @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) ) // 其他配置... return http.build(); } }前端演示代码：// 初始化时获取CSRF Token fetch('/csrf', { credentials: 'include' }) .then(res => { const token = res.headers.get('X-CSRF-TOKEN'); axios.defaults.headers.common['X-CSRF-TOKEN'] = token; }); // 所有POST请求自动携带Token axios.interceptors.request.use(config => { if (['post', 'put', 'delete'].includes(config.method.toLowerCase())) { config.headers['X-CSRF-TOKEN'] = getCSRFToken(); } return config; });❸ 自定义令牌存储策略// 使用Redis存储CSRF令牌（分布式场景） @Bean public CsrfTokenRepository redisCsrfTokenRepository(RedisTemplate<String, String> redisTemplate) { return new CsrfTokenRepository() { @Override public CsrfToken generateToken(HttpServletRequest request) { return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", UUID.randomUUID().toString()); } @Override public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) { String sessionId = request.getSession().getId(); if (token == null) { redisTemplate.delete(sessionId); } else { redisTemplate.opsForValue().set(sessionId, token.getToken(), 30, MINUTES); } } @Override public CsrfToken loadToken(HttpServletRequest request) { String sessionId = request.getSession().getId(); String token = redisTemplate.opsForValue().get(sessionId); return token != null ? new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", token) : null; } }; }4.2 双重Cookie验证实际上在我们日常开发中，使用Spring Security同步令牌方案基本能满足我们大部分需求，这里就简单演示一下双重Cookie验证：public class DoubleCookieCsrfFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { CsrfToken token = (CsrfToken) request.getAttribute(CsrfToken.class.getName()); if (requiresValidation(request)) { String headerToken = request.getHeader(token.getHeaderName()); String cookieToken = getCookieValue(request, "CSRF-TOKEN"); if (!token.getToken().equals(headerToken) || !token.getToken().equals(cookieToken)) { response.sendError(HttpStatus.FORBIDDEN.value()); return; } } filterChain.doFilter(request, response); } private boolean requiresValidation(HttpServletRequest request) { return "POST".equalsIgnoreCase(request.getMethod()) || "PUT".equalsIgnoreCase(request.getMethod()) || "DELETE".equalsIgnoreCase(request.getMethod()); } private String getCookieValue(HttpServletRequest request, String cookieName) { Cookie[] cookies = request.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if (cookieName.equals(cookie.getName())) { return cookie.getValue(); } } } return null; } }4.3 SameSite Cookie策略限制cookie的跨站请求：@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) ) .sessionManagement(session -> session .sessionCookiePolicy(cookie -> cookie.sameSite(SameSite.STRICT)) ); return http.build(); }结语CSRF攻击凭借“利用用户身份”的特点，对任何依赖Cookie的状态修改接口都构成威胁。本文从攻击原理入手，详细介绍了同步令牌、双重提交Cookie、SameSite属性等防护方案，并给出了对应代码供小伙伴们参考！
Spring Security实战教程（十）权限表达式进阶：SpEL在安全控制中的高阶魔法
title: （十）权限表达式进阶：SpEL在安全控制中的高阶魔法categories: [程技]tags: [Spring Security]date: 2026-03-021. 前言博主在持续更新Spring Security教程过程中，有一些小伙伴总会私信问到之前关于ABAC属性权限模型那一个章节中自定义策略条件表达式的问题，如下图：温馨提示：没了解ABAC属性权限模型的可以访问：最新Spring Security实战教程（六）基于数据库的ABAC属性权限模型实战开发进行了解。大家最关心的问题就是：为什么在EvaluationContext上下文中传入了对应的自定义策略条件表达式，Spring Security就能进行更细粒度控制？针对这些问题，博主觉得还是有必要再补充一下SpEL在Spring Security中的一些应用技巧。2. SpEL在Spring Security中的基本应用Spring Expression Language（SpEL）作为Spring生态的通用表达式引擎，在Spring Security中扮演着权限控制的灵魂角色。通过SpEL表达式让权限判断更灵活、更具有可配置性。我们不仅可以对用户角色进行细粒度控制，还可以在方法参数、请求信息等多维度上做出动态判断，满足复杂业务场景下的安全需求。2.1 基础权限校验在Spring Security的注解中，经常会看到@PreAuthorize、@PostAuthorize、@PreFilter、@PostFilter等注解，实际上这些注解内部都使用了SpEL来编写权限表达式：@PreAuthorize("hasAuthority('USER_CREATE')") public void createUser(User user) { /*...*/ } @PreAuthorize("hasRole('ADMIN') or hasRole('AUDITOR')") public void auditSystem() { /*...*/ }在上面的例子中，hasRole('ADMIN')就是基于SpEL的表达式。Spring Security内部会将该表达式编译并执行，根据认证对象中的角色信息决定是否允许访问。2.2 复杂对象属性校验下面举例：Project.owner（项目所有者必须是当前认证用户并且项目状态 = EDITABLE）：public record Project(Long id, String owner, String status) {} // 校验项目所有者且项目状态为可编辑 @PreAuthorize("#project.owner == authentication.name and #project.status == 'EDITABLE'") public void updateProject(Project project) { // 更新逻辑 }2.3 方法参数动态处理下面再看看方法上的动态处理：// 校验用户ID与当前认证用户匹配 @PreAuthorize("#userId == authentication.principal.id") public User getUserProfile(Long userId) { return userService.findById(userId); } // 集合参数过滤 @PostFilter("filterObject.department == authentication.principal.department") public List<Document> getAllDocuments() { return documentRepository.findAll(); }通过上述的几个例子，就可以知道Spring Security常见的几个注解就是基于SpEL的表达式。3. 常用的SpEL表达式及变量在Spring Security中，SpEL表达式可以访问以下几个常见的内置变量及方法，从而实现灵活的权限控制：❶ authentication获取当前用户的身份认证信息，包含用户名、密码、授权信息等。示例：// 判断传递用户名是否当前认证信息中的用户 @PreAuthorize("authentication.principal.username == #username") public void getUserDetails(String username) { /*...*/ }❷ principal当前经过认证的用户对象，通常是一个实现了UserDetails接口的对象。示例：// 判断当前用户是否启用 @PreAuthorize("principal.enabled") public void sensitiveOperation() { /*...*/ }❸ permitAll / denyAll快速通过/拒绝所有访问。示例：@PreAuthorize("permitAll") // 等价于 @PermitAll public String publicApi() { /*...*/ }❹ #this当前上下文中的对象，即当前方法调用对象（非静态方法）。示例：@PreAuthorize("#this.isFeatureEnabled()") public void usePremiumFeature() { /*...*/ }❺ #参数名直接访问方法参数。示例：@PreAuthorize("#user.id == principal.id") public void updateUser(User user) { /*...*/ }❻ returnObject方法返回值（仅限@PostAuthorize和@PostFilter）。示例：// 返回Document对象中owner = 当前用户名 才允许访问该接口 @PostAuthorize("returnObject.owner == principal.username") public Document getConfidentialDoc() { /*...*/ }❼ target / targetObjectAOP代理的原始目标对象（目标方法所属的对象）。如：当前有一个adminService对象，adminService对象下有adminOperation方法，target = adminService。示例：@PreAuthorize("target.isFeatureEnabled('ADVANCED_MODE')") public void adminOperation() { /*...*/ }❽ @beanName访问Spring容器中的Bean，如Spring容器中有一个RiskEvaluator的bean，可以通过下面示例调用。示例：@PreAuthorize("@riskEvaluator.isLowRisk(#request)") public void processRequest(Request request) { /*...*/ }常用表达式速查表表达式说明hasRole('ROLE')是否拥有指定角色hasAnyRole('ROLE1','ROLE2')是否拥有任一指定角色hasAuthority('AUTH')是否拥有指定权限hasAnyAuthority('AUTH1','AUTH2')是否拥有任一指定权限isAuthenticated()是否已认证isAnonymous()是否为匿名用户isRememberMe()是否通过RememberMe认证fullyAuthenticated()是否为完整认证（非RememberMe）permitAll()始终允许denyAll()始终拒绝#paramName访问方法参数@beanName.method()调用Spring Bean的方法4. 自定义表达式实战有时内置的SpEL表达式无法满足业务需要，我们可以扩展Spring Security，增加自定义的安全表达式。例如，我们可以定义一个检查用户是否在某一部门的表达式。4.1 注册自定义权限校验器回顾一下我们之前第6章节中ABAC属性权限模型，自定义注解authz，通过调用authz注解以实现我们自己所需业务：@Component("authz") @RequiredArgsConstructor public class AuthorizationLogic { private final SpelExpressionParser parser = new SpelExpressionParser(); private final SysPolicyMapper sysPolicyMapper; public boolean check(MethodSecurityExpressionOperations operations, String permission) { SysUser userDetails = (SysUser) operations.getAuthentication().getPrincipal(); // 加载策略集 LambdaQueryWrapper<SysPolicy> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(SysPolicy::getTargetResource, permission); List<SysPolicy> policies = sysPolicyMapper.selectList(queryWrapper); if (policies.isEmpty()) { return false; } // 构建评估上下文 EvaluationContext context = new StandardEvaluationContext(); // 将用户传入表达式上下文 如：#user.attrs['department'] == 'IT' // 其中user前缀就是我们传入的user context.setVariable("user", userDetails); return policies.stream().allMatch(policy -> parser.parseExpression(policy.getConditionExpression()).getValue(context, Boolean.class) ); } }调用示例：@PreAuthorize("hasRole('ADMIN') and @authz.check(authentication, 'admin:menu')") @GetMapping("/admin/test") public ResponseEntity<?> test() { return ResponseEntity.ok("RBAC角色 + ABAC属性的混合校验"); }代码解释：通过调用@authz传递当前用户认证信息 + permission = "admin:menu"，查询出对应表达式（见文章开头的数据库截图），判断用户是否满足表达式条件。4.2 自定义MethodSecurityExpressionHandler在之前最新Spring Security实战教程（六）基于数据库的ABAC属性权限模型实战开发的章节中已经演示过，这里不再赘述，感兴趣的小伙伴可以回顾之前的章节代码：// 调用示例 @PreAuthorize("hasRole('ADMIN') and @abacDecisionEngine.check(authentication, 'admin:menu')") @GetMapping("/admin/test") public ResponseEntity<?> test() { return ResponseEntity.ok("RBAC角色 + ABAC属性的混合校验"); }5. 高阶表达式技巧下面演示几个高阶表达式技巧，小伙伴可以根据自己业务需求来调整使用。5.1 多条件组合表达式// 复杂逻辑组合 @PreAuthorize("(hasRole('ADMIN') and @env.isProduction()) or #forceOverride") public void systemReboot(boolean forceOverride) { // 系统重启操作 }5.2 时间维度控制// 限制工作时间访问 @PreAuthorize("@timeValidator.isWorkHour()") public void accessFinancialSystem() { // 财务系统操作 } // 定时权限校验 public class TimeValidator { public boolean isWorkHour() { LocalTime now = LocalTime.now(); return now.isAfter(LocalTime.of(9, 0)) && now.isBefore(LocalTime.of(18, 0)); } }5.3 安全审计集成AuditLogger是我们Spring容器中的bean，调用logExportAttempt传递相关参数判断是否有相应权限：// 权限校验与审计联动 @PreAuthorize("hasAuthority('DATA_EXPORT') and @auditLogger.logExportAttempt(#request)") public void exportData(ExportRequest request) { // 数据导出逻辑 }5.4 参数过滤与返回结果过滤Spring Security支持@PreFilter与@PostFilter注解，对集合类型的参数或返回值进行过滤。这样可以确保传入的集合中只包含当前用户可以操作的文档，返回的集合中也仅包含公开或本人拥有的文档：@PreFilter("filterObject.owner == authentication.principal.username") @PostFilter("filterObject.visibility == 'PUBLIC' or filterObject.owner == authentication.principal.username") @GetMapping("/documents") public List<Document> getDocuments(@RequestBody List<Document> docs) { return docs; }5.5 动态资源权限控制结合Spring Bean实现动态资源权限判断：@Component("resourceChecker") public class ResourceChecker { public boolean canAccess(Long resourceId, String operation) { // 从数据库查询资源权限配置 // 根据资源ID、操作类型、当前用户等动态判断 return true; // 示例返回 } } // 使用示例 @PreAuthorize("@resourceChecker.canAccess(#resourceId, 'EDIT')") public void editResource(Long resourceId) { // 编辑资源逻辑 }6. SpEL表达式执行原理简析理解SpEL的执行原理有助于我们更好地运用它。SpEL表达式的执行主要包含以下几个步骤：解析（Parsing）：将表达式字符串解析为抽象语法树（AST）。编译（Compilation）：可选步骤，将表达式编译为字节码以提高性能。评估（Evaluation）：在给定的上下文中执行表达式，返回结果。在Spring Security中，MethodSecurityExpressionHandler负责创建和管理表达式评估上下文，并将当前认证信息、方法参数等注入到上下文中，供表达式使用。7. 性能优化建议虽然SpEL非常强大，但过度使用或不当使用可能影响系统性能。以下是一些优化建议：问题建议表达式重复解析使用表达式缓存，避免重复解析相同表达式复杂对象图导航避免过深的对象属性访问链频繁调用的方法将复杂逻辑移到自定义Bean中，表达式只做简单调用集合过滤大数据量集合谨慎使用@PreFilter/@PostFilter8. 结语SpEL在Spring Security中的应用使得权限控制不仅仅局限于静态的角色和权限匹配，而可以根据请求参数、用户属性、业务逻辑等灵活判断访问权限。通过灵活组合内置功能与自定义扩展，开发者可以实现从简单角色校验到复杂业务规则的全场景覆盖。希望这个章节的内容能够帮助小伙伴们更深入地理解Spring Security权限表达式的底层原理与应用技巧，在实际项目中设计出更灵活高效的安全策略。如果你在实践过程中有任何疑问或更好的扩展思路，欢迎在评论区留言，并三连给博主一点点鼓励！谢谢～
Spring Security实战教程（九）前后端分离认证实战 - JWT+SpringSecurity无缝整合
title: （九）前后端分离认证实战 - JWT+SpringSecurity无缝整合categories: [程技]tags: [spring security]date: 2026-03-021. 前言又是新的一周，博主继续来给大家更新 Spring Security 实战教程系列啦～ 通过前面的章节教程从认证到授权，相信大家已经基本了解 Spring Security 的工作原理。但在前后端分离架构成为主流的今天，传统的 Session-Cookie 认证模式面临跨域限制、服务端状态维护等难题。JWT（JSON Web Token）作为无状态令牌方案，凭借其自包含、易扩展的特性，成为现代分布式系统的首选认证方案。那么本章节，博主就带着大家一起来进行 Spring Security 前后端分离认证实战，手把手教构建安全的 JWT 认证体系！2. JWT 基本原理JWT（JSON Web Token） 是一种开放标准（RFC 7519），用于在各方之间以 JSON 对象安全地传输信息。其主要特点包括：无状态：服务端无需保存会话信息，降低了服务端压力（传统 Session 是保存服务端）跨域支持：适用于前后端分离应用场景JWT 通常由三部分组成：Header、Payload 和 Signature。在认证场景中，用户登录后服务器生成一个包含用户信息的 Token，前端将该 Token 存储在本地，并在后续请求中携带到 HTTP Header 中。服务端通过解析和验证 Token，完成用户身份认证。3. Spring Security 与 JWT 整合思路整合 JWT 与 Spring Security 的关键在于：无状态配置：关闭 Spring Security 默认的 Session 管理，采用无状态认证自定义认证入口：提供一个登录接口，验证用户凭据，生成 JWTJWT 拦截过滤器：在请求到达业务逻辑前，拦截 HTTP 请求，解析和验证 JWT，将用户认证信息写入 SecurityContext完整流程图如下：4. 实战开始本次演示我们先简单模拟集成，还是在之前的 Maven 项目中新建子模块，命名：jwt-spring-security Maven 配置文件追加 JWT 库：<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.12.6</version> </dependency>YML 配置文件中添加密钥以及 Token 有效期：jwt: secret: "dGhpcyBpcyBhIHNlY3JldCBrZXkgZm9yIG15IGFwcA==" # Base64编码密钥 expiration: 900000 # 15分钟4.1 配置 JWT 工具类如果你的系统使用了 Hutool 工具包，可以直接调用 JWTUtil 来创建以及验证 JWT，具体参考 https://doc.hutool.cn/pages/JWTUtil/这里我们使用的是 io.jsonwebtoken 来自定义 JWT：@Component public class JwtUtils { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private long expiration; // 生成令牌时设置 subject public String generateToken(String username) { return Jwts.builder() .subject(username) // 关键：设置用户名到 subject .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + expiration)) .signWith(getSigningKey()) .compact(); } // 用户名提取方法 public String extractUsername(String token) { return parseClaims(token).getSubject(); } // 统一的令牌验证方法 public boolean validateToken(String token) { parseClaims(token); // 复用解析逻辑 return true; } // 校验 Token 是否过期 public boolean isTokenExpired(String token) { Claims claims = parseClaims(token); return claims.getExpiration().before(new Date()); } // 私有方法：统一解析令牌声明 private Claims parseClaims(String token) { return Jwts.parser() .verifyWith(getSigningKey()) .build() .parseSignedClaims(token) .getPayload(); } // 密钥生成方法 private SecretKey getSigningKey() { byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8); return Keys.hmacShaKeyFor(keyBytes); } }4.2 JwtAuthFilter：JWT 过滤器在前面章节我们讲解 Spring Security 底层原理的时候，我们知道 Spring Security 默认 DefaultSecurityFilterChain 启动的时候，会通过多个 Filter 来逐层检查，实际上同理只需要我们自定义 JWT 过滤器来实现我们所需业务即可：@Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 从请求头获取 Token，约定使用 "Authorization" 且前缀为 "Bearer " String token = parseToken(request); if (token != null && jwtUtils.validateToken(token)) { String username = jwtUtils.extractUsername(token); // 如果 token 存在且 SecurityContext 为空，设置用户认证 if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { // 这里只是示例，实际应用中应加载用户详情信息 UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 将认证信息放入上下文中 SecurityContextHolder.getContext().setAuthentication(authToken); } } filterChain.doFilter(request, response); } private String parseToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { return header.substring(7); } return null; } }4.3 安全配置类Spring Security 配置类主要关闭 session 管理，并追加自定义 JWT Filter，放行 /api/auth/login 接口地址，其余均需要验证 JWT：@Configuration // 开启方法级的安全控制 @EnableMethodSecurity @RequiredArgsConstructor public class JwtSecurityConfig { private final JwtAuthFilter jwtAuthFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 禁用 CSRF，因为使用 JWT 方式无需 Session .csrf(csrf -> csrf.disable()) // 设置无状态 Session 管理 .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login").permitAll() .anyRequest().authenticated()) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }4.4 配置测试 Controller编写一个登陆接口 /api/auth/login 以及一个验证接口 /api/auth/verify：@RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { private final JwtUtils jwtUtils; // 简单示例，真实场景中应从数据库加载用户信息并校验密码 @PostMapping("/login") public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest) { // 这里假设用户名为 "admin"，密码为 "admin" if ("admin".equals(loginRequest.getUsername()) && "admin".equals(loginRequest.getPassword())) { String token = jwtUtils.generateToken(loginRequest.getUsername()); return ResponseEntity.ok(token); } return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("用户名或密码错误"); } @GetMapping("/verify") public ResponseEntity<?> verify() { return ResponseEntity.ok("验证用户访问成功"); } }4.5 运行测试这里博主使用的 Apifox 测试效果，首先登陆获得 token： 在接下来的验证接口，配置 Header 设置 Authorization 的 Key，并将登陆获得的 token 设置值：格式：Bearer + 登陆获得的 Token至此我们就完成了最简单的 JWT + Spring Security 整合。5. 基于 RBAC 角色模型的升级回忆一下我们之前第五章节中，RBAC 角色模型的表设计：需要通过用户 ID 查询到用户分配的角色 + 角色所配置的菜单资源： 这里我们就基于 最新 Spring Security 实战教程（五）基于数据库的动态用户认证传统 RBAC 角色模型实战开发 复用代码，集合 JWT 动态从数据库获取用户信息认证、授权。5.1 复用第五章节 RBAC 角色模型代码相关 RBAC 角色模型的知识，请小伙伴自行查阅第五章节内容，篇幅有限这里就简单贴出代码即可。实体类// SysMenu 实体类 @Data @TableName("sys_menu") public class SysMenu { @TableId(type = IdType.AUTO) private Long menuId; private String menuName; private String perms; } // SysRole 实体类 @Data @TableName("sys_role") public class SysRole { @TableId(type = IdType.AUTO) private Long roleId; private String roleName; private String roleKey; @TableField(exist = false) private List<SysMenu> menus; } // SysUser 实体类 // 博主为了方便，直接使用数据库映射的 SysUser 对象直接实现 UserDetails，大家在开发过程中建议单独构建实现对象！ @Data @TableName("sys_user") public class SysUser implements UserDetails { @TableId(type = IdType.AUTO) private Long userId; @TableField("login_name") private String username; // Spring Security 认证使用的字段 private String password; private String status; // 状态（0 正常 1 锁定） private String delFlag; // 删除标志（0 代表存在 1 代表删除） @TableField(exist = false) private List<SysRole> roles; // 实现 UserDetails 接口 @Override public Collection<? extends GrantedAuthority> getAuthorities() { // 组装 GrantedAuthority 集合，将角色和菜单权限都加入 Set<GrantedAuthority> authorities = new HashSet<>(); authorities.addAll(roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRoleKey())) .collect(Collectors.toList())); authorities.addAll(roles.stream() .flatMap(role -> role.getMenus().stream()) .map(menu -> new SimpleGrantedAuthority(menu.getPerms())) .collect(Collectors.toList())); return authorities; } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return "0".equals(status); } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return "0".equals(delFlag); } }Mapper 配置// MenuMapper 配置 @Mapper public interface MenuMapper extends BaseMapper<SysMenu> { @Select("SELECT DISTINCT m.* FROM sys_menu m " + "JOIN sys_role_menu rm ON m.menu_id = rm.menu_id " + "JOIN sys_user_role ur ON rm.role_id = ur.role_id " + "WHERE ur.user_id = #{userId}") List<SysMenu> selectByUserId(Long userId); } // RoleMapper 配置 @Mapper public interface RoleMapper extends BaseMapper<SysRole> { @Select("SELECT m.* FROM sys_menu m " + "JOIN sys_role_menu rm ON m.menu_id = rm.menu_id " + "WHERE rm.role_id = #{roleId}") List<SysMenu> selectMenusByRoleId(Long roleId); } // UserMapper 配置 @Mapper public interface UserMapper extends BaseMapper<SysUser> { @Select("SELECT r.* FROM sys_role r " + "JOIN sys_user_role ur ON r.role_id = ur.role_id " + "WHERE ur.user_id = #{userId}") @Results({ @Result(property = "roleId", column = "role_id"), @Result(property = "menus", column = "role_id", many = @Many(select = "com.toher.springsecurity.demo.jwt.mapper.MenuMapper.selectByUserId")) }) List<SysRole> selectRolesByUserId(Long userId); }5.2 自定义 UserDetailsService 实现自定义 UserDetailsService 继承 UserDetailsService，重写 loadUserByUsername 方法，注入 UserMapper 以及 RoleMapper 通过用户名查询数据库数据，同时将用户的角色、菜单资源集合一并赋值：@Service @RequiredArgsConstructor public class UserDetailsServiceImpl implements UserDetailsService { private final UserMapper userMapper; private final RoleMapper roleMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 查询基础用户信息 LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(SysUser::getUsername, username); SysUser user = userMapper.selectOne(wrapper); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } // 2. 加载角色和权限 List<SysRole> roles = userMapper.selectRolesByUserId(user.getUserId()); roles.forEach(role -> role.setMenus(roleMapper.selectMenusByRoleId(role.getRoleId())) ); user.setRoles(roles); // 3. 检查账户状态 if (!user.isEnabled()) { throw new DisabledException("用户已被禁用"); } return user; } }5.3 改造 JwtAuthFilter这里我们需要调用 自定义 UserDetailsService 的 loadUserByUsername 方法从数据库获取用户信息，其中 user.getAuthorities() 包含了用户角色、资源相关权限信息（具体查阅 SysUser 对象）：@Component @RequiredArgsConstructor public class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtils jwtUtils; private final UserDetailsServiceImpl userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 从请求头获取 Token，约定使用 "Authorization" 且前缀为 "Bearer " String token = parseToken(request); if (token != null && jwtUtils.validateToken(token)) { String username = jwtUtils.extractUsername(token); UserDetails user = userDetailsService.loadUserByUsername(username); // 创建认证信息 UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(user, token, user.getAuthorities()); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 将认证信息放入上下文中 SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authToken); SecurityContextHolder.setContext(context); } filterChain.doFilter(request, response); } private String parseToken(HttpServletRequest request) { String header = request.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { return header.substring(7); } return null; } }5.4 创建 AuthService 处理用户登陆我们专门创建一个 AuthService 用以处理用户的登陆，用户查询存在则返回 JWT token：@Service @RequiredArgsConstructor public class AuthService { private final AuthenticationManager authenticationManager; private final JwtUtils jwtUtils; public AuthResponse authenticate(LoginRequest request) { // authenticationManager 会调用 userDetailsService.loadUserByUsername 方法 Authentication authentication = authenticationManager .authenticate(new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); SysUser user = (SysUser) authentication.getPrincipal(); String token = jwtUtils.generateToken(user.getUsername()); return new AuthResponse(token, user.getUsername(), user.getAuthorities()); } }另外还有两个 DTO LoginRequest：用来接收登陆用户名、密码 AuthResponse：用于返回 token、用户的权限信息@Data public class LoginRequest { private String username; private String password; } @Data @AllArgsConstructor public class AuthResponse { private String token; private String username; private Collection<?> authorities; }5.5 调整 JwtSecurityConfig 配置@Configuration // 开启方法级的安全控制 @EnableMethodSecurity @RequiredArgsConstructor public class JwtSecurityConfig { private final JwtAuthFilter jwtAuthFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 禁用 CSRF，因为使用 JWT 方式无需 Session .csrf(csrf -> csrf.disable()) // 设置无状态 Session 管理 .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login").permitAll() .requestMatchers("/api/auth/loginByMysql").permitAll() .anyRequest().authenticated()) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) .exceptionHandling(ex -> ex .authenticationEntryPoint(jwtAuthenticationEntryPoint()) ); return http.build(); } // 统一认证凭证处理 @Bean public AuthenticationEntryPoint jwtAuthenticationEntryPoint() { return (request, response, authException) -> { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\": 401,\"msg\": \"无效的认证凭证\"}"); }; } // 自定义认证中使用 AuthenticationManager @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } // 定义密码加密方式 @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }5.6 定义接口统一返回 Result + 全局异常处理之前简单的整合过程中，小伙伴或许发现了接口返回的非 JSON 数据，且 token 过期等没有正确的错误提示，这里我们继续完善一下：@Data @NoArgsConstructor @AllArgsConstructor @Accessors(chain = true) public class AjaxResult<T> { private static final long serialVersionUID = 1L; /** * 状态码 */ private int code; /** * 返回内容 */ private String msg; /** * 数据对象 */ private T data; /** * 初始化一个新创建的 AjaxResult 对象 * * @param code 状态码 * @param msg 返回内容 */ public AjaxResult(int code, String msg) { this.code = code; this.msg = msg; } /** * 返回成功消息 * * @return 成功消息 */ public static AjaxResult<Void> success() { return AjaxResult.success("操作成功"); } /** * 返回成功数据 * * @return 成功消息 */ public static <T> AjaxResult<T> success(T data) { return AjaxResult.success("操作成功", data); } /** * 返回成功消息 * * @param msg 返回内容 * @return 成功消息 */ public static AjaxResult<Void> success(String msg) { return AjaxResult.success(msg, null); } /** * 返回成功消息 * * @param msg 返回内容 * @param data 数据对象 * @return 成功消息 */ public static <T> AjaxResult<T> success(String msg, T data) { return new AjaxResult<>(200, msg, data); } /** * 返回错误消息 * * @return */ public static AjaxResult<Void> error() { return AjaxResult.error("操作失败"); } /** * 返回错误消息 * * @param msg 返回内容 * @return 警告消息 */ public static AjaxResult<Void> error(String msg) { return AjaxResult.error(msg, null); } /** * 返回错误消息 * * @param msg 返回内容 * @param data 数据对象 * @return 警告消息 */ public static <T> AjaxResult<T> error(String msg, T data) { return new AjaxResult<>(500, msg, data); } /** * 返回错误消息 * * @param code 状态码 * @param msg 返回内容 * @return 警告消息 */ public static AjaxResult<Void> error(int code, String msg) { return new AjaxResult<>(code, msg, null); } }定义全局异常处理类@RestControllerAdvice public class GlobalExceptionHandler { /** * 全局异常 */ @ExceptionHandler(Exception.class) public AjaxResult handleException(Exception e) { return AjaxResult.error(e.getMessage()); } }5.7 Controller 中登陆、测试方法 // 通过数据库用户数据登陆 @PostMapping("/loginByMysql") public AjaxResult<AuthResponse> loginByMysql(@RequestBody LoginRequest request) { return AjaxResult.success(authService.authenticate(request)); } // 验证权限 @PreAuthorize("hasAuthority('admin:menu:add')") @GetMapping("/add") public AjaxResult<Void> add() { return AjaxResult.success("方法的授权 admin:menu:add，访问 ok"); }5.8 运行测试访问 /loginByMysql 接口查看返回的用户数据，前端可以根据登陆接口返回决定资源权限的配置：最后切换用户测试 /add 仅配置了 admin:menu:add 允许访问：最后修改 token，或者当 token 过期，继续请求查看是否被 AuthenticationEntryPoint 统一处理：6. 结语至此本章节内容也就结束了，我们通过 Spring Security 与 JWT 实现无状态认证。从 JWT 的基本原理出发，逐步构建了登录接口、JWT 生成工具、拦截过滤器及安全配置的初步整合方案，最后再结合之前章节 RBAC 角色模型升级，完整实现了动态数据库用户的认证授权等。通过完整的代码样例，相信小伙伴们都可以快速搭建出一个高效、灵活的认证系统。