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

《Java Web 开发实战:基于 Spring Boot + JWT 的用户认证与权限控制设计与落地》

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

Java Web 开发实战:基于 Spring Boot + JWT 的用户认证与权限控制设计与落地

在 Java Web 项目里,用户认证和权限控制几乎是绕不开的基础设施。很多团队一开始会直接“先能用再说”:登录成功后把用户信息放 Session,接口里手写 if-else 判断角色。项目小的时候问题不大,一旦前后端分离、服务拆分、客户端变多,这套方案很快就会显得笨重。

这篇文章我换一个更偏“架构落地”的角度来讲:为什么要用 Spring Boot + JWT、它的边界在哪里、如何设计一套可运行、可维护、可扩展的认证与授权机制。我会带你从背景问题、核心原理、方案取舍,一直走到完整代码实现和常见排查。


背景与问题

先看一个很常见的业务场景:

  • 用户通过用户名密码登录
  • 登录后访问个人资料接口
  • 管理员可以访问后台管理接口
  • 普通用户只能访问业务接口
  • 前后端分离,前端通过 Authorization: Bearer xxx 携带登录态
  • 后续可能还会有小程序、移动端、内部调用等接入

如果继续用传统 Session,会遇到这些问题:

  1. 扩展性差
    服务横向扩容后,需要共享 Session,通常得引入 Redis 或粘性会话。

  2. 前后端分离适配不自然
    浏览器 Cookie + Session 当然能做,但对多端接入不够统一。

  3. 服务间传递用户身份麻烦
    微服务或网关场景下,Session 状态同步会越来越复杂。

  4. 权限逻辑分散
    认证、角色判断、资源访问控制容易散落在 Controller 和 Service 中。

所以很多团队会采用:

  • Spring Security 负责认证授权框架
  • JWT(JSON Web Token) 负责无状态身份凭证
  • Spring Boot 负责快速集成与工程化落地

但这里有个很重要的现实问题:JWT 不是银弹
它解决的是“无状态认证凭证”的问题,不是自动解决所有安全问题。比如注销、踢人下线、权限变更实时生效,这些都需要额外设计。


方案概览:我们到底要落地什么

本文给出的目标架构如下:

  • 用户登录时,校验用户名密码
  • 校验通过后签发 JWT
  • JWT 中保存用户标识、用户名、角色等必要信息
  • 请求进入系统时,通过过滤器解析 JWT
  • 如果 Token 合法,则把用户身份放入 Spring Security 上下文
  • 接口通过角色/权限表达式做访问控制
  • 对异常、过期、伪造 Token 做统一返回

认证与授权流程图

flowchart LR
    A[用户登录 /auth/login] --> B[校验用户名密码]
    B -->|成功| C[签发 JWT]
    B -->|失败| D[返回 401]
    C --> E[客户端保存 Token]
    E --> F[访问受保护接口]
    F --> G[JWT 过滤器解析 Token]
    G -->|合法| H[写入 SecurityContext]
    G -->|非法或过期| I[返回 401]
    H --> J[Spring Security 鉴权]
    J -->|通过| K[执行 Controller]
    J -->|拒绝| L[返回 403]

核心模块拆分

classDiagram
    class AuthController {
      +login(LoginRequest)
      +profile()
      +admin()
    }

    class JwtTokenProvider {
      +generateToken(username, roles)
      +parseToken(token)
      +validateToken(token)
    }

    class JwtAuthenticationFilter {
      +doFilterInternal(req, resp, chain)
    }

    class CustomUserDetailsService {
      +loadUserByUsername(username)
    }

    class SecurityConfig {
      +securityFilterChain(http)
      +passwordEncoder()
      +authenticationManager(config)
    }

    class UserRepository {
      +findByUsername(username)
    }

    AuthController --> JwtTokenProvider
    JwtAuthenticationFilter --> JwtTokenProvider
    JwtAuthenticationFilter --> CustomUserDetailsService
    CustomUserDetailsService --> UserRepository
    SecurityConfig --> JwtAuthenticationFilter

方案对比与取舍分析

在正式写代码之前,先把常见方案说透,避免“会写但不知道为什么这么写”。

1. Session vs JWT

方案优点缺点适用场景
Session简单、服务端易控制分布式扩展麻烦、跨端不自然单体后台系统
JWT无状态、适合前后端分离、多端统一注销难、Token 泄露风险大、过期策略要设计API 服务、网关场景

2. 只做角色控制 vs 角色 + 权限点

方案优点缺点
只做角色(ROLE_ADMIN)简单直接粒度粗,后期容易失控
角色 + 权限点(user:read / user:delete)精细化、可扩展设计和维护成本更高

在中型项目里,我更建议:

  • 初期至少支持角色控制
  • 如果后台操作复杂,再逐步演进到“角色 + 权限点”

3. Token 里放多少信息

一个常见误区是把用户完整信息都塞到 JWT 中。
我通常建议只放:

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

不要放:

  • 手机号
  • 邮箱
  • 敏感业务字段
  • 会频繁变化的权限明细

因为 JWT 一旦签发,在过期前通常是不可变的。你放进去的信息越多,越容易出现“Token 里还是旧数据”的问题。


核心原理

JWT 的结构一般是三段:

header.payload.signature

例如:

  • Header:声明签名算法,如 HS256
  • Payload:用户标识、角色、过期时间等
  • Signature:用密钥对前两部分签名,防止篡改

请求链路时序

sequenceDiagram
    participant Client as 客户端
    participant API as Spring Boot API
    participant Filter as JWT过滤器
    participant Security as Spring Security
    participant Controller as Controller

    Client->>API: POST /auth/login 用户名密码
    API->>Security: 认证
    Security-->>API: 认证成功
    API-->>Client: 返回 JWT

    Client->>API: GET /api/profile Authorization: Bearer Token
    API->>Filter: 进入过滤器
    Filter->>Filter: 解析并验证 JWT
    Filter->>Security: 设置 Authentication
    Security->>Controller: 鉴权通过后放行
    Controller-->>Client: 返回业务数据

认证与授权的边界

这是很多初学者容易混淆的地方:

  • 认证 Authentication:你是谁
  • 授权 Authorization:你能做什么

对应到本文设计:

  • 登录校验用户名密码,是认证
  • @PreAuthorize("hasRole('ADMIN')"),是授权

JWT 更多是为认证结果提供一个“可携带、可验证”的凭证;真正的授权逻辑,还是要靠 Spring Security 和你的角色/权限模型。


实战代码(可运行)

下面给出一个可运行的 Spring Boot 3 + Spring Security 6 示例。为方便演示,我使用内存用户仓库,但代码结构已经按真实项目拆好了,后续替换数据库非常顺手。

项目依赖

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>springboot-jwt-demo</artifactId>
    <version>1.0.0</version>

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

    <properties>
        <java.version>17</java.version>
        <jjwt.version>0.12.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>

配置文件

src/main/resources/application.yml

server:
  port: 8080

jwt:
  secret: 12345678901234567890123456789012
  expiration: 3600000

这里的 secret 只是演示。真实环境请使用高强度随机密钥,至少 256 bit。


启动类

DemoApplication.java

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

用户模型与仓库

AppUser.java

package com.example.demo.user;

import java.util.Set;

public class AppUser {
    private Long id;
    private String username;
    private String password;
    private Set<String> roles;

    public AppUser(Long id, String username, String password, Set<String> roles) {
        this.id = id;
        this.username = username;
        this.password = password;
        this.roles = roles;
    }

    public Long getId() {
        return id;
    }

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }

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

UserRepository.java

package com.example.demo.user;

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

import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;

@Repository
public class UserRepository {

    private final PasswordEncoder passwordEncoder;
    private final Map<String, AppUser> users = new HashMap<>();

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

    @PostConstruct
    public void init() {
        users.put("admin", new AppUser(
                1L,
                "admin",
                passwordEncoder.encode("123456"),
                Set.of("ADMIN", "USER")
        ));

        users.put("tom", new AppUser(
                2L,
                "tom",
                passwordEncoder.encode("123456"),
                Set.of("USER")
        ));
    }

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

UserDetailsService

CustomUserDetailsService.java

package com.example.demo.security;

import com.example.demo.user.AppUser;
import com.example.demo.user.UserRepository;
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 UserRepository userRepository;

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

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

        return new User(
                user.getUsername(),
                user.getPassword(),
                user.getRoles().stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                        .collect(Collectors.toSet())
        );
    }
}

JWT 工具类

JwtTokenProvider.java

package com.example.demo.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.Component;

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

@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) {
        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 String getUsernameFromToken(String token) {
        return parseClaims(token).getSubject();
    }

    public boolean validateToken(String token) {
        parseClaims(token);
        return true;
    }

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

JWT 过滤器

JwtAuthenticationFilter.java

package com.example.demo.security;

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.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 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 authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);

            try {
                if (jwtTokenProvider.validateToken(token)
                        && SecurityContextHolder.getContext().getAuthentication() == null) {

                    String username = jwtTokenProvider.getUsernameFromToken(token);
                    UserDetails userDetails = userDetailsService.loadUserByUsername(username);

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

                    authentication.setDetails(
                            new WebAuthenticationDetailsSource().buildDetails(request)
                    );

                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            } catch (Exception ex) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.setContentType("application/json;charset=UTF-8");
                response.getWriter().write("{\"code\":401,\"message\":\"Token 无效或已过期\"}");
                return;
            }
        }

        filterChain.doFilter(request, response);
    }
}

安全配置

SecurityConfig.java

package com.example.demo.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.*;
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.*;
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("/auth/login").permitAll()
                    .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(ex -> ex
                    .authenticationEntryPoint((request, response, authException) -> {
                        response.setStatus(401);
                        response.setContentType("application/json;charset=UTF-8");
                        response.getWriter().write("{\"code\":401,\"message\":\"未认证或认证已失效\"}");
                    })
                    .accessDeniedHandler((request, response, accessDeniedException) -> {
                        response.setStatus(403);
                        response.setContentType("application/json;charset=UTF-8");
                        response.getWriter().write("{\"code\":403,\"message\":\"无权限访问\"}");
                    })
            );

        return http.build();
    }

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

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

DTO

LoginRequest.java

package com.example.demo.auth;

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.auth;

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

控制器

AuthController.java

package com.example.demo.auth;

import com.example.demo.security.JwtTokenProvider;
import jakarta.validation.Valid;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.User;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

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

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

        String token = jwtTokenProvider.generateToken((User) authentication.getPrincipal());
        return new LoginResponse(token);
    }

    @GetMapping("/api/profile")
    public Map<String, Object> profile(@AuthenticationPrincipal User user) {
        return Map.of(
                "username", user.getUsername(),
                "authorities", user.getAuthorities()
        );
    }

    @GetMapping("/api/admin")
    @org.springframework.security.access.prepost.PreAuthorize("hasRole('ADMIN')")
    public Map<String, Object> admin() {
        return Map.of(
                "message", "只有 ADMIN 角色可以访问这里"
        );
    }
}

运行与验证

启动项目后,按下面顺序测试。

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/profile' \
  -H 'Authorization: Bearer 你的token'

3. 访问管理员接口

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

如果使用 tom 登录,则访问 /api/admin 会返回 403。


容量与演进思考

如果你的项目只是内部管理后台,上面这套方案已经够用。
但如果进入更真实的线上环境,架构上需要提前考虑下面几点。

1. 单体应用阶段

  • JWT 签发与验证都在同一个应用内
  • 角色信息放 Token 中
  • 接口通过 Spring Security 注解控制

这个阶段成本低、交付快。

2. 多服务阶段

当服务拆分后,会出现新问题:

  • 每个服务都要验签吗?
  • 角色权限变更如何同步?
  • 网关是否做统一认证?

比较常见的设计是:

  • 网关统一做 Token 基础校验
  • 业务服务做二次鉴权
  • 用户核心信息通过请求头向下游透传,但要注意防伪造
  • 内网服务间调用建议再加签名或 mTLS,不要只信任头信息

3. 权限模型演进

早期:

  • 角色:ADMIN / USER

中期:

  • 菜单权限
  • 按钮权限
  • 资源权限
  • 数据权限

这时不建议把所有细权限都直接塞到 JWT。更合理的方式是:

  • Token 中只保存用户身份与基础角色
  • 权限明细从缓存或权限服务加载
  • 做本地短时缓存,兼顾性能与实时性

常见坑与排查

这一节我尽量讲得“像真实开发现场一点”。有些问题不是不会写,而是写完了发现为什么总不对。

1. 明明登录成功了,访问接口还是 401

现象

  • /auth/login 正常返回 Token
  • 带 Token 访问 /api/profile 却返回 401

排查顺序

  1. 确认请求头是否正确
    必须是:
Authorization: Bearer xxxxx

不是:

authorization: xxxxx
token: xxxxx
Bearer: xxxxx
  1. 看过滤器是否执行
    JwtAuthenticationFilter 中打印日志,确认请求是否进入过滤器。

  2. 看 Token 是否过期
    如果机器时间不一致,也可能导致误判过期。

  3. SecurityContextHolder 是否成功写入认证信息
    如果没写进去,后面的 Spring Security 仍然认为你未登录。

一个常见原因

Token 解析成功了,但 loadUserByUsername 查不到用户,最后照样失败。
这在“用户被删除但旧 Token 还在”时很常见。


2. 返回 403,不是 401

区别

  • 401:你没通过认证
  • 403:你已经认证了,但没有权限

典型原因

你登录的是 tom,只有 USER 角色,却访问了:

@PreAuthorize("hasRole('ADMIN')")

这不是认证失败,而是授权失败,所以返回 403 是对的。


3. hasRole('ADMIN') 总是不生效

因为 Spring Security 对角色默认有前缀约定:

  • hasRole("ADMIN") 实际匹配的是 ROLE_ADMIN

所以你在 GrantedAuthority 里要写:

new SimpleGrantedAuthority("ROLE_ADMIN")

而不是:

new SimpleGrantedAuthority("ADMIN")

这个坑我自己也踩过,第一次看接口一直 403,会怀疑半天表达式,最后发现是角色前缀没对上。


4. JWT secret 太短,启动或签发时报错

如果使用 HS256,密钥长度不能太短。
123456 这种在新版本 JJWT 中通常会直接报错。

建议:

  • 用随机生成的 32 字节以上字符串
  • 生产环境放到环境变量或密钥管理系统中
  • 不要硬编码在代码仓库里

5. 注销登录怎么做?

这是 JWT 方案最经典的问题。

因为 JWT 是无状态的,服务端通常不保存会话,所以“签发出去的 Token”在过期前原则上仍然可用。

常见处理方式有三种:

方案 A:短有效期 Token

  • Access Token 15~30 分钟
  • 到期后重新刷新

优点:简单
缺点:不能做到即时失效

方案 B:黑名单

  • 注销时把 Token 或 jti 放进 Redis 黑名单
  • 每次请求校验是否命中黑名单

优点:能主动失效
缺点:重新引入状态管理成本

方案 C:Token 版本号

  • 用户表增加 tokenVersion
  • JWT 里带版本号
  • 验证时比对数据库或缓存中的最新版本

优点:适合踢人下线、密码修改后强制失效
缺点:需要额外查询或缓存

在中型业务系统中,我更推荐:

  • 短期 Access Token + Refresh Token
  • 关键场景叠加 Redis 黑名单或版本号机制

安全/性能最佳实践

这一节是“上线前一定要看”的部分。代码能跑,不代表架构真的稳。

安全最佳实践

1. 永远走 HTTPS

JWT 本身只是签名,不是加密。
如果走 HTTP,Token 被抓包就等于身份被盗用。

2. 密钥不要硬编码

建议使用:

  • 环境变量
  • Docker Secret
  • KMS / Vault 等密钥管理服务

3. Token 不要存敏感信息

JWT 默认可被 Base64 解码看到 Payload。
不要把手机号、身份证号、银行卡号等敏感数据放进去。

4. 设定合理过期时间

建议分层设计:

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

如果 Access Token 设置成 7 天,看起来省事,实际上泄露风险很高。

5. 防重放与设备管理

如果业务敏感,可以在 Token 中加入:

  • jti 唯一 ID
  • deviceId
  • clientType

再结合 Redis 做在线会话管理。

6. 密码必须加密存储

一定用:

  • BCrypt
  • Argon2
  • PBKDF2

不要自己手写 MD5、SHA1 这类老方案。


性能最佳实践

1. JWT 验签虽然快,但不是零成本

在高并发下,每次请求都做 Token 解析和签名校验,仍然有 CPU 开销。
如果网关层已经做过基础校验,业务服务可以按场景权衡是否重复做完整校验。

2. 权限不要每次都查数据库

更合理的做法:

  • 用户基础身份来自 JWT
  • 权限数据放 Redis 或本地缓存
  • 设置短 TTL,并在权限变更时主动清理

3. 过滤器里少做重操作

JwtAuthenticationFilter 里最好只做:

  • 提取 Token
  • 验签
  • 构造认证对象

不要在里面堆复杂业务逻辑,否则链路抖动会很明显。

4. 统一异常响应,便于观测

认证失败、授权失败、Token 过期、签名错误,最好统一成结构化响应,并打出可检索日志。
这样线上排障效率会高很多。


可落地的增强方案

如果你准备把这套方案真正用到生产环境,我建议进一步补上下面几块。

1. 引入 Refresh Token

当前示例只有一个 Access Token,适合教学和简单系统。
生产中更推荐双 Token 机制:

  • Access Token:短期有效
  • Refresh Token:专门换新 Access Token

2. 引入用户状态校验

比如用户是否:

  • 被禁用
  • 被锁定
  • 被删除
  • 被强制下线

这些状态最好在认证链路中明确处理,而不是只靠 Token 是否合法。

3. 引入细粒度权限模型

例如:

  • user:list
  • user:create
  • user:delete

然后在接口上写:

@PreAuthorize("hasAuthority('user:delete')")

这样比“全靠角色判断”更适合复杂后台。

4. 接入审计日志

至少记录:

  • 登录成功/失败
  • 权限拒绝
  • Token 刷新
  • 注销
  • 异常访问来源 IP

当安全事件发生时,这些日志真的很关键。


总结

如果用一句话概括这套设计:

Spring Boot + Spring Security + JWT 的组合,适合构建前后端分离、无状态、易扩展的认证与权限控制体系。

但真正落地时,要把这几个事实想清楚:

  1. JWT 解决的是身份凭证传递,不是全部安全问题
  2. 认证和授权必须分层设计
  3. 角色模型先简单,权限模型逐步演进
  4. 生产环境一定补上过期、刷新、注销、黑名单或版本控制机制
  5. 安全性和可运维性,跟代码实现同样重要

如果你现在要在项目里开工,我建议按这个顺序推进:

  • 第一步:先落地本文示例的基础认证链路
  • 第二步:加上方法级权限控制
  • 第三步:补 Refresh Token 和注销机制
  • 第四步:把权限数据从硬编码迁移到数据库 + 缓存
  • 第五步:接入审计日志和统一监控

这样做的好处是:不是一开始就做得很重,而是让系统能随着业务复杂度自然演进。

如果你的场景是内部中后台、单体或轻量服务,这套方案已经足够实用;
如果你要做高安全、多终端、强实时权限控制系统,那么 JWT 仍然可用,但一定要搭配缓存、黑名单、会话管理和更严格的密钥策略一起上。


分享到:

下一篇
《Web逆向实战:中级开发者如何定位并复现前端签名算法实现接口自动化调用》