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

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

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

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

前后端分离项目里,登录鉴权几乎是每个团队都会自己“再做一遍”的模块。看起来像是老生常谈,但一旦上手到 Spring Boot 3 + Spring Security 6,很多人会发现:配置方式变了、过滤器链写法变了、JWT 接入点也和旧版本不一样了

这篇文章我不打算只讲概念,而是带你从一个常见场景出发,完整实现一套:

  • 用户登录获取 JWT
  • 前端携带 Token 访问受保护接口
  • Spring Security 6 解析并校验 Token
  • 基于角色做接口授权
  • 处理常见报错与排查路径

文章以“能跑起来”为目标,同时尽量讲清楚背后的原理和边界。


背景与问题

在传统服务端渲染时代,登录态通常依赖 Session + Cookie。但到了前后端分离架构,常见的情况是:

  • 前端是 Vue / React / 小程序 / App
  • 后端提供 REST API
  • 服务可能部署成多实例
  • 接口需要无状态化,方便扩缩容和网关转发

这时候,如果还强依赖 Session,就会遇到几个问题:

  1. 服务端要保存会话状态
  2. 多节点部署需要共享 Session
  3. 跨端访问时,Cookie 管理没那么自然
  4. 接口化场景下,更适合令牌方式认证

于是 JWT(JSON Web Token)就成了很常见的选择。

但 JWT 也不是“开箱即安全”,很多项目会踩这些坑:

  • 只会生成 Token,不会把它正确接到 Spring Security 过滤器链里
  • 忘了关闭 CSRF,导致 POST 请求 403
  • 角色前缀 ROLE_ 配错,结果授权一直失败
  • Token 过期、签名错误时,返回信息不明确
  • 把敏感信息塞进 JWT Payload,误以为“加密”了

所以,真正的实战关键不是“会不会生成 JWT”,而是:如何把 JWT 融入 Spring Security 6 的认证与授权流程


前置知识与环境准备

技术栈

本文示例使用:

  • JDK 17
  • Spring Boot 3.x
  • Spring Security 6
  • Maven
  • JWT:jjwt
  • 数据层为了聚焦鉴权流程,先用内存用户演示,可运行、易理解

Maven 依赖

先创建一个 Spring Boot 3 项目,加入以下依赖:

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

核心原理

先别急着写代码,先把流程捋顺。只要你理解了这条链路,Spring Security 的配置就不会显得“玄学”。

1. 登录与鉴权分成两件事

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

在 JWT 场景下:

  • 用户登录时,服务端校验账号密码
  • 校验通过后,签发 JWT 给前端
  • 前端以后每次请求都带上 JWT
  • 服务端解析 JWT,恢复用户身份和权限
  • Spring Security 决定该请求是否允许访问

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

JWT 的特点是:

  • 无状态:服务端不需要保存会话
  • 可携带用户标识与权限信息
  • 适合跨服务传递身份

但注意一个常被误解的点:

JWT 默认是“签名防篡改”,不是“加密防偷窥”。

也就是说,Payload 里的内容别人是能解码看到的,所以不要把密码、身份证号、银行信息这类敏感数据放进去

3. Spring Security 6 的关键位置

在 Spring Security 6 里,我们一般会做这些事:

  1. 配置 SecurityFilterChain
  2. 放行登录接口
  3. 对其他接口要求认证
  4. 自定义一个 JWT 过滤器
  5. 在过滤器里:
    • 读请求头 Authorization
    • 提取 Bearer Token
    • 校验签名和过期时间
    • 从 Token 中拿到用户名和角色
    • 构建 Authentication
    • 放进 SecurityContextHolder

这样后面的授权判断就能正常工作了。


整体流程图

先看系统的请求流转。

