跳转到内容
123xiao | 无名键客

《Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离认证鉴权实战》

字数: 0 阅读时长: 1 分钟

Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离认证鉴权实战

前后端分离项目里,认证和鉴权几乎是绕不过去的一环。很多人第一次接触 Spring Security 6 时,最容易卡住的不是“怎么写登录接口”,而是“为什么我明明放行了接口,还是 403”“为什么 JWT 过滤器不生效”“为什么升级到 Spring Boot 3 以后以前的配置全不能用了”。

这篇文章我会从一个中级开发者真正会遇到的问题出发,带你做一套能跑起来的方案:

  • 用户登录后签发 JWT
  • 前端携带 Token 访问受保护接口
  • Spring Security 6 基于 Token 恢复登录态
  • 按角色控制接口访问
  • 顺手把常见坑和生产实践讲清楚

整篇以 Spring Boot 3 + Spring Security 6 + jjwt 为基础,代码是可运行的,适合作为你项目的起点。


背景与问题

在传统服务端渲染应用里,认证通常依赖 Session。浏览器登录后,服务端把用户状态保存在 Session 中,后续请求靠 Cookie 带回 Session ID。

但到了前后端分离场景,问题就变了:

  1. 前端可能是 Vue、React、小程序、App,不一定天然依赖 Cookie Session
  2. 服务端经常要做无状态部署,扩容时不想同步 Session
  3. 网关、微服务、移动端统一接入时,更适合用可自描述的 Token

这时候,JWT(JSON Web Token)就成了常见选择。

不过,JWT 不是“加上一个依赖就完事”的魔法。实际落地时常见问题很多:

  • 登录成功了,但后续请求拿不到用户信息
  • 明明前端传了 Authorization 头,后端还是匿名用户
  • hasRole("ADMIN") 一直不生效
  • Token 过期后返回 500 而不是 401
  • WebSecurityConfigurerAdapter 找不到,老教程根本抄不动

这些问题的根源,基本都在于:没有真正理解 Spring Security 6 的认证链路


前置知识与环境准备

运行环境

  • JDK 17+
  • Maven 3.8+
  • Spring Boot 3.x
  • Spring Security 6.x

本文目标接口

我们要完成这些接口:

  • POST /api/auth/login:用户名密码登录,返回 JWT
  • GET /api/public/hello:公开接口,无需登录
  • GET /api/user/profile:需要登录
  • GET /api/admin/dashboard:需要 ADMIN 角色

Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.12.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.12.5</version>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

核心原理

先别急着写代码,先把链路想明白。

1. JWT 在前后端分离中的角色

JWT 本质上是一个字符串,通常由三部分组成:

  • Header:声明算法、类型
  • Payload:载荷,比如用户名、角色、过期时间
  • Signature:签名,用于防篡改

服务端登录成功后签发 JWT,前端后续请求带上:

Authorization: Bearer xxxxx.yyyyy.zzzzz

服务端在过滤器里解析 JWT,如果合法,就把用户信息放进 Spring Security 的上下文中。后续控制器、权限注解、鉴权逻辑都依赖这个上下文。

2. Spring Security 6 的关键变化

以前很多文章写的是继承 WebSecurityConfigurerAdapter
Spring Security 6 里已经推荐改成 声明式 Bean 配置

  • SecurityFilterChain
  • AuthenticationManager
  • PasswordEncoder
  • 自定义 OncePerRequestFilter

这一点非常关键。很多升级失败,根本原因就是还在套旧写法。

3. 认证和鉴权不是一回事

这两个概念很容易混:

  • 认证 Authentication:你是谁
    比如用户名密码登录成功、JWT 被成功解析
  • 鉴权 Authorization:你能访问什么
    比如只有 ADMIN 才能访问后台管理接口

你可以把它理解成:

认证解决“进门资格”,鉴权解决“进门后能去哪些房间”。

4. 整体请求流程

flowchart TD
    A[前端提交用户名密码] --> B[登录接口校验账号密码]
    B --> C[服务端生成JWT]
    C --> D[前端保存JWT]
    D --> E[请求受保护接口时携带Authorization Bearer Token]
    E --> F[JWT过滤器解析Token]
    F --> G[构建Authentication并放入SecurityContext]
    G --> H[Spring Security进行权限判断]
    H --> I[返回业务数据或401/403]

