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

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

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

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

前后端分离项目里,登录认证几乎是绕不过去的一关。很多人第一次接触 Spring Security 时,最大的感受通常是:概念多、过滤器链长、配置改一点就 403。如果再叠加 JWT,问题会更多——登录成功后怎么发 token、请求来了在哪校验、权限怎么挂钩、接口为什么明明带了 token 还是被拦。

这篇文章我不走“纯概念铺陈”的路子,而是按一个常见的业务路径带你做一遍:

  • 用户登录
  • 服务端签发 JWT
  • 前端带着 JWT 调接口
  • Spring Security 解析并完成认证
  • 基于角色/权限做授权控制

文章基于:

  • Spring Boot 3
  • Spring Security 6
  • JJWT
  • 前后端分离、无状态认证

我会给出一套可运行的代码骨架,并把几个最常踩的坑放在后面集中排查。


背景与问题

在传统服务端渲染应用里,认证往往依赖 Session:

  1. 用户登录成功
  2. 服务端把登录态存到 Session
  3. 浏览器自动带 Cookie
  4. 服务端根据 Session 识别用户

但到了前后端分离场景,尤其是:

  • Web 前端和后端分域部署
  • 移动端、小程序、多端接入
  • 网关转发、微服务拆分
  • 希望服务端尽量无状态

这时 Session 方案会遇到几个问题:

  • 跨域与 Cookie 管理复杂
  • 服务横向扩容后 Session 共享麻烦
  • 多端统一认证成本高
  • 网关/服务间透传身份不够自然

JWT 的思路是:把用户身份信息签名后放进 token,客户端保存并在每次请求里携带,服务端只负责校验 token 是否可信

不过要注意,JWT 解决的是“认证状态传递”问题,不是万能安全药。它带来的收益和代价都很明显:

方案优点缺点
Session服务端可控、便于失效扩容与共享复杂
JWT无状态、跨端方便主动失效难、token 泄露风险更高

如果你的系统是标准前后端分离接口服务,那么 Spring Security 6 + JWT 依然是很常见、很实用的搭配。


前置知识与环境准备

建议你至少了解这些基础概念:

  • Spring Boot 基本项目结构
  • Spring MVC 接口开发
  • Spring Security 的认证与授权区别
  • HTTP Header 的基本使用
  • Maven 依赖管理

本文示例环境:

  • JDK 17+
  • Spring Boot 3.3.x
  • Maven 3.9+
  • 数据存储先用内存模拟,方便聚焦认证流程

认证与授权:先把概念说透

很多人会把登录、权限、token 混成一团。其实可以拆开看:

  • 认证 Authentication:你是谁
    比如用户名密码校验通过,确认你是张三。
  • 授权 Authorization:你能干什么
    比如张三能访问 /admin/** 吗?
  • JWT:身份信息的携带方式
    它不是权限系统本身,而是承载认证结果的容器。

一句话理解:

登录成功后把“你是谁、你有哪些权限、token 何时过期”打包进 JWT;后续请求中,Spring Security 负责把这个 token 重新还原成当前用户上下文,再决定是否允许访问接口。


核心原理

先看整体请求链路。

flowchart LR
    A[前端提交用户名密码] --> B[/auth/login]
    B --> C[AuthenticationManager 校验账号密码]
    C --> D[生成 JWT]
    D --> E[前端保存 Token]

    E --> F[访问受保护接口]
    F --> G[Authorization: Bearer token]
    G --> H[JWT过滤器解析Token]
    H --> I[构造 Authentication 放入 SecurityContext]
    I --> J[Spring Security 做权限判断]
    J --> K[返回业务数据]

这个流程里最关键的点有三个:

1. 登录接口不再依赖默认表单登录

Spring Security 以前常见的用法是表单登录页面,但前后端分离通常不需要这个页面,所以我们会:

  • 关闭默认 formLogin
  • 提供自己的 /auth/login JSON 登录接口

2. 每个请求都要经过 JWT 过滤器

JWT 是无状态的,服务端不会记住你是谁。
所以每次请求到来时,都要:

  • Authorization 头里取 token
  • 校验签名和过期时间
  • 解析用户名、角色
  • 构造成 Authentication
  • 放进 SecurityContextHolder

3. 授权最终还是交给 Spring Security

也就是说:

  • JWT 负责“带身份”
  • Spring Security 负责“认身份、管权限”

这点非常重要。很多项目把权限全写进自定义拦截器,最后会越来越乱。更推荐的方式是:让 JWT 过滤器只做认证恢复,让权限判断继续走 Security 的标准机制


Spring Security 6 下的关键变化

如果你看过一些旧教程,可能会发现写法对不上。这不是你记错了,是版本变了。

Spring Security 6 / Spring Boot 3 里几个明显变化:

  • WebSecurityConfigurerAdapter 已废弃,改用 SecurityFilterChain
  • 授权配置从 authorizeRequests() 变为 authorizeHttpRequests()
  • requestMatchers() 替代很多旧匹配写法
  • 更强调 Lambda 风格配置

所以如果你以前是这么写:

@Override
protected void configure(HttpSecurity http) throws Exception {
    // 旧写法
}

那现在要换思路了。


项目结构设计

为了让代码足够清晰,我们拆成下面几个部分:

src/main/java/com/example/jwtdemo
├── JwtDemoApplication.java
├── config
│   ├── SecurityConfig.java
│   └── JwtAuthenticationFilter.java
├── controller
│   ├── AuthController.java
│   └── UserController.java
├── dto
│   ├── LoginRequest.java
│   └── LoginResponse.java
├── security
│   ├── CustomUserDetailsService.java
│   └── JwtService.java
└── store
    └── InMemoryUserStore.java

这套结构不复杂,但职责比较明确:

  • AuthController:登录发 token
  • JwtAuthenticationFilter:每次请求解析 token
  • JwtService:生成/校验 JWT
  • CustomUserDetailsService:根据用户名加载用户
  • SecurityConfig:统一安全配置

实战代码(可运行)

下面给你一套最小可运行版本。你可以直接照着搭。


第一步:创建 Maven 依赖

pom.xml

<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>
    <name>jwt-demo</name>
    <description>Spring Boot 3 JWT Security Demo</description>

    <properties>
        <java.version>17</java.version>
        <spring.boot.version>3.3.4</spring.boot.version>
        <jjwt.version>0.12.6</jjwt.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring.boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <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>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

第二步:配置 application.yml

server:
  port: 8080

jwt:
  secret: 12345678901234567890123456789012
  expiration: 3600000

说明:

  • secret 至少要足够长,HS256 下建议 32 字节以上
  • expiration 单位毫秒,这里是 1 小时

第三步:启动类

JwtDemoApplication.java

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);
    }
}

第四步:定义登录请求与响应 DTO

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;
    }
}

dto/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;
    }
}

第五步:准备一个内存用户存储

为了先跑通流程,我们不用数据库,先模拟两个用户:

  • admin / 123456
  • user / 123456

store/InMemoryUserStore.java

package com.example.jwtdemo.store;

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

import java.util.*;

@Component
public class InMemoryUserStore {

    private final Map<String, UserRecord> users = new HashMap<>();

    public InMemoryUserStore(PasswordEncoder passwordEncoder) {
        users.put("admin", new UserRecord(
                "admin",
                passwordEncoder.encode("123456"),
                List.of("ROLE_ADMIN", "ROLE_USER")
        ));

        users.put("user", new UserRecord(
                "user",
                passwordEncoder.encode("123456"),
                List.of("ROLE_USER")
        ));
    }

    public Optional<UserRecord> findByUsername(String username) {
        return Optional.ofNullable(users.get(username));
    }

    public static class UserRecord {
        private final String username;
        private final String password;
        private final List<String> roles;

        public UserRecord(String username, String password, List<String> roles) {
            this.username = username;
            this.password = password;
            this.roles = roles;
        }

        public String getUsername() {
            return username;
        }

        public String getPassword() {
            return password;
        }

        public List<String> getRoles() {
            return roles;
        }
    }
}

第六步:实现 UserDetailsService

security/CustomUserDetailsService.java

package com.example.jwtdemo.security;

import com.example.jwtdemo.store.InMemoryUserStore;
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 InMemoryUserStore userStore;

    public CustomUserDetailsService(InMemoryUserStore userStore) {
        this.userStore = userStore;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        var user = userStore.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));

        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .authorities(user.getRoles().stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList()))
                .build();
    }
}

这里我使用了 authorities 而不是 roles("ADMIN"),因为 JWT 里通常会直接保存完整权限标识,后面更灵活。


第七步:实现 JWT 工具类

security/JwtService.java

package com.example.jwtdemo.security;

import io.jsonwebtoken.*;
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.Service;

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

@Service
public class JwtService {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expiration}")
    private long expiration;

    private SecretKey getSignKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

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

        Date now = new Date();
        Date expireDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .subject(userDetails.getUsername())
                .claim("authorities", authorities)
                .issuedAt(now)
                .expiration(expireDate)
                .signWith(getSignKey())
                .compact();
    }

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

    public List<String> extractAuthorities(String token) {
        Object authorities = parseClaims(token).get("authorities");
        if (authorities instanceof List<?> list) {
            return list.stream().map(String::valueOf).toList();
        }
        return List.of();
    }

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

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

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

第八步:实现 JWT 过滤器

这是整套方案里最核心的一环。

config/JwtAuthenticationFilter.java

package com.example.jwtdemo.config;

import com.example.jwtdemo.security.JwtService;
import com.example.jwtdemo.security.CustomUserDetailsService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.stream.Collectors;

@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 {

        final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

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

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

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

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

            if (jwtService.isTokenValid(token, userDetails)) {
                var authorities = jwtService.extractAuthorities(token).stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

                var authToken = new UsernamePasswordAuthenticationToken(
                        userDetails,
                        null,
                        authorities
                );
                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

这里有个实践细节值得说一下:

  • 是否每次都查数据库加载用户?
    示例里查了 userDetailsService,这样可以确保账号状态变化能被感知。
  • 如果你想完全无状态,也可以只信任 token 内的数据,但那样权限变更和封号失效会不及时。

生产里我通常更推荐:

  • token 内保留基础身份和权限快照
  • 配合短时效 access token
  • 必要时结合黑名单或版本号机制

第九步:Spring Security 配置

config/SecurityConfig.java

package com.example.jwtdemo.config;

import com.example.jwtdemo.security.CustomUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.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 CustomUserDetailsService userDetailsService;

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

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

        return http.build();
    }

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

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

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

几个关键配置解释一下:

  • csrf.disable():前后端分离、JWT header 认证场景中通常关闭
  • STATELESS:禁用 Session
  • addFilterBefore(...):让 JWT 过滤器在用户名密码认证过滤器之前执行
  • hasRole("ADMIN"):底层会匹配 ROLE_ADMIN

第十步:登录接口

controller/AuthController.java

package com.example.jwtdemo.controller;

import com.example.jwtdemo.dto.LoginRequest;
import com.example.jwtdemo.dto.LoginResponse;
import com.example.jwtdemo.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("/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(@RequestBody @Valid LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(),
                        request.getPassword()
                )
        );

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

这个接口很“前后端分离”:

  • 接收 JSON
  • 返回 JSON
  • 不跳登录页
  • 不设置 Session

第十一步:受保护接口与角色接口

controller/UserController.java

package com.example.jwtdemo.controller;

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

import java.util.Map;

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

    @GetMapping("/user/profile")
    public Map<String, Object> profile(Authentication authentication) {
        return Map.of(
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }

    @GetMapping("/admin/dashboard")
    public Map<String, Object> adminDashboard(Authentication authentication) {
        return Map.of(
                "message", "欢迎进入管理员面板",
                "operator", authentication.getName()
        );
    }
}

一张时序图:请求到底怎么走

如果你总觉得过滤器链抽象,这张图会更直观。

sequenceDiagram
    participant FE as 前端
    participant Auth as AuthController
    participant AM as AuthenticationManager
    participant JWT as JwtService
    participant Filter as JwtAuthenticationFilter
    participant API as Protected API

    FE->>Auth: POST /auth/login 用户名密码
    Auth->>AM: authenticate()
    AM-->>Auth: 认证成功
    Auth->>JWT: generateToken()
    JWT-->>FE: 返回 JWT

    FE->>Filter: GET /api/user/profile + Bearer Token
    Filter->>JWT: 解析并校验 token
    JWT-->>Filter: 用户名/权限
    Filter->>Filter: 放入 SecurityContext
    Filter->>API: 放行请求
    API-->>FE: 返回业务数据

运行与验证

启动项目后,按下面顺序验证。

1. 登录获取 token

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

预期返回:

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

2. 访问普通用户接口

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

3. 访问管理员接口

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

如果用 user/123456 登录,再访问管理员接口,应该得到 403。


逐步验证清单

这里给一个实战里很好用的排错顺序。我自己调 Spring Security 时,基本都按这个顺序来。

  • /auth/login 是否被 permitAll()
  • 用户密码是否经过同一种 PasswordEncoder
  • JWT 是否真的放在 Authorization: Bearer xxx
  • 过滤器是否成功注册到链路中
  • token 中的权限是否为 ROLE_ADMIN 这种格式
  • hasRole("ADMIN") 与权限字符串是否匹配
  • 是否设置了 SessionCreationPolicy.STATELESS
  • 请求是否被跨域或预检 OPTIONS 拦截

常见坑与排查

这部分很关键。很多“JWT 不生效”的问题,其实都集中在下面几类。

坑 1:登录成功,但访问接口仍然 403

常见原因:

  1. token 没带
  2. token 格式错了,没有 Bearer
  3. JWT 过滤器没执行
  4. 权限不匹配

比如你 token 里存的是:

["ADMIN"]

而接口配置是:

.requestMatchers("/api/admin/**").hasRole("ADMIN")

这会失败,因为 hasRole("ADMIN") 实际要匹配的是 ROLE_ADMIN

解决方式二选一:

  • token 中存 ROLE_ADMIN
  • 或者用 hasAuthority("ADMIN")

我个人建议统一走 ROLE_ 前缀,少绕弯。


坑 2:密码明明对,还是 BadCredentials

最常见原因是密码编码器不一致。

比如用户保存时用了 BCrypt:

passwordEncoder.encode("123456")

认证时却用明文比较,肯定失败。

要确保:

  • 存储密码时加密
  • 登录校验时用同一个 PasswordEncoder

坑 3:接口返回 401 和 403 分不清

这个区别必须知道:

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

简单理解:

  • 没登录/假 token:401
  • 已登录但不是管理员:403

如果项目里两个状态都返回 403,排查会很痛苦。建议自定义异常处理,明确区分。


坑 4:前端跨域预检请求被拦截

浏览器会先发一个 OPTIONS 预检请求。
如果这个请求没放行,前端可能看到的是跨域报错,而不是认证报错。

可以增加配置:

.authorizeHttpRequests(auth -> auth
    .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
    .requestMatchers("/auth/login").permitAll()
    .anyRequest().authenticated()
)

如果前后端分域部署,这一步很常见,我当时第一次接 SPA 项目时就在这里卡了半天。


坑 5:token 一改权限,老 token 还有效

这是 JWT 的典型问题。
因为服务端不存状态,所以已签发 token 在过期前通常仍可用。

解决思路:

  • access token 设短一点,比如 15~30 分钟
  • 配 refresh token 续签
  • 引入 token 黑名单
  • 用户表里增加 tokenVersion,签发时写入 claim,校验时比对

如果系统对“立即踢人下线”“立刻回收权限”要求很高,单纯 JWT 不够,需要配合服务端状态。


安全/性能最佳实践

教程跑通只是第一步,真正上线时,要把安全和性能一起考虑。

1. Access Token 短时效,必要时配 Refresh Token

推荐思路:

  • Access Token:15 分钟 ~ 2 小时
  • Refresh Token:7 天 ~ 30 天

这样即便 access token 泄露,风险窗口也有限。

2. 永远不要把敏感数据放进 JWT

不要把下面这些直接塞进 token:

  • 明文密码
  • 手机号、身份证号等高敏字段
  • 完整用户对象
  • 内部系统敏感配置

JWT 是签名防篡改,不是默认加密。
能被客户端拿到的东西,都默认可能被看到。

3. 强制 HTTPS

JWT 最怕的不是被“猜”,而是被“偷”。
如果你还在 HTTP 明文传输,再复杂的安全设计都很脆。

4. 密钥管理要正规

不要这样干:

jwt:
  secret: mysecret

也不要把生产密钥直接写死在 Git 仓库里。

更推荐:

  • 环境变量注入
  • 配置中心
  • KMS / Secret Manager
  • 定期轮换密钥

5. 过滤器里不要做过重逻辑

JWT 过滤器是每个请求都会走的地方。
所以不要在这里:

  • 查很多次数据库
  • 做复杂权限树计算
  • 远程调用用户中心

否则认证链会变成性能瓶颈。

6. 用 Method Security 做细粒度授权

除了 URL 规则,还可以直接在方法上做权限控制。

例如:

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/stats")
public Map<String, Object> stats() {
    return Map.of("pv", 1024);
}

这种方式在业务复杂时会更清晰,尤其是同一路径下不同操作权限不同时。


一个更完整的授权视角

项目做大后,认证和授权通常会演化成下面这样:

classDiagram
    class User {
        +String username
        +String password
        +boolean enabled
    }

    class Role {
        +String code
    }

    class Permission {
        +String code
    }

    class JWT {
        +sub
        +exp
        +authorities
    }

    User --> Role
    Role --> Permission
    JWT --> User

在简单系统里,你可以直接把角色放进 token。
在复杂系统里,可能会进一步拆成:

  • 用户
  • 角色
  • 权限点
  • 数据范围
  • 租户信息

这时候要权衡一点:token 放太多内容会臃肿,放太少又会频繁查库
我的建议是:

  • token 放“高频、稳定、必要”的最小信息
  • 复杂动态权限留给服务端实时判断

可以继续扩展的能力

本文示例是最小闭环,实际项目一般还会继续加这些内容:

1. 自定义未认证/无权限响应

让 401/403 返回统一 JSON,而不是默认错误页。

2. Refresh Token 机制

避免用户频繁重新登录。

3. 登出与黑名单

JWT 无状态不等于不能登出,只是要引入额外状态管理。

4. 基于数据库的用户、角色、权限模型

将内存用户替换为 MyBatis/JPA 查询。

5. 网关统一鉴权

如果是微服务架构,可以把 JWT 校验下沉到网关,业务服务只消费用户上下文。


常见边界条件

实战里你还要考虑这些边界:

  • 移动端长期登录:建议 refresh token 方案
  • 权限变更要实时生效:建议短 token + 版本号/黑名单
  • 单点登录(SSO):可考虑 OAuth2 / OIDC,而不是完全手写
  • 高安全后台:增加二次验证、设备绑定、IP 风险控制
  • 多租户系统:token 中最好明确租户标识,并在服务端二次校验

如果只是内部管理后台,这篇方案通常够用。
如果是面向公网、高并发、多终端的大系统,建议在 JWT 之上再补:

  • refresh token
  • 黑名单
  • 审计日志
  • 风控策略
  • 密钥轮换

总结

我们这篇文章从“前后端分离为什么常用 JWT”出发,完整走了一遍 Spring Boot 3 + Spring Security 6 的认证授权落地流程:

  1. 自定义 /auth/login 登录接口
  2. 使用 AuthenticationManager 校验用户名密码
  3. 登录成功后签发 JWT
  4. 通过自定义 JwtAuthenticationFilter 在每个请求中解析 token
  5. 将认证信息放入 SecurityContext
  6. 用 Spring Security 的 URL 规则或方法注解完成授权

如果你现在正准备把它用进项目,我给三个可执行建议:

  • 先跑通最小闭环:登录、带 token、访问受保护接口、验证角色控制
  • 统一权限命名规则:建议角色统一使用 ROLE_ 前缀,减少 403 迷惑问题
  • 别把 JWT 当成“纯无状态银弹”:权限实时失效、强制登出这些需求,往往仍需要服务端状态配合

最后给一个经验判断:

  • 中小型前后端分离项目:这套方案很好用
  • 需要标准化第三方登录/SSO:优先考虑 OAuth2/OIDC
  • 安全要求极高:JWT 只是基础设施,不是全部答案

如果你按本文代码搭起来,至少已经能清楚地回答三个问题:

  • token 在哪发?
  • token 在哪验?
  • 权限到底由谁判断?

这三个点一旦打通,Spring Security 就不再那么“黑盒”了。


分享到:

上一篇
《Java Web开发实战:基于Spring Boot与JWT实现前后端分离的登录鉴权与权限控制》
下一篇
《安卓逆向实战:基于 Frida 与 JADX 的登录接口参数签名分析与还原》