flowchart TD
    A[前端提交用户名密码] --> B[/api/auth/login]
    B --> C{账号密码是否正确}
    C -- 否 --> D[返回 401]
    C -- 是 --> E[生成 JWT]
    E --> F[前端保存 Token]
    F --> G[请求受保护接口]
    G --> H[Authorization: Bearer Token]
    H --> I[JWT 过滤器解析 Token]
    I --> J{Token 是否有效}
    J -- 否 --> K[返回 401]
    J -- 是 --> L[写入 SecurityContext]
    L --> M[进入控制器]
    M --> N{是否有访问权限}
    N -- 否 --> O[返回 403]
    N -- 是 --> P[返回业务数据]

再看一次更细一点的时序。

sequenceDiagram
    participant Client as 前端
    participant AuthController as 登录接口
    participant AuthManager as AuthenticationManager
    participant JwtUtil as JWT工具类
    participant JwtFilter as JWT过滤器
    participant Api as 业务接口

    Client->>AuthController: POST /api/auth/login
    AuthController->>AuthManager: 校验用户名密码
    AuthManager-->>AuthController: 认证成功
    AuthController->>JwtUtil: 生成Token
    JwtUtil-->>Client: 返回JWT

    Client->>JwtFilter: GET /api/user/profile + Bearer Token
    JwtFilter->>JwtUtil: 解析并校验Token
    JwtUtil-->>JwtFilter: 用户名、角色
    JwtFilter->>Api: 放行并附带认证信息
    Api-->>Client: 返回业务数据

实战代码(可运行)

下面给出一套最小可运行版本。为了让流程更清晰,我先用内存用户模拟数据库用户,等你理解之后,再替换成 UserDetailsService + MySQL 非常顺手。

1. 项目结构建议

src/main/java/com/example/demo
├── DemoApplication.java
├── config
│   └── SecurityConfig.java
├── controller
│   ├── AuthController.java
│   └── UserController.java
├── dto
│   ├── LoginRequest.java
│   └── LoginResponse.java
├── security
│   ├── JwtAuthenticationFilter.java
│   └── JwtUtil.java

2. application.yml

JWT 密钥一定要足够长。我建议至少 256 bit 对称密钥。

server:
  port: 8080

jwt:
  secret: "my-super-secret-key-my-super-secret-key-123456"
  expiration: 3600000

3. 启动类

package com.example.demo;

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

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

4. DTO

LoginRequest.java

package com.example.demo.dto;

import jakarta.validation.constraints.NotBlank;

public class LoginRequest {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    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.demo.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 工具类

JwtUtil.java

package com.example.demo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
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.util.Date;
import java.util.List;

@Component
public class JwtUtil {

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

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

    private SecretKey secretKey;

    @PostConstruct
    public void init() {
        byte[] keyBytes = secret.getBytes();
        this.secretKey = Keys.hmacShaKeyFor(keyBytes);
    }

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

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

        return Jwts.builder()
                .subject(userDetails.getUsername())
                .claim("roles", roles)
                .issuedAt(now)
                .expiration(expiryDate)
                .signWith(secretKey)
                .compact();
    }

    public Claims parseToken(String token) {
        return Jwts.parser()
                .verifyWith(secretKey)
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

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

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

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

这里我没有把密码或其他敏感字段放进 Token,只放了用户名和角色。大部分业务里,这就够用了。


6. JWT 过滤器

JwtAuthenticationFilter.java

package com.example.demo.security;

import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.User;
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.Collections;
import java.util.List;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull 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);

        try {
            Claims claims = jwtUtil.parseToken(token);
            String username = claims.getSubject();

            @SuppressWarnings("unchecked")
            List<String> roles = claims.get("roles", List.class);

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                List<SimpleGrantedAuthority> authorities = roles == null
                        ? Collections.emptyList()
                        : roles.stream().map(SimpleGrantedAuthority::new).toList();

                User userDetails = new User(username, "", authorities);

                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(userDetails, null, authorities);

                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            SecurityContextHolder.clearContext();
        }

        filterChain.doFilter(request, response);
    }
}

这段代码的核心作用就一句话:

把 JWT 中的信息恢复成 Spring Security 能识别的 Authentication


7. Security 配置

SecurityConfig.java

package com.example.demo.config;