5. 认证时序图

sequenceDiagram
    participant Client as 前端
    participant Auth as /api/auth/login
    participant Security as Spring Security
    participant JWT as JwtService

    Client->>Auth: 提交用户名/密码
    Auth->>Security: AuthenticationManager.authenticate(...)
    Security-->>Auth: 认证成功
    Auth->>JWT: 生成JWT
    JWT-->>Auth: token
    Auth-->>Client: 返回token

    Client->>Security: 带Bearer Token访问受保护接口
    Security->>JWT: 解析并校验token
    JWT-->>Security: 用户名/角色
    Security-->>Client: 放行并返回资源

项目结构建议

为了让代码更清晰,我建议按下面的结构组织:

src/main/java/com/example/jwtsecurity
├── JwtSecurityApplication.java
├── config
│   └── SecurityConfig.java
├── controller
│   ├── AuthController.java
│   ├── PublicController.java
│   ├── UserController.java
│   └── AdminController.java
├── dto
│   ├── LoginRequest.java
│   └── LoginResponse.java
├── security
│   ├── JwtAuthenticationFilter.java
│   ├── JwtService.java
│   └── CustomUserDetailsService.java
└── service
    └── DemoUserService.java

实战代码(可运行)

下面是一套精简但完整的实现。

1. 启动类

package com.example.jwtsecurity;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JwtSecurityApplication {
    public static void main(String[] args) {
        SpringApplication.run(JwtSecurityApplication.class, args);
    }
}

2. DTO

LoginRequest

package com.example.jwtsecurity.dto;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class LoginRequest {

    @NotBlank
    private String username;

    @NotBlank
    private String password;
}

LoginResponse

package com.example.jwtsecurity.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class LoginResponse {
    private String token;
}

3. JWT 服务

这里负责生成和解析 Token。

package com.example.jwtsecurity.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;
import java.util.List;

@Service
public class JwtService {

    // 这是一个 Base64 编码后的 256-bit 密钥,示例用,生产环境请从配置中心或环境变量读取
    private static final String SECRET_KEY = "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=";

    private SecretKey getSignKey() {
        byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateToken(UserDetails userDetails) {
        Instant now = Instant.now();
        List<String> roles = userDetails.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        return Jwts.builder()
                .subject(userDetails.getUsername())
                .claim("roles", roles)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusSeconds(60 * 60 * 2)))
                .signWith(getSignKey())
                .compact();
    }

    public String extractUsername(String token) {
        return parseClaims(token).getSubject();
    }

    public boolean isTokenValid(String token, UserDetails userDetails) {
        String username = extractUsername(token);
        return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
    }

    public boolean isTokenExpired(String token) {
        Date expiration = parseClaims(token).getExpiration();
        return expiration.before(new Date());
    }

    private Claims parseClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSignKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }
}

4. 模拟用户服务

为了方便演示,不接数据库,先用内存用户。
真实项目里你应该从数据库加载用户、角色、状态等信息。

package com.example.jwtsecurity.service;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.Set;

@Service
public class DemoUserService {

    private final PasswordEncoder passwordEncoder;

    public DemoUserService(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }

    public DemoUser findByUsername(String username) {
        Map<String, DemoUser> users = Map.of(
                "user", new DemoUser(
                        1L,
                        "user",
                        passwordEncoder.encode("123456"),
                        Set.of("ROLE_USER")
                ),
                "admin", new DemoUser(
                        2L,
                        "admin",
                        passwordEncoder.encode("123456"),
                        Set.of("ROLE_ADMIN", "ROLE_USER")
                )
        );

        return users.get(username);
    }

    public record DemoUser(Long id, String username, String password, Set<String> roles) {}
}

这里我特意保留了 ROLE_ 前缀,因为 Spring Security 的 hasRole("ADMIN") 底层会匹配 ROLE_ADMIN。这个坑真的太常见了。

5. 自定义 UserDetailsService

package com.example.jwtsecurity.security;

import com.example.jwtsecurity.service.DemoUserService;
import com.example.jwtsecurity.service.DemoUserService.DemoUser;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

import java.util.stream.Collectors;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final DemoUserService demoUserService;

    public CustomUserDetailsService(DemoUserService demoUserService) {
        this.demoUserService = demoUserService;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        DemoUser user = demoUserService.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        return User.builder()
                .username(user.username())
                .password(user.password())
                .authorities(user.roles().stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet()))
                .build();
    }
}

