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

《Java Web 开发中基于 Spring Boot + JWT 的登录鉴权与权限控制实战指南》

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

Java Web 开发中基于 Spring Boot + JWT 的登录鉴权与权限控制实战指南

很多 Java Web 项目做到登录这一步时,最容易先“跑起来”,然后再慢慢补安全和权限。但现实通常是:一旦接口增多、角色变复杂、前后端分离上线后,认证和授权问题会集中爆发。

这篇文章我不打算只讲概念,而是带你从一个典型的 Spring Boot 项目出发,把 登录、JWT 签发、请求鉴权、角色权限控制 一步步串起来。文章目标很明确:你看完后,能自己落地一套可运行的基础方案,也知道它的边界在哪,哪些坑要提前避开。


背景与问题

在传统 Java Web 开发里,登录态常常依赖 Session。它简单直接,但到了前后端分离、移动端、多服务调用的场景,Session 的问题会越来越明显:

  • 服务端要保存会话状态
  • 集群环境下要做 Session 共享
  • 前后端跨域时处理起来别扭
  • 接口调用更适合无状态认证

这时候 JWT(JSON Web Token)就很常见了。它的核心价值是:

  • 用户登录成功后,服务端签发一个 Token
  • 客户端后续每次请求带上 Token
  • 服务端通过验签和解析 Token 来识别用户身份
  • 可以结合角色、权限字段做接口访问控制

但很多人第一次做 JWT 鉴权时,容易踩下面这些坑:

  1. 只做了登录,没有做统一拦截
  2. Token 能解析,但没有做过期校验
  3. 角色信息放进 Token 后,改权限不生效
  4. 把 JWT 当成“绝对安全”方案
  5. 权限控制写死在 Controller 里,后面维护困难

所以我们这篇文章会重点解决两个问题:

  • 怎么让 Spring Boot 正确完成登录鉴权
  • 怎么把权限控制做得清晰、可维护

前置知识与环境准备

你需要具备的基础

本文默认你已经了解:

  • Spring Boot 基本项目结构
  • Maven 依赖管理
  • RESTful API 基础
  • Java 注解、过滤器、拦截器的基本概念

示例环境

本文示例基于:

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

整体方案概览

先看我们要实现的流程:

  1. 用户通过用户名密码登录
  2. 服务端校验成功后签发 JWT
  3. 客户端把 JWT 放到 Authorization: Bearer xxx
  4. 每次请求进入过滤器时校验 JWT
  5. 如果合法,就把用户身份和角色放入 Spring Security 上下文
  6. Controller 或方法级注解根据角色判断是否允许访问

核心原理

1. JWT 到底是什么

JWT 本质上是一个字符串,分成三段:

  • Header:声明类型和签名算法
  • Payload:保存用户数据,例如用户 ID、用户名、角色
  • Signature:签名,防止内容被篡改

结构如下:

header.payload.signature

注意一点:JWT 默认只是 Base64Url 编码,不是加密
也就是说,Payload 里的内容别人是可以解码看到的,只是不能随便篡改。

2. 认证与授权的区别

这是开发里非常容易混淆的点:

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

在本文方案中:

  • 用户登录校验用户名密码,属于认证
  • 根据 ROLE_ADMINROLE_USER 决定接口能否访问,属于授权

3. 为什么 Spring Security 适合做这件事

Spring Security 的优势在于它已经提供了成熟的安全链路:

  • 过滤器链拦截请求
  • SecurityContext 保存当前登录用户
  • 支持角色和权限判断
  • 支持注解方式控制访问

我们要做的,不是自己写一整套安全框架,而是把 JWT 接进 Spring Security 的流程里。


认证与授权流程图

flowchart TD
    A[用户提交用户名密码] --> B[登录接口校验账号]
    B --> C{校验是否成功}
    C -- 否 --> D[返回 401 或错误信息]
    C -- 是 --> E[生成 JWT]
    E --> F[客户端保存 Token]
    F --> G[请求受保护接口时携带 Authorization Bearer Token]
    G --> H[JWT 过滤器解析并验签]
    H --> I{Token 是否合法}
    I -- 否 --> J[返回 401]
    I -- 是 --> K[构建 Authentication]
    K --> L[写入 SecurityContext]
    L --> M[权限判断]
    M --> N[返回业务数据]

Spring Security 与 JWT 的执行时序