import com.example.demo.security.JwtAuthenticationFilter;
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.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
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 UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
        return new InMemoryUserDetailsManager(
                User.withUsername("admin")
                        .password(passwordEncoder.encode("123456"))
                        .roles("ADMIN")
                        .build(),
                User.withUsername("user")
                        .password(passwordEncoder.encode("123456"))
                        .roles("USER")
                        .build()
        );
    }

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

    @Bean
    public DaoAuthenticationProvider authenticationProvider(
            UserDetailsService userDetailsService,
            PasswordEncoder passwordEncoder
    ) {
        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 SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .cors(Customizer.withDefaults())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/login").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated()
                )
                .authenticationProvider(authenticationProvider(userDetailsService(passwordEncoder()), passwordEncoder()))
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

这里有几个关键点:

  • SessionCreationPolicy.STATELESS:明确告诉系统不用 Session
  • csrf.disable():前后端分离、JWT 场景下通常关闭
  • addFilterBefore(...):让 JWT 过滤器在用户名密码过滤器之前执行
  • hasRole("ADMIN") 会匹配权限 ROLE_ADMIN

这也是很多人第一个会踩的坑:你以为你配置的是 ADMIN,实际上 Spring Security 底层比对的是 ROLE_ADMIN


8. 登录接口

AuthController.java

package com.example.demo.controller;

import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.LoginResponse;
import com.example.demo.security.JwtUtil;
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.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

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

    private final AuthenticationManager authenticationManager;
    private final JwtUtil jwtUtil;

    public AuthController(AuthenticationManager authenticationManager, JwtUtil jwtUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtUtil = jwtUtil;
    }

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

        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        String token = jwtUtil.generateToken(userDetails);

        return new LoginResponse(token);
    }
}

9. 受保护接口

UserController.java

package com.example.demo.controller;

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

import java.util.Map;

@RestController
public class UserController {

    @GetMapping("/api/user/profile")
    public Map<String, Object> profile(Authentication authentication) {
        return Map.of(
                "message", "这是用户信息接口",
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }

    @GetMapping("/api/admin/dashboard")
    public Map<String, Object> dashboard(Authentication authentication) {
        return Map.of(
                "message", "这是管理员面板接口",
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }
}

逐步验证清单

到这里代码已经齐了,下面一步一步测。

1. 登录获取 Token

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

成功返回类似:

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

2. 访问用户接口

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

3. 访问管理员接口

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

4. 使用普通用户登录再测管理员接口

如果你用 user / 123456 登录,再访问 /api/admin/dashboard,应该得到 403 Forbidden

这就说明两件事都生效了:

  • 认证成功:JWT 能识别用户身份
  • 授权生效:角色判断工作正常

用类图理解职责划分

如果你喜欢从结构上理解代码,可以看这个简单类图。

classDiagram
    class AuthController {
        +login(LoginRequest) LoginResponse
    }

    class SecurityConfig {
        +securityFilterChain(HttpSecurity) SecurityFilterChain
        +authenticationManager(AuthenticationConfiguration) AuthenticationManager
        +userDetailsService(PasswordEncoder) UserDetailsService
    }

    class JwtAuthenticationFilter {
        -JwtUtil jwtUtil
        +doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain)
    }

    class JwtUtil {
        +generateToken(UserDetails) String
        +parseToken(String) Claims
        +extractUsername(String) String
        +isTokenValid(String, UserDetails) boolean
    }

    AuthController --> JwtUtil
    SecurityConfig --> JwtAuthenticationFilter
    JwtAuthenticationFilter --> JwtUtil

常见坑与排查

这一节我尽量讲得“接地气”一点。很多问题看上去像配置错了,但其实都有固定排查路径。

1. 登录接口直接 403

现象

请求 /api/auth/login,还没进控制器就返回 403。

常见原因

  • 没关闭 CSRF
  • 登录接口没有 permitAll()

排查方法

检查 SecurityFilterChain

.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/auth/login").permitAll()
    .anyRequest().authenticated()
)

如果是前后端分离 + JWT,绝大多数情况下应该关闭 CSRF。