6. JWT 过滤器

这是整套方案的核心之一。它会在每次请求时检查 Header 中的 Token。

package com.example.jwtsecurity.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final CustomUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtService jwtService, CustomUserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

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

        String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);
        String username;

        try {
            username = jwtService.extractUsername(token);
        } catch (Exception e) {
            filterChain.doFilter(request, response);
            return;
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            if (jwtService.isTokenValid(token, userDetails)) {
                UsernamePasswordAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );

                authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

7. Security 配置

Spring Security 6 推荐用 Bean 方式配置。

package com.example.jwtsecurity.config;

import com.example.jwtsecurity.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .httpBasic(Customizer.withDefaults())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/login", "/api/public/**").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

注意:这里开启了 STATELESS,表示不依赖 Session 保存认证状态,完全符合 JWT 场景。

8. 登录接口

package com.example.jwtsecurity.controller;

import com.example.jwtsecurity.dto.LoginRequest;
import com.example.jwtsecurity.dto.LoginResponse;
import com.example.jwtsecurity.security.JwtService;
import jakarta.validation.Valid;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;

    public AuthController(AuthenticationManager authenticationManager, JwtService jwtService) {
        this.authenticationManager = authenticationManager;
        this.jwtService = jwtService;
    }

    @PostMapping("/login")
    public LoginResponse login(@Valid @RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(),
                        request.getPassword()
                )
        );

        String token = jwtService.generateToken((org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal());
        return new LoginResponse(token);
    }
}

9. 测试接口

公开接口

package com.example.jwtsecurity.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PublicController {

    @GetMapping("/api/public/hello")
    public String hello() {
        return "public hello";
    }
}

用户接口

package com.example.jwtsecurity.controller;

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class UserController {

    @GetMapping("/api/user/profile")
    public String profile(Authentication authentication) {
        return "当前登录用户: " + authentication.getName();
    }
}

管理员接口

package com.example.jwtsecurity.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class AdminController {

    @GetMapping("/api/admin/dashboard")
    public String dashboard() {
        return "admin dashboard";
    }
}

逐步验证清单

建议你边写边测,不要等全写完一起跑。认证链路出错时,分段验证最省时间。

第一步:访问公开接口

curl http://localhost:8080/api/public/hello

预期结果:

public hello

第二步:直接访问受保护接口

curl http://localhost:8080/api/user/profile

预期结果:
返回 401 Unauthorized

第三步:登录获取 Token

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"user","password":"123456"}'

预期结果:

{"token":"eyJhbGciOiJIUzI1NiJ9..."}

第四步:携带 Token 访问用户接口

curl http://localhost:8080/api/user/profile \
  -H "Authorization: Bearer 这里替换成你的token"

预期结果:

当前登录用户: user

第五步:普通用户访问管理员接口

curl http://localhost:8080/api/admin/dashboard \
  -H "Authorization: Bearer 这里替换成user的token"

预期结果:
返回 403 Forbidden

第六步:管理员登录并访问管理员接口

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"123456"}'

然后:

curl http://localhost:8080/api/admin/dashboard \
  -H "Authorization: Bearer 这里替换成admin的token"

预期结果:

admin dashboard

权限模型再看一眼

如果你对“角色”和“过滤器”之间的关系还不够直观,可以看下面这张图:

classDiagram
    class JwtAuthenticationFilter {
        +doFilterInternal()
    }

    class JwtService {
        +generateToken(UserDetails)
        +extractUsername(String)
        +isTokenValid(String, UserDetails)
    }

    class SecurityContextHolder {
        +getContext()
        +setAuthentication()
    }

    class AuthenticationManager {
        +authenticate(Authentication)
    }

    class UserDetailsService {
        <<interface>>
        +loadUserByUsername(String)
    }

    JwtAuthenticationFilter --> JwtService
    JwtAuthenticationFilter --> UserDetailsService
    JwtAuthenticationFilter --> SecurityContextHolder
    AuthController --> AuthenticationManager

常见坑与排查

这一节我会集中讲一些特别高频的问题。很多时候不是代码不会写,而是定位思路不对。

1. 已经放行了登录接口,还是 403

常见原因

  • CSRF 没关,而你用的是前后端分离 JSON 请求
  • 请求路径写错,比如实际接口是 /api/auth/login,放行写成了 /auth/login
  • 前端发的是 OPTIONS 预检请求,但你没处理跨域

排查建议

先看配置里是否有:

.requestMatchers("/api/auth/login", "/api/public/**").permitAll()

再确认:

.csrf(csrf -> csrf.disable())

如果涉及跨域,还要补 cors() 和对应的 CorsConfigurationSource


2. 带了 Token,还是拿不到登录用户

常见原因

  • Header 不是 Authorization
  • 前缀不是 Bearer
  • 过滤器没加入链路
  • Token 解析异常被你悄悄吞掉了
  • SecurityContextHolder 没有成功设置认证对象

排查建议

重点看这一行有没有:

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

然后在过滤器里临时打印:

System.out.println("Authorization=" + request.getHeader("Authorization"));

以及打印 usernameSecurityContextHolder.getContext().getAuthentication()

我自己排过几次这种问题,最后发现有时不是后端问题,而是前端把 Token 存了,但请求拦截器没带上。


3. hasRole("ADMIN") 不生效

原因

你给的权限是 ADMIN,但 Spring Security 的 hasRole("ADMIN") 会自动补前缀,实际比的是 ROLE_ADMIN

正确做法

角色建议统一存成:

ROLE_USER
ROLE_ADMIN

如果你不想带 ROLE_ 前缀,也可以改成用 hasAuthority("ADMIN"),但团队里一定要统一。


4. 登录总是失败,明明密码对了

常见原因

  • 密码没有用同一个 PasswordEncoder
  • 数据库存的是明文,但配置用了 BCryptPasswordEncoder
  • 每次启动都重新 encode,导致结果不同

说明

像 BCrypt 这种算法,同一个明文多次加密结果本来就不同。
所以不要拿“加密后的字符串是否相等”来判断,应该交给 PasswordEncoder.matches()


5. 返回 401 和 403 分不清

这是非常实际的问题:

  • 401 Unauthorized:你还没通过认证,或者 Token 无效/过期
  • 403 Forbidden:你已经登录了,但权限不够

如果一个普通用户访问管理员接口,应该是 403,不是 401。


6. JWT 过期后直接 500

原因

你在过滤器或解析代码里没有正确处理异常,比如:

  • ExpiredJwtException
  • JwtException
  • 其他运行时异常

建议

最简单的做法是先在过滤器里捕获异常并放行给后续统一处理;更完善的做法是自定义:

  • AuthenticationEntryPoint 处理 401
  • AccessDeniedHandler 处理 403

安全/性能最佳实践

JWT 很方便,但不能因为方便就“能跑就行”。下面这些建议,我认为是从 demo 到生产最应该补上的部分。

1. 密钥不要硬编码

本文为了演示,把密钥写在代码里。生产环境必须改成:

  • 环境变量
  • 配置中心
  • KMS / Secret Manager

例如:

jwt:
  secret: ${JWT_SECRET}
  expiration: 7200

然后用 @ConfigurationProperties 读取。


2. 不要在 JWT 里放敏感信息

JWT 只是可签名,不是默认加密
不要把这些信息直接放进去:

  • 用户密码
  • 手机号
  • 身份证号
  • 银行卡号
  • 大量业务数据

通常放这些就够了:

  • 用户 ID
  • 用户名
  • 角色
  • 过期时间
  • 签发时间

3. Token 过期时间不要太长

过期时间太长,泄露风险会被放大。
常见建议:

  • Access Token:15 分钟到 2 小时
  • Refresh Token:几天到几周

如果你对安全要求更高,推荐采用:

  • 短期 Access Token
  • 长期 Refresh Token
  • 服务端维护 Refresh Token 状态

本文为了聚焦主线,没有展开 Refresh Token,但生产项目很值得加。


4. 对注销和踢下线要有预案

JWT 的典型问题是:一旦签发,在过期前通常都有效
这会带来两个现实需求:

  • 用户主动注销后,旧 Token 怎么失效?
  • 管理员踢用户下线怎么做?

常见方案有:

  1. 黑名单机制
    把已作废 Token 的唯一标识放到 Redis 中
  2. 版本号机制
    用户表维护 tokenVersion,JWT 中携带版本,校验时比对
  3. 缩短 Token 生命周期
    结合 Refresh Token 降低风险窗口

如果系统安全要求一般,第三种成本最低。


5. 角色和权限尽量解耦

很多小项目一开始只有 ADMINUSER,直接写死角色判断没问题。
但如果你的权限逐渐复杂,建议从早期就区分:

  • 角色 Role:岗位/身份
  • 权限 Permission:具体操作点

例如:

  • ROLE_ADMIN
  • sys:user:list
  • sys:user:create

这样后期做菜单权限、按钮权限时不至于全推翻。


6. 给异常返回统一 JSON

前后端分离最怕后端丢回一个默认 HTML 错误页。
所以建议自定义 401/403 返回格式,例如:

{
  "code": 401,
  "message": "未登录或token已失效"
}

以及:

{
  "code": 403,
  "message": "没有访问该资源的权限"
}

这样前端才能稳定地做跳转、提示和刷新 Token。


7. 过滤器里尽量只做认证,不做重业务逻辑

JWT 过滤器的职责应该尽量单一:

  • 取 Token
  • 解析 Token
  • 恢复 Authentication

不要在里面塞太多业务判断,比如复杂菜单查询、审计写库、用户状态联表计算。
否则每个请求都会变重,性能和可维护性都会下降。


8. 为接口链路加日志,但别打出完整 Token

日志里建议打印:

  • 请求路径
  • 用户名
  • 认证结果
  • 异常类型

不建议打印完整 JWT。实在需要排查,最多打印前几位和后几位。

错误示例:

token=eyJhbGciOiJIUzI1NiJ9......

更好的方式:

tokenPrefix=eyJhbGciOi... , username=admin

可继续扩展的方向

如果你准备把本文方案用于正式项目,下一步通常会演进成下面这些能力:

  • 接数据库用户表、角色表、权限表
  • 增加 Refresh Token
  • 接 Redis 黑名单
  • 加统一异常处理
  • 支持多端登录策略
  • 支持方法级权限控制,如 @PreAuthorize

例如方法级鉴权可以这样写:

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/admin/stats")
public String stats() {
    return "stats";
}

一套更接近生产的排查顺序

最后给你一个我自己常用的排查顺序,出问题时很省事:

  1. 先看请求有没有进接口
  2. 再看过滤器有没有执行
  3. 再看 Authorization 头是否正确
  4. 再看 JWT 是否解析成功
  5. 再看 SecurityContext 是否放入 Authentication
  6. 最后看权限表达式是否匹配

很多同学一上来就怀疑 Spring Security 配置,其实最常见的问题是:

  • 前端没带 Token
  • Token 前缀少空格
  • 角色名没加 ROLE_

这几个问题,真的占了大多数。


总结

Spring Boot 3 + Spring Security 6 下做 JWT 认证鉴权,核心其实就三件事:

  1. 登录时校验用户名密码,并签发 JWT
  2. 请求时通过过滤器解析 JWT,恢复用户认证信息
  3. 通过 Spring Security 的权限规则完成接口鉴权

你可以把整套方案记成一句话:

登录接口负责“发证”,JWT 过滤器负责“验票”,Spring Security 负责“决定能去哪”。

如果你是第一次从旧版 Spring Security 升级过来,最重要的心智切换有两个:

  • 不再依赖 WebSecurityConfigurerAdapter
  • 不再依赖 Session 保存登录态,而是显式通过过滤器恢复认证上下文

本文这套代码适合作为入门到项目落地之间的桥梁版本
如果你的系统已经进入生产阶段,我建议至少再补三项:

  • 统一 401/403 JSON 返回
  • Refresh Token 机制
  • 密钥与 Token 作废策略

做到这一步,你的前后端分离认证体系就不只是“能跑”,而是“基本能上场”。

如果你准备直接实战,建议先按本文代码完整跑通,再逐步替换为数据库用户、Redis、权限表,而不是一开始就把所有复杂度揉在一起。这样成功率最高。


分享到:

上一篇
《前端性能优化实战:基于 Web Vitals 的页面加载与交互体验提升方案》
下一篇
《从 Prompt Engineering 到 Agent Workflow:中级开发者构建可落地 AI 自动化流程的实践指南》