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

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

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

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

前后端分离项目里,登录认证几乎是绕不过去的一关。很多同学在 Spring Boot 2 时代用过 WebSecurityConfigurerAdapter,结果一升级到 Spring Boot 3 / Spring Security 6,发现不少写法都“失效”了:配置方式变了、过滤器链变了、权限判断方式也更强调组件化。

这篇文章我会从**“怎么从 0 到 1 搭起来,并且能跑通”的角度,带你做一个基于 JWT + Spring Security 6 的认证授权示例。文章重点不是讲概念大而全,而是讲一个前后端分离项目里真正常用的实现方式**。


背景与问题

在前后端分离场景下,后端一般不再依赖服务端 Session 页面跳转,而更常见的是:

  • 用户调用 /login 登录
  • 服务端校验账号密码
  • 登录成功后返回一个 JWT
  • 前端把 JWT 存起来
  • 后续请求在 Authorization: Bearer <token> 中携带
  • 后端解析 JWT,识别用户身份与权限
  • 决定是否放行请求

这套方案的核心价值有两个:

  1. 无状态:服务端不用保存登录会话,适合分布式部署
  2. 适配前后端分离:接口风格自然,前端只需要管理 token

但问题也很集中:

  • Spring Security 6 的配置方式和旧版本不同
  • JWT 过滤器该挂在哪?
  • 401403 经常搞混
  • 权限字段是放 roles 还是 authorities
  • token 过期、跨域、异常响应这些细节很容易踩坑

如果这些点你也遇到过,这篇就是给你准备的。


前置知识与环境准备

适合读者

本文默认你已经具备:

  • Java 基础
  • Spring Boot 基本开发经验
  • 知道 REST 接口怎么写
  • 了解“登录”和“权限”的基本概念

环境版本

本文示例使用:

  • JDK 17
  • Spring Boot 3.x
  • Spring Security 6.x
  • Maven
  • jjwt 0.11.5

核心原理

先不急着写代码,我们先把链路捋顺。

1. 整体认证流程

flowchart TD
    A[前端提交用户名密码] --> B[/login 接口]
    B --> C[AuthenticationManager 校验账号密码]
    C -->|成功| D[生成 JWT]
    C -->|失败| E[返回 401]
    D --> F[前端保存 Token]
    F --> G[后续请求携带 Authorization Bearer Token]
    G --> H[JWT 过滤器解析 Token]
    H --> I[构建 Authentication 放入 SecurityContext]
    I --> J[Spring Security 进行权限判断]
    J -->|通过| K[访问业务接口]
    J -->|拒绝| L[返回 403]

2. Spring Security 6 在这里扮演什么角色?

你可以把它理解成两层:

  • 认证(Authentication):你是谁?
  • 授权(Authorization):你能访问什么?

在 JWT 方案里:

  • 登录时,通过 AuthenticationManager 做用户名密码认证
  • 登录后,通过自定义 JWT 过滤器从 token 中恢复用户身份
  • 最后,框架根据接口权限配置判断能不能访问

3. JWT 为什么适合前后端分离?

JWT 本质上是一个自包含的令牌,里面可以携带:

  • 用户名
  • 用户 ID
  • 权限列表
  • 过期时间

服务端不需要再查 Session 就能知道是谁发来的请求。

当然,它也不是银弹:

  • token 一旦签发,失效控制不像 Session 那么直接
  • 不适合在 token 里塞太多敏感信息
  • 要考虑过期、刷新、吊销策略

4. 一次请求经过过滤器链的过程

sequenceDiagram
    participant Client as 前端
    participant Filter as JwtAuthenticationFilter
    participant Security as Spring Security
    participant Controller as Controller

    Client->>Filter: GET /api/admin/users + Bearer token
    Filter->>Filter: 解析 JWT
    Filter->>Filter: 校验签名、过期时间
    Filter->>Security: 设置 Authentication 到 SecurityContext
    Security->>Security: 校验接口权限
    alt 有权限
        Security->>Controller: 放行请求
        Controller-->>Client: 200 OK
    else 无权限
        Security-->>Client: 403 Forbidden
    end

项目结构设计