2. Token 带了,但接口还是 401

现象

前端明明带了 JWT,请求受保护接口还是未认证。

常见原因

  • Authorization 请求头没传
  • 前缀不是 Bearer
  • JWT 过滤器没有加入过滤器链
  • Token 已过期
  • 签名密钥不一致

建议排查顺序

  1. 浏览器开发者工具确认请求头是否真的带上了
  2. 确认格式是否为:
    Authorization: Bearer xxxxx
  3. 确认是否有:
    .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
  4. 打印过滤器日志,看是否进入过滤器
  5. 检查 Token 是否过期、是否来自当前环境

我当时第一次接 Spring Security 6 时,就是把过滤器写好了,但忘了加到过滤器链里,结果看了半天代码都没发现问题。


3. 明明有管理员角色,却总是 403

现象

认证成功,但访问管理员接口总被拒绝。

根因

多半是 ROLE_ 前缀问题。

比如:

  • 你配置 hasRole("ADMIN")
  • 但 Token 中存的是 ADMIN
  • Spring 期望的权限实际上是 ROLE_ADMIN

解决方式

要么统一使用 roles("ADMIN"),让 Spring 自动补前缀;
要么统一使用 hasAuthority("ROLE_ADMIN") 并手动存完整权限名。

本文里采用的是 Spring 默认习惯:

  • 用户构建时用 roles("ADMIN")
  • Token 中保存的是 ROLE_ADMIN
  • 授权时用 hasRole("ADMIN")

这一套最省心。


4. 密钥长度报错

现象

启动时报错,大意类似:

The specified key byte array is 该算法不够长

原因

HMAC SHA 算法对密钥长度有要求,太短会报错。

解决

jwt.secret 设置得足够长,至少 32 字节以上更稳妥。


5. 过滤器吞掉异常,调试困难

在示例里,我是这样写的:

} catch (Exception ex) {
    SecurityContextHolder.clearContext();
}

这在最小演示里没问题,但正式项目里建议:

  • 记录日志
  • 区分异常类型(过期、签名错误、格式错误)
  • 返回更明确的错误码

否则线上一旦“全是 401”,你会很难快速判断到底是过期还是签名问题。


安全/性能最佳实践

JWT 很方便,但不能因为方便就放松安全要求。这部分是实战里真正影响上线质量的地方。

1. Access Token 要短时效

很多项目喜欢把 Token 设成 7 天、30 天,图省事。我的建议是:

  • Access Token:15 分钟 ~ 2 小时
  • Refresh Token:单独设计、长期一些

本文为了演示配成了 1 小时,但生产上最好结合业务风险来定。

2. 不要把敏感信息放进 JWT

不要放这些:

  • 明文密码
  • 手机号完整信息
  • 身份证号
  • 银行卡号
  • 详细权限树数据

JWT 适合放“身份标识 + 少量授权信息”,不要变成“小型用户档案”。

3. 生产环境密钥不要写死在代码里

示例里为了演示放在配置文件里,但正式环境建议:

  • 环境变量
  • KMS / 密钥管理服务
  • 配置中心加密存储

至少不要把生产密钥直接提交到 Git 仓库。

4. 建议引入 Refresh Token 机制

单 JWT 方案有个痛点:

  • Token 太短,用户体验差
  • Token 太长,风险增大

常见折中方案:

  • 登录返回 Access Token + Refresh Token
  • Access Token 短期有效
  • Refresh Token 用于续签
  • Refresh Token 可落库并支持吊销

如果你的系统有“退出登录后立刻失效”的强需求,只靠无状态 JWT 往往不够,通常还要配合黑名单或 Refresh Token 存储。

5. 网关和服务间认证要区分场景

如果你是微服务架构,不要把“浏览器访问后端接口”和“服务间调用”混成一种认证方式。

常见拆分方式:

  • 用户访问:JWT
  • 服务间调用:内部 Token、OAuth2、mTLS 等

否则后面权限边界会越来越乱。

6. 控制 JWT 体积

JWT 每次请求都会带上,所以别塞太多内容。否则会带来:

  • 请求头膨胀
  • 网络开销增大
  • 网关限制触发
  • 性能浪费

通常保存:

  • sub:用户名 / 用户 ID
  • roles:角色列表
  • iat / exp:签发和过期时间

就已经够大多数接口用了。

7. 给未认证与未授权返回统一 JSON

默认的 Spring Security 报错返回,对前端并不总是友好。建议自定义:

  • AuthenticationEntryPoint:处理 401
  • AccessDeniedHandler:处理 403

这样前端更容易统一处理登录失效、权限不足等场景。

例如:

http.exceptionHandling(exception -> exception
    .authenticationEntryPoint((request, response, authException) -> {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":401,\"message\":\"未登录或Token无效\"}");
    })
    .accessDeniedHandler((request, response, accessDeniedException) -> {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":403,\"message\":\"无权限访问\"}");
    })
);

这一点在前后端联调时特别重要。


从内存用户切到数据库用户,怎么改?

很多人学完示例后,会卡在这一步:“能跑了,但我项目里是 MySQL 用户表,怎么接?”

核心只需要替换一层:UserDetailsService

大致思路是:

  1. 用户登录时,AuthenticationManager 调用你的 UserDetailsService
  2. 你去数据库查用户
  3. 把数据库用户封装成 UserDetails
  4. 返回给 Spring Security

伪代码如下:

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

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

        return org.springframework.security.core.userdetails.User
                .withUsername(user.getUsername())
                .password(user.getPassword())
                .authorities(user.getRoleCode())
                .disabled(!user.getEnabled())
                .build();
    }
}

然后在配置类里把内存用户替换掉即可。

注意两点:

  • 数据库里的密码必须是加密后的,比如 BCrypt
  • 权限字段要和你的授权表达式保持一致

边界条件:什么时候不建议自己手写 JWT?

这篇文章讲的是“自己搭一套轻量 JWT 鉴权”。但也要说清楚边界:

如果你的项目具备这些特点:

  • 单点登录(SSO)
  • 第三方登录集成较多
  • 多客户端统一身份中心
  • 多服务复杂权限模型
  • 需要 OAuth2 / OpenID Connect 标准化能力

那么建议优先考虑:

  • Spring Authorization Server
  • Keycloak
  • Auth0 / 企业统一身份平台

自己手写 JWT 方案适合:

  • 中小型业务系统
  • 单体或轻量微服务
  • 权限模型相对清晰
  • 团队想快速落地前后端分离鉴权

如果上来就是复杂 IAM 需求,手写方案后期维护成本会越来越高。


总结

我们这次完整走了一遍 Spring Boot 3 + Spring Security 6 + JWT 的前后端分离登录鉴权流程,核心脉络其实就三步:

  1. 登录时校验用户名密码
  2. 认证成功后签发 JWT
  3. 后续请求通过过滤器解析 JWT 并恢复认证信息

你真正需要记住的不是某个 API 名字,而是这几个关键点:

  • Spring Security 6 的入口是 SecurityFilterChain
  • JWT 的落点是自定义过滤器
  • 无状态认证要配置 SessionCreationPolicy.STATELESS
  • 前后端分离通常要关闭 CSRF
  • 角色权限最容易踩 ROLE_ 前缀坑

如果你准备把本文方案用于实际项目,我的建议是:

  • 先按本文最小示例跑通
  • 再替换成数据库用户
  • 然后补上统一异常处理
  • 最后根据业务引入 Refresh Token、登出失效、黑名单机制

这样推进最稳,也最不容易“越改越乱”。

如果只想一句话概括这套方案的落地原则,那就是:

让登录简单,让鉴权清晰,让 Token 足够轻,别把 JWT 用成万能状态仓库。

祝你这次别再被 401 和 403 折腾半天。


分享到:

上一篇
《自动化测试中的测试数据管理实战:构建稳定、可复用的中级测试体系》
下一篇
《安卓逆向实战:从 Frida 动态 Hook 到定位并绕过常见 App 签名校验逻辑》