sequenceDiagram
    participant Client as 客户端
    participant LoginApi as 登录接口
    participant JwtUtil as JWT工具类
    participant Filter as JWT过滤器
    participant Security as Spring Security
    participant Controller as 业务接口

    Client->>LoginApi: POST /auth/login 用户名+密码
    LoginApi->>LoginApi: 校验用户名密码
    LoginApi->>JwtUtil: 生成JWT
    JwtUtil-->>Client: 返回token

    Client->>Filter: 请求 /admin/profile + Bearer token
    Filter->>JwtUtil: 解析并校验token
    JwtUtil-->>Filter: 用户信息/角色
    Filter->>Security: 设置Authentication到SecurityContext
    Security->>Controller: 进入接口并做权限校验
    Controller-->>Client: 返回结果

实战代码(可运行)

下面给你一套简化但完整的可运行示例。
为了聚焦 JWT 和权限控制,我们先不接数据库,直接用内存用户。你把它跑通后,再替换成 MyBatis/JPA 查询即可。


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

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

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

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 工具类

这个类负责:

  • 生成 Token
  • 解析 Token
  • 校验 Token 是否过期、签名是否正确
package com.example.demo.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;

@Component
public class JwtUtil {

    private final SecretKey secretKey;
    private final long expiration;

    public JwtUtil(@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()
                .setSubject(userDetails.getUsername())
                .claim("roles", roles)
                .setIssuedAt(now)
                .setExpiration(expiryDate)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

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

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

    public boolean isTokenValid(String token, UserDetails userDetails) {
        String username = getUsername(token);
        Date expirationDate = parseClaims(token).getExpiration();
        return username.equals(userDetails.getUsername()) && expirationDate.after(new Date());
    }
}

6. 自定义用户服务

为了演示权限控制,我们准备两个用户:

  • admin / 123456,角色 ADMIN
  • user / 123456,角色 USER
package com.example.demo.security;

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

import java.util.List;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final PasswordEncoder passwordEncoder;

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

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("admin".equals(username)) {
            return User.builder()
                    .username("admin")
                    .password(passwordEncoder.encode("123456"))
                    .authorities(List.of(
                            new SimpleGrantedAuthority("ROLE_ADMIN"),
                            new SimpleGrantedAuthority("ROLE_USER")
                    ))
                    .build();
        }

        if ("user".equals(username)) {
            return User.builder()
                    .username("user")
                    .password(passwordEncoder.encode("123456"))
                    .authorities(List.of(
                            new SimpleGrantedAuthority("ROLE_USER")
                    ))
                    .build();
        }

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

这里有个经验点:
如果你后面接数据库,不要直接把密码明文放库里,一定要走 BCryptPasswordEncoder


7. JWT 过滤器

过滤器的职责是:

  • 从请求头获取 Token
  • 验证 Token
  • 如果合法,就把用户信息放进 SecurityContext
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.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 JwtUtil jwtUtil;
    private final CustomUserDetailsService userDetailsService;

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

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

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

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

        String token = authHeader.substring(7);

        try {
            String username = jwtUtil.getUsername(token);

            if (StringUtils.hasText(username) &&
                SecurityContextHolder.getContext().getAuthentication() == null) {

                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                if (jwtUtil.isTokenValid(token, userDetails)) {
                    UsernamePasswordAuthenticationToken authentication =
                            new UsernamePasswordAuthenticationToken(
                                    userDetails,
                                    null,
                                    userDetails.getAuthorities()
                            );

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

                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        } catch (Exception e) {
            // 这里不要吞掉所有异常后静默成功
            // 示例中先放行,最终由后续鉴权失败返回 401
        }

        filterChain.doFilter(request, response);
    }
}

8. Spring Security 配置

这是整套方案的核心装配点。

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.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.Customizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

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

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

        return http.build();
    }

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

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

为什么是无状态

这行配置很关键:

.sessionCreationPolicy(SessionCreationPolicy.STATELESS)

它表示服务端不再依赖 Session 保存登录状态。
否则你明明想做 JWT,结果系统背后还偷偷用了 Session,行为就会混乱。


9. 登录接口

登录时我们还是借助 Spring Security 的认证管理器来校验用户名密码,不自己手搓。

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.web.bind.annotation.*;

@RestController
@RequestMapping("/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()
                )
        );

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

        return new LoginResponse(token);
    }
}

10. 受保护接口与权限控制

这里演示三个接口:

  • 所有登录用户可访问
  • 只有 USERADMIN 可访问
  • 只有 ADMIN 可访问
package com.example.demo.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 DemoController {

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

    @PreAuthorize("hasRole('USER')")
    @GetMapping("/user/hello")
    public Map<String, String> userHello() {
        return Map.of("message", "Hello, user");
    }

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/hello")
    public Map<String, String> adminHello() {
        return Map.of("message", "Hello, admin");
    }
}

11. 权限模型关系图

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

    class Role {
        +Long id
        +String roleCode
        +String roleName
    }

    class Permission {
        +Long id
        +String permCode
        +String permName
    }

    User "many" --> "many" Role : 拥有
    Role "many" --> "many" Permission : 包含

这个图对应真实项目里的常见设计:

  • 用户和角色多对多
  • 角色和权限多对多
  • JWT 里通常放用户标识和角色摘要
  • 细粒度权限建议在服务端动态判断,不要全部塞进 Token

逐步验证清单

项目跑起来之后,按这个顺序验证最稳。

1. 登录获取 Token

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

返回示例:

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

2. 带 Token 访问个人信息接口

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

3. 用 user 账号测试普通权限接口

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

再访问:

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

应该成功。

4. 用 user 账号访问管理员接口

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

应该返回 403。

这里顺便记住:

  • 401:你没登录,或 Token 无效
  • 403:你登录了,但没权限

这两个状态码混淆是排查权限问题时最常见的障碍之一。


常见坑与排查

这部分我想讲得更接地气一点,因为真正让人卡住的通常不是“不会写”,而是“为什么明明写了却不生效”。

坑 1:角色名没加 ROLE_ 前缀

你写了:

new SimpleGrantedAuthority("ADMIN")

但注解里写的是:

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

实际上 hasRole('ADMIN') 底层会找 ROLE_ADMIN
所以要么你权限里存 ROLE_ADMIN,要么改成 hasAuthority('ADMIN')

这是我见过最多的坑,没有之一。


坑 2:每次加载用户都重新加密密码,导致登录失败

示例里为了简化写成:

.password(passwordEncoder.encode("123456"))

但如果你真实项目里从数据库查出的是已经加密过的密码,再 encode 一次就完了。

正确做法是:

  • 数据库存密文
  • loadUserByUsername 直接返回数据库里的密文
  • 登录时由 PasswordEncoder.matches() 比较

坑 3:Token 里有角色,但服务端加载的权限变了

有些人喜欢把完整权限列表塞到 JWT 里,感觉这样就不用查数据库了。
短期看很爽,长期很容易埋坑:

  • 用户角色变更后,旧 Token 里还是旧权限
  • 管理员刚被降权,旧 Token 仍然能访问高权限接口

我的建议是:

  • Token 中放 用户 ID、用户名、角色摘要
  • 关键权限仍从服务端动态获取
  • 或者配合短过期时间 + 刷新机制

坑 4:过滤器执行了,但接口还是 401

通常排查这几步:

  1. 请求头是不是 Authorization
  2. 值是不是以 Bearer 开头
  3. Token 是否被前端多带了引号
  4. 过滤器有没有成功把 Authentication 放进 SecurityContextHolder
  5. requestMatchers("/auth/login").permitAll() 是否配置正确
  6. 有没有多个 SecurityFilterChain 互相影响

如果你想快速确认过滤器是否生效,可以先打日志:

System.out.println("JWT Filter executed");

虽然土,但调试阶段很直接。


坑 5:明明是权限不足,却返回 401

这种情况往往是因为:

  • 没有正确设置认证对象
  • 或异常处理器把所有安全异常都统一返回 401

记住标准语义:

  • 未认证:401
  • 已认证但无权限:403

生产项目最好自定义 AuthenticationEntryPointAccessDeniedHandler,把这两个场景清晰区分开。


坑 6:JWT 密钥长度不够

如果你用 HS256,密钥太短会报错。
像本文示例里写了至少 32 字节的字符串,就是为了满足要求。

如果线上你随便写个:

jwt.secret=abc123

大概率会直接启动或运行时报异常。


安全最佳实践

JWT 很方便,但不要神化它。它只是认证载体,不是安全万能药。

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

不要把这些内容放进 Payload:

  • 明文密码
  • 手机号完整信息
  • 银行卡信息
  • 身份证号
  • 过多内部权限细节

因为 JWT 可以被解码看见。


2. Token 过期时间不要太长

如果你的访问 Token 一发就是 7 天甚至 30 天,一旦泄露,风险很大。

更稳妥的做法:

  • Access Token:15 分钟 ~ 2 小时
  • Refresh Token:7 天 ~ 30 天
  • Access Token 短期有效
  • Refresh Token 用于换新 Token

如果只是中后台管理系统,通常 1~2 小时已经够用了。


3. 使用 HTTPS

这个很多人知道,但上线时还是会“先图省事”。
如果没有 HTTPS,Token 在传输过程中就可能被窃听。
JWT 再“无状态”、签名再“安全”,也扛不住你明文传输。


4. 做登出与 Token 失效控制

JWT 最大的一个现实问题是:签发后天然难以主动失效

可选方案有:

  • 使用短过期时间
  • 维护 Token 黑名单
  • 在 Redis 中保存用户 Token 版本号
  • 用户修改密码/管理员禁用账号时,提升版本号,让旧 Token 失效

如果你的业务对“即时下线”要求很高,纯 JWT 无状态方案通常要配合 Redis 一起做。


5. 区分认证失败与鉴权失败

建议统一返回结构,但语义要清楚:

  • Token 缺失、过期、非法:401
  • 用户身份合法但权限不足:403

这会极大降低前后端联调成本。


性能最佳实践

JWT 常被说成“无状态、性能好”,但性能好不等于你可以随便用。

1. Token 不要塞太多字段

JWT 不是缓存对象。
放太多内容会导致:

  • 请求头变大
  • 网络开销增加
  • 解析成本上升

通常保留这些就够了:

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

2. 热点权限可做缓存,但别失控

真实项目里,如果每次请求都查数据库拿权限,压力会明显上升。
可选策略:

  • 用户权限放 Redis
  • 设置合理 TTL
  • 用户权限变更时主动删除缓存

但缓存越多,权限一致性就越复杂。
所以别一上来就“全量缓存”,先压测、再决定。


3. 过滤器里避免重逻辑

JWT 过滤器是每个请求都会走的地方。
我一般会避免在里面做这些事:

  • 大量数据库查询
  • 复杂对象拼装
  • 额外远程调用

过滤器只做必要认证,业务逻辑下沉到服务层。


真实项目中的推荐演进路径

如果你现在是从 0 到 1 搭建,可以按这个顺序演进:

阶段 1:先跑通基础认证

  • 登录接口签发 JWT
  • 过滤器统一验签
  • 接口基于角色做访问控制

阶段 2:接入数据库用户体系

  • 用户表
  • 角色表
  • 权限表
  • 用户角色关联
  • 角色权限关联

阶段 3:做细粒度权限控制

比如:

  • system:user:list
  • system:user:add
  • system:user:delete

这时可以用:

@PreAuthorize("hasAuthority('system:user:list')")

阶段 4:补齐工程化能力

  • 统一异常处理
  • 登录失败审计
  • Token 刷新
  • 黑名单机制
  • 权限缓存
  • 操作日志
  • 安全监控

什么时候不适合只用 JWT

边界条件也得说清楚,不然文章就容易变成“JWT 万能论”。

以下场景,纯 JWT 方案往往不够:

  1. 必须支持强制实时下线
  2. 权限变更要求立即全局生效
  3. 高安全场景要求设备级会话管理
  4. 需要非常细粒度的动态授权判断

这时更常见的做法是:

  • JWT + Redis
  • 或 Session/Token 混合方案
  • 或统一认证中心(如 OAuth2 / OIDC)

所以你要把 JWT 看成一种工程方案,而不是宗教信仰。


总结

这篇文章我们从实战角度,把 Spring Boot + JWT 的登录鉴权和权限控制完整走了一遍,核心链路是:

  • 用户登录
  • 服务端签发 JWT
  • 请求进入过滤器验签
  • 将用户信息放入 SecurityContext
  • 通过 @PreAuthorize 做角色/权限控制

如果你现在要落地,我建议直接按下面这个最小可执行方案开始:

  1. 先用本文示例跑通登录和接口鉴权
  2. 把内存用户替换成数据库用户
  3. 把角色控制扩展成权限点控制
  4. 加上统一 401/403 返回
  5. 根据业务决定是否接入 Redis 做 Token 失效管理

最后给一个很实用的判断标准:

  • 中小型前后端分离项目:Spring Boot + JWT 足够好用
  • 需要即时失效、复杂会话治理的项目:JWT 需要配合 Redis 或认证中心
  • 高安全后台系统:不要只想着“能跑通”,要优先考虑失效机制、审计和权限一致性

如果你把本文代码跑通,再往数据库权限模型扩一层,基本就已经具备在实际项目里独立搭建登录鉴权模块的能力了。


分享到:

上一篇
《分布式架构中基于 Saga 模式的分布式事务实战:从一致性设计到失败补偿落地》
下一篇
《Spring Boot 中基于 Redis 与 AOP 实现接口幂等控制的实战指南》