为了让代码更清晰,我建议按下面方式拆分:

src/main/java/com/example/jwtdemo
├── JwtDemoApplication.java
├── config
│   └── SecurityConfig.java
├── controller
│   ├── AuthController.java
│   └── UserController.java
├── dto
│   ├── LoginRequest.java
│   └── LoginResponse.java
├── security
│   ├── JwtAuthenticationFilter.java
│   ├── JwtTokenProvider.java
│   ├── RestAccessDeniedHandler.java
│   └── RestAuthenticationEntryPoint.java
└── service
    └── CustomUserDetailsService.java

实战代码(可运行)

下面给出一个最小可运行版本。为了突出 JWT 与 Security 的主线,示例中的用户数据先写死在内存中。实际项目里你可以替换成数据库查询。


1)Maven 依赖

<project xmlns="http://maven.apache.org/POM/4.0.0" 
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.example</groupId>
    <artifactId>jwt-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>17</java.version>
        <jjwt.version>0.11.5</jjwt.version>
    </properties>

    <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>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

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

2)配置文件

src/main/resources/application.yml

server:
  port: 8080

jwt:
  secret: 12345678901234567890123456789012
  expiration: 3600000

这里的 secret 只是演示,生产环境一定要使用高强度密钥,并通过环境变量或密钥管理服务注入。


3)启动类

package com.example.jwtdemo;

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

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

4)DTO

LoginRequest.java

package com.example.jwtdemo.dto;

import jakarta.validation.constraints.NotBlank;

public class LoginRequest {

    @NotBlank
    private String username;

    @NotBlank
    private String password;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

LoginResponse.java

package com.example.jwtdemo.dto;

public class LoginResponse {

    private String token;
    private String tokenType = "Bearer";

    public LoginResponse(String token) {
        this.token = token;
    }

    public String getToken() {
        return token;
    }

    public String getTokenType() {
        return tokenType;
    }
}

5)JWT 工具类

JwtTokenProvider.java

package com.example.jwtdemo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;

@Component
public class JwtTokenProvider {

    private final SecretKey secretKey;
    private final long expiration;

    public JwtTokenProvider(@Value("${jwt.secret}") String secret,
                            @Value("${jwt.expiration}") long expiration) {
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expiration = expiration;
    }

    public String generateToken(UserDetails userDetails) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration);

        List<String> authorities = userDetails.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());

        return Jwts.builder()
                .setSubject(userDetails.getUsername())
                .claim("authorities", authorities)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    public String getUsernameFromToken(String token) {
        return getClaims(token).getSubject();
    }

    public List<String> getAuthorities(String token) {
        Claims claims = getClaims(token);
        return claims.get("authorities", List.class);
    }

    public boolean validateToken(String token) {
        try {
            getClaims(token);
            return true;
        } catch (Exception ex) {
            return false;
        }
    }

    private Claims getClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(secretKey)
                .build()
                .parseClaimsJws(token)
                .getBody();
    }
}

6)用户加载逻辑

CustomUserDetailsService.java

这里先使用内存用户模拟。为了更贴近真实项目,我保留了 UserDetailsService 的标准接口,这样以后切换数据库代价很小。

package com.example.jwtdemo.service;

import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("admin".equals(username)) {
            return new User(
                    "admin",
                    "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
                    List.of(
                            new SimpleGrantedAuthority("ROLE_ADMIN"),
                            new SimpleGrantedAuthority("user:read")
                    )
            );
        }

        if ("user".equals(username)) {
            return new User(
                    "user",
                    "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy",
                    List.of(
                            new SimpleGrantedAuthority("ROLE_USER"),
                            new SimpleGrantedAuthority("user:read")
                    )
            );
        }

        throw new UsernameNotFoundException("用户不存在");
    }
}

上面两个用户的密码都是:

password

7)JWT 认证过滤器

JwtAuthenticationFilter.java

这个过滤器负责:

  • 从请求头拿到 Bearer Token
  • 解析用户名和权限
  • 构造 Authentication
  • 放进 SecurityContextHolder
package com.example.jwtdemo.security;

import com.example.jwtdemo.service.CustomUserDetailsService;
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.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
                                   CustomUserDetailsService userDetailsService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.userDetailsService = userDetailsService;
    }

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

        String token = resolveToken(request);

        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsernameFromToken(token);

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

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );

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

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

8)认证失败与权限不足响应

RestAuthenticationEntryPoint.java

未登录或 token 非法时返回 401

package com.example.jwtdemo.security;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":401,\"message\":\"未认证或Token无效\"}");
    }
}

RestAccessDeniedHandler.java

已登录但权限不足时返回 403

package com.example.jwtdemo.security;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":403,\"message\":\"权限不足\"}");
    }
}

9)Spring Security 6 配置

SecurityConfig.java

这是升级到 Spring Security 6 后最容易改错的部分。重点有三个:

  • 使用 SecurityFilterChain Bean 配置
  • 显式声明 AuthenticationManager
  • 把 JWT 过滤器放到 UsernamePasswordAuthenticationFilter 之前
package com.example.jwtdemo.config;

import com.example.jwtdemo.security.JwtAuthenticationFilter;
import com.example.jwtdemo.security.RestAccessDeniedHandler;
import com.example.jwtdemo.security.RestAuthenticationEntryPoint;
import com.example.jwtdemo.service.CustomUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.annotation.web.configurers.AbstractHttpConfigurer;
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;
    private final RestAuthenticationEntryPoint authenticationEntryPoint;
    private final RestAccessDeniedHandler accessDeniedHandler;
    private final CustomUserDetailsService userDetailsService;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
                          RestAuthenticationEntryPoint authenticationEntryPoint,
                          RestAccessDeniedHandler accessDeniedHandler,
                          CustomUserDetailsService userDetailsService) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.accessDeniedHandler = accessDeniedHandler;
        this.userDetailsService = userDetailsService;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .cors(Customizer.withDefaults())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/login").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .requestMatchers("/api/user/**").hasAuthority("user:read")
                        .anyRequest().authenticated()
                )
                .authenticationProvider(authenticationProvider())
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

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

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

10)登录接口与受保护接口

AuthController.java

package com.example.jwtdemo.controller;

import com.example.jwtdemo.dto.LoginRequest;
import com.example.jwtdemo.dto.LoginResponse;
import com.example.jwtdemo.security.JwtTokenProvider;
import jakarta.validation.Valid;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthController(AuthenticationManager authenticationManager,
                          JwtTokenProvider jwtTokenProvider) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenProvider = jwtTokenProvider;
    }

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

        String token = jwtTokenProvider.generateToken(
                (org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal()
        );

        return new LoginResponse(token);
    }
}

UserController.java

package com.example.jwtdemo.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class UserController {

    @GetMapping("/profile")
    public Map<String, Object> profile(Authentication authentication) {
        return Map.of(
                "message", "当前登录用户信息",
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }

    @GetMapping("/user/hello")
    public Map<String, Object> userHello() {
        return Map.of("message", "拥有 user:read 权限即可访问");
    }

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/hello")
    public Map<String, Object> adminHello() {
        return Map.of("message", "只有 ADMIN 可访问");
    }
}

运行与验证

启动项目后,可以按下面步骤测试。

1)登录获取 token

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

返回示例:

{
  "token": "eyJhbGciOiJIUzI1NiJ9....",
  "tokenType": "Bearer"
}

2)携带 token 访问个人信息接口

curl http://localhost:8080/api/profile \
  -H "Authorization: Bearer 你的token"

3)访问普通权限接口

curl http://localhost:8080/api/user/hello \
  -H "Authorization: Bearer 你的token"

4)访问管理员接口

curl http://localhost:8080/api/admin/hello \
  -H "Authorization: Bearer 你的token"

如果用 user/password 登录,再访问 /api/admin/hello,应该得到 403


逐步验证清单

这是我自己排查认证问题时常用的顺序,特别有效。

登录阶段

  • /login 是否放行了?
  • 用户名密码是否正确?
  • PasswordEncoder 是否和密码加密方式一致?
  • 是否成功返回 JWT?

携带 token 阶段

  • 请求头是否是 Authorization
  • 前缀是否为 Bearer
  • token 是否已过期
  • JWT 密钥是否一致

权限判断阶段

  • hasRole("ADMIN") 对应的权限是否真的叫 ROLE_ADMIN
  • hasAuthority("user:read") 是否和 token/用户信息一致
  • 方法级注解是否开启了 @EnableMethodSecurity

常见坑与排查

这一部分非常重要,很多问题不是不会写,而是写完跑不通。

1. 401403 分不清

这是最常见的问题。

  • 401 Unauthorized:你还没有被系统认可,通常是没带 token、token 无效、token 过期
  • 403 Forbidden:你已经登录了,但权限不够

很多人看到“访问失败”就一股脑以为是权限问题,实际上可能是过滤器根本没把用户身份放进 SecurityContext


2. hasRole("ADMIN") 为什么要求权限是 ROLE_ADMIN

因为 hasRole("ADMIN") 底层会自动补前缀 ROLE_。所以:

.hasRole("ADMIN")

等价于检查:

ROLE_ADMIN

如果你的权限里只有 ADMIN,那就会匹配失败。

建议

  • 角色统一用 ROLE_ 前缀
  • 细粒度权限统一用 user:readuser:create 这种形式

这样最不容易乱。


3. 登录明明成功,后续接口还是 401

优先检查这几项:

  • 前端有没有真的把 token 带上
  • 是否写成了 Bearer 而不是 bearer
  • 过滤器是不是注册到了正确位置
  • sessionCreationPolicy 是否设置为 STATELESS

我之前就踩过一个坑:前端请求封装层里,登录接口和普通接口走了两套拦截器,结果 token 根本没带出去,后端排查半天。


4. 密码正确却提示 Bad Credentials

通常是密码编码器不一致导致的。

例如:

  • 数据库里存的是 BCrypt 加密后的密码
  • 你却用了明文比较
  • 或者用了别的编码器

本文示例用了:

new BCryptPasswordEncoder()

那用户密码就必须是 BCrypt 格式。

如果你要生成一个新的 BCrypt 密码,可以临时写段代码:

package com.example.jwtdemo;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

public class PasswordTest {
    public static void main(String[] args) {
        System.out.println(new BCryptPasswordEncoder().encode("password"));
    }
}

5. 升级到 Spring Security 6 后旧配置失效

旧版常见写法:

extends WebSecurityConfigurerAdapter

在新版本里已经不推荐这样用了,应该改成基于 SecurityFilterChain 的 Bean 配置。

如果你是老项目升级,优先看以下几点:

  • 是否还在继承 WebSecurityConfigurerAdapter
  • 是否还在用 antMatchers
  • 是否忘了声明 AuthenticationManager

6. CORS 跨域问题

前后端分离经常前端端口和后端端口不同,这时浏览器会先发预检请求。

如果跨域没处理好,现象常常是:

  • 前端提示跨域报错
  • 后端你以为是权限问题,其实请求还没真正到业务接口

可以补一个简单的跨域配置:

package com.example.jwtdemo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.*;

import java.util.List;

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:3000"));
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setAllowCredentials(true);

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

安全/性能最佳实践

JWT 做起来不难,难的是做得“够稳”。

1. 不要把敏感信息直接塞进 JWT

JWT 是可以被解码查看内容的,只要拿到 token,就能看到 payload。
所以不要在里面放:

  • 明文密码
  • 手机号、身份证号等敏感信息
  • 过多业务数据

建议只放最小必要信息:

  • 用户唯一标识
  • 用户名
  • 权限列表
  • 过期时间

2. 密钥一定要安全存储

生产环境不要把密钥硬编码在代码仓库里。建议:

  • 使用环境变量
  • 使用配置中心
  • 使用 KMS / Vault 等密钥管理服务

并且要有密钥轮换机制。


3. token 过期时间不要太长

太长会增加泄露风险,太短会影响体验。

一般建议:

  • Access Token:15 分钟 ~ 2 小时
  • Refresh Token:更长,但必须单独管理

本文示例为了简单只做了 Access Token。真实项目建议补上刷新机制。


4. 重要系统建议做 token 黑名单或版本控制

JWT 的一个天然问题是:一旦签发,在过期前通常都有效。
如果你要支持这些场景:

  • 强制下线
  • 修改密码后旧 token 失效
  • 管理员封禁账号

可以考虑:

  • Redis 黑名单
  • 用户 tokenVersion 字段
  • 登出时记录 token jti

这一步不是所有系统都必须上,但中后台、金融、企业系统通常值得做。


5. 权限不要只靠前端控制

前端按钮隐藏只是“体验优化”,不是安全控制。
真正的权限校验必须在后端完成:

  • 路由能否访问由后端接口判定
  • 数据能否查询由后端权限控制
  • 操作能否执行由后端授权规则决定

这是非常重要的边界。


6. 尽量减少每次请求的数据库查询

JWT 的优势之一就是减少会话查找。
但如果你的过滤器每次都:

  • 解析 token
  • 再查数据库
  • 再拼权限

那性能收益就会被抵消一部分。

常见做法有两种:

  1. token 中带权限信息,请求时直接恢复
  2. 配合缓存,用户权限变更后再失效缓存

本文示例为了安全与结构清晰,仍然通过 UserDetailsService 加载用户。生产里你可以根据权限变更频率做取舍。


方案边界与取舍

JWT 不是所有系统都最优,我建议你根据场景选。

适合 JWT 的场景

  • 前后端分离
  • 微服务网关统一认证
  • 多节点部署,不想共享 Session
  • APP / 小程序 / SPA 接口认证

不一定适合的场景

  • 极强的实时踢下线要求
  • 对会话可控性要求很高
  • 权限变化非常频繁,且必须立刻生效

这类场景可能要结合:

  • Redis 会话
  • Refresh Token
  • 黑名单
  • 网关统一鉴权

权限模型建议

如果你准备把这套方案用到真实项目,权限命名最好尽早定规范。我建议:

角色

表示身份层级,例如:

  • ROLE_ADMIN
  • ROLE_USER
  • ROLE_MANAGER

权限点

表示操作粒度,例如:

  • user:read
  • user:create
  • user:update
  • order:audit

为什么要分开?

因为角色适合做“粗粒度入口控制”,权限点适合做“细粒度操作控制”。

比如:

  • /api/admin/** 只允许管理员进入
  • 页面里“删除用户”按钮要求 user:delete

这样系统会比较清晰,不容易后期失控。


权限结构示意图

classDiagram
    class User {
      +Long id
      +String username
      +String password
    }

    class Role {
      +String code
    }

    class Permission {
      +String code
    }

    User --> Role : 多对多
    Role --> Permission : 多对多

实际项目里,很多团队会采用:

  • 用户 -> 角色
  • 角色 -> 权限
  • 登录时把权限汇总后放入 JWT 或缓存

这是比较经典也容易维护的设计。


总结

到这里,我们已经完整走通了一套基于 Spring Boot 3 + Spring Security 6 + JWT 的前后端分离认证授权方案。核心落地点其实就几件事:

  1. AuthenticationManager 完成登录认证
  2. 登录成功后签发 JWT
  3. OncePerRequestFilter 在每次请求中解析 token
  4. 将用户身份放入 SecurityContext
  5. 用 URL 规则和方法注解做权限控制
  6. 明确区分 401403

如果你准备把它用于真实项目,我建议按这个顺序继续演进:

  • 第一步:先把本文最小版本跑通
  • 第二步:把内存用户替换为数据库用户
  • 第三步:加入角色-权限表模型
  • 第四步:补上 Refresh Token
  • 第五步:根据业务需要增加黑名单、强制下线、审计日志

最后给你一个很实用的建议:
认证问题排查,永远按“登录成功了吗 -> token 带了吗 -> token 解析成功了吗 -> SecurityContext 里有用户吗 -> 权限字符串匹配了吗”这条链路查。
这样比盲猜快很多,我自己做项目时基本也是这么定位的。

如果你只是想先搭一个稳定的后台接口认证体系,这套方案已经足够作为一个可靠起点。


分享到:

上一篇
《大模型应用实战:基于 RAG 构建企业知识库问答系统的架构设计与性能优化》
下一篇
《微服务架构中分布式事务的实战方案:基于 Seata 的一致性设计与落地经验》