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

《Java Web开发实战:基于Spring Boot与JWT实现中后台系统的登录鉴权与权限控制》

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

背景与问题

中后台系统的登录鉴权,看起来像个“老生常谈”的话题,但真到项目里,问题往往不是“能不能登录”,而是:

  • 怎么让前后端分离场景下的认证流程更顺滑?
  • 怎么避免把权限判断写得到处都是 if
  • JWT 明明很方便,为什么上线后经常遇到“登录状态失效”“权限不生效”“Token 无法撤销”?
  • Spring Boot 项目里,认证、授权、接口保护、角色权限,怎样组织才不乱?

我自己做中后台系统时,最容易踩的坑就是:一开始只想着“登录成功返回一个 token”,等菜单权限、按钮权限、角色变更、Token 刷新、接口排查这些需求一起上来,代码就迅速失控。

所以这篇文章不只讲“怎么写”,更重点讲:如何用 Spring Boot + JWT 搭一个结构清晰、方便扩展的鉴权架构。我们会从架构分层、核心原理、可运行代码、排查方式到安全和性能实践,一次串起来。


方案目标与架构思路

在中后台系统里,一个相对稳妥的目标通常是:

  1. 用户登录后拿到 JWT
  2. 前端请求时携带 JWT
  3. 后端统一解析 Token,建立当前用户上下文
  4. 接口级做角色/权限校验
  5. 业务侧尽量少关心底层鉴权细节

从架构上看,可以拆成 4 层:

  • 认证层:校验用户名密码,签发 JWT
  • 令牌层:负责 JWT 生成、解析、校验、过期判断
  • 鉴权层:拦截请求,提取用户身份,写入上下文
  • 授权层:基于角色、权限点控制接口访问

下面这张图可以先把整体流程看清楚。

flowchart TD
    A[前端登录请求 /auth/login] --> B[Spring Boot 登录接口]
    B --> C[校验用户名密码]
    C --> D[生成 JWT]
    D --> E[返回 accessToken]
    E --> F[前端存储 Token]
    F --> G[访问受保护接口]
    G --> H[JWT 过滤器]
    H --> I{Token 是否有效}
    I -- 否 --> J[返回 401 Unauthorized]
    I -- 是 --> K[解析用户ID/角色/权限]
    K --> L[写入 SecurityContext]
    L --> M[控制器/方法权限校验]
    M --> N{是否有权限}
    N -- 否 --> O[返回 403 Forbidden]
    N -- 是 --> P[返回业务数据]

方案对比与取舍分析

在中后台系统里,常见认证方案主要有三类:

优点

  • 服务端容易控制会话
  • 传统 MVC 项目接入简单

缺点

  • 前后端分离、多端接入不够灵活
  • 分布式部署通常要引入 Session 共享

2. JWT 无状态认证

优点

  • 天然适合前后端分离
  • 服务端无需保存登录态
  • 横向扩展友好

缺点

  • Token 撤销困难
  • 权限变更不能立即生效,除非增加黑名单/版本控制
  • 设计不好容易把敏感信息塞进 Token

3. OAuth2 / OIDC

优点

  • 适合统一身份中心、第三方登录、单点登录
  • 标准成熟

缺点

  • 对很多普通中后台系统来说偏重
  • 接入和运维复杂度更高

这篇文章的取舍

这里选的是:Spring Boot + Spring Security + JWT + 基于角色/权限的授权模型

适用边界:

  • 前后端分离的后台管理系统
  • 用户规模中小到中等
  • 权限模型以 RBAC 为主
  • 没有特别复杂的 SSO / 第三方授权要求

如果你的系统有以下特征,就要进一步扩展:

  • 需要统一认证中心:考虑 OAuth2 / OIDC
  • 需要实时踢人下线:考虑 Redis 黑名单或 Token 版本号
  • 需要细粒度数据权限:在 RBAC 之外再叠加数据范围控制

核心原理

这一部分我们只抓最关键的三个概念:认证、授权、JWT 结构

1. 认证与授权的区别

很多项目里这两个词会混着说,但实现时一定要分开。

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

举个后台系统的例子:

  • 用户输入账号密码登录,这是认证
  • 登录成功后,是否能访问“用户管理-删除用户”接口,这是授权

2. JWT 的组成

JWT 一般由三部分组成:

  • Header
  • Payload
  • Signature

格式类似:

header.payload.signature

其中:

  • Header 说明算法类型,比如 HS256
  • Payload 放声明信息,比如用户 ID、用户名、角色
  • Signature 用服务端密钥签名,防止内容被篡改

需要强调一点:JWT 默认不是加密,只是 Base64Url 编码
所以不要把手机号、身份证号、密码摘要这类敏感数据直接放进去。

3. Spring Security 在这里扮演什么角色

Spring Security 本质上是一个安全框架,它提供:

  • 过滤器链
  • 认证上下文 SecurityContext
  • 方法级权限控制
  • 统一异常处理入口

我们要做的事情其实很清晰:

  1. 登录时自己校验用户名密码
  2. 签发 JWT
  3. 在请求过滤器里解析 JWT
  4. 把用户信息塞进 SecurityContext
  5. @PreAuthorize 或角色权限表达式保护接口

这张时序图能帮助你把过程串起来。

sequenceDiagram
    participant Client as 前端
    participant Auth as 登录接口
    participant JWT as JWT工具类
    participant Filter as JWT过滤器
    participant API as 业务接口

    Client->>Auth: POST /auth/login 用户名+密码
    Auth->>Auth: 校验账号密码
    Auth->>JWT: 生成Token
    JWT-->>Auth: accessToken
    Auth-->>Client: 返回Token

    Client->>API: GET /users + Authorization: Bearer xxx
    API->>Filter: 进入过滤器链
    Filter->>JWT: 解析并校验Token
    JWT-->>Filter: 用户ID/角色/权限
    Filter->>Filter: 写入SecurityContext
    Filter-->>API: 放行请求
    API->>API: 执行权限判断
    API-->>Client: 返回结果

权限模型设计:别只停留在“有没有登录”

一个能真正支撑中后台的权限模型,通常要考虑三层:

  • 用户 User
  • 角色 Role
  • 权限 Permission

典型关系是:

  • 用户可以拥有多个角色
  • 角色可以绑定多个权限
  • 接口或按钮需要某个权限点才能访问

例如:

  • ROLE_ADMIN
  • sys:user:list
  • sys:user:create
  • sys:user:delete

如果你只做“登录态校验”,那只是完成了第一步。真正的中后台控制,往往需要做到:

  • 菜单可见性控制
  • 按钮级权限控制
  • 接口访问控制
  • 审计日志记录谁做了什么

从代码设计上,我建议:

  • Token 里放用户 ID、用户名、角色列表
  • 权限列表可以按需放,或者每次从缓存/数据库加载
  • 如果权限变更需要快速生效,不要把完整权限集长期固化在 Token 里

实战代码(可运行)

下面给出一个基于 Spring Boot 3 + Spring Security 6 + jjwt 的最小可运行示例。
为了聚焦鉴权流程,示例里用内存用户代替数据库,但结构是可以平滑替换成真实项目的。

1. Maven 依赖

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

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

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

    <properties>
        <java.version>17</java.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>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
</project>

2. application.yml

server:
  port: 8080

jwt:
  secret: 12345678901234567890123456789012
  expire-seconds: 7200

这里的 secret 只是示例。生产环境请使用足够强度的随机密钥,并通过环境变量或密钥管理系统注入。

3. 启动类

package com.example.jwtdemo;

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

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

4. JWT 工具类

package com.example.jwtdemo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.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 expireMillis;

    public JwtTokenProvider(@Value("${jwt.secret}") String secret,
                            @Value("${jwt.expire-seconds}") long expireSeconds) {
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
        this.expireMillis = expireSeconds * 1000;
    }

    public String generateToken(Long userId, String username, List<String> roles, List<String> permissions) {
        Date now = new Date();
        Date expireAt = new Date(now.getTime() + expireMillis);

        return Jwts.builder()
                .setSubject(username)
                .claim("userId", userId)
                .claim("roles", roles)
                .claim("permissions", permissions)
                .setIssuedAt(now)
                .setExpiration(expireAt)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

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

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

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

5. 登录请求与响应对象

package com.example.jwtdemo.model;

public class LoginRequest {
    private String username;
    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;
    }
}
package com.example.jwtdemo.model;

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

6. 用户模型与模拟用户服务

package com.example.jwtdemo.model;

import java.util.List;

public class LoginUser {
    private Long userId;
    private String username;
    private String password;
    private List<String> roles;
    private List<String> permissions;

    public LoginUser(Long userId, String username, String password, List<String> roles, List<String> permissions) {
        this.userId = userId;
        this.username = username;
        this.password = password;
        this.roles = roles;
        this.permissions = permissions;
    }

    public Long getUserId() {
        return userId;
    }

    public String getUsername() {
        return username;
    }

    public String getPassword() {
        return password;
    }

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

    public List<String> getPermissions() {
        return permissions;
    }
}
package com.example.jwtdemo.service;

import com.example.jwtdemo.model.LoginUser;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Map;

@Service
public class UserService {

    private final Map<String, LoginUser> users = Map.of(
            "admin", new LoginUser(
                    1L,
                    "admin",
                    "{noop}123456",
                    List.of("ROLE_ADMIN"),
                    List.of("sys:user:list", "sys:user:create", "sys:user:delete")
            ),
            "editor", new LoginUser(
                    2L,
                    "editor",
                    "{noop}123456",
                    List.of("ROLE_EDITOR"),
                    List.of("sys:user:list")
            )
    );

    public LoginUser findByUsername(String username) {
        return users.get(username);
    }
}

7. 自定义 UserDetailsService

package com.example.jwtdemo.security;

import com.example.jwtdemo.model.LoginUser;
import com.example.jwtdemo.service.UserService;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserService userService;

    public CustomUserDetailsService(UserService userService) {
        this.userService = userService;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LoginUser loginUser = userService.findByUsername(username);
        if (loginUser == null) {
            throw new UsernameNotFoundException("用户不存在");
        }

        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        loginUser.getRoles().forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));
        loginUser.getPermissions().forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission)));

        return new User(loginUser.getUsername(), loginUser.getPassword(), authorities);
    }
}

8. JWT 认证过滤器

package com.example.jwtdemo.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.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

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

    @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 ")) {
            String token = authHeader.substring(7);

            if (jwtTokenProvider.validateToken(token)) {
                Claims claims = jwtTokenProvider.parseClaims(token);
                String username = claims.getSubject();

                List<String> roles = claims.get("roles", List.class);
                List<String> permissions = claims.get("permissions", List.class);

                List<SimpleGrantedAuthority> authorities = new ArrayList<>();
                if (roles != null) {
                    roles.forEach(role -> authorities.add(new SimpleGrantedAuthority(role)));
                }
                if (permissions != null) {
                    permissions.forEach(permission -> authorities.add(new SimpleGrantedAuthority(permission)));
                }

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

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

9. Security 配置

package com.example.jwtdemo.config;

import com.example.jwtdemo.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.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.factory.PasswordEncoderFactories;
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 AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

10. 登录接口

package com.example.jwtdemo.controller;

import com.example.jwtdemo.model.LoginRequest;
import com.example.jwtdemo.model.LoginResponse;
import com.example.jwtdemo.model.LoginUser;
import com.example.jwtdemo.security.JwtTokenProvider;
import com.example.jwtdemo.service.UserService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.bind.annotation.*;

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

    private final AuthenticationManager authenticationManager;
    private final UserService userService;
    private final JwtTokenProvider jwtTokenProvider;

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

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

        LoginUser loginUser = userService.findByUsername(request.getUsername());
        String token = jwtTokenProvider.generateToken(
                loginUser.getUserId(),
                loginUser.getUsername(),
                loginUser.getRoles(),
                loginUser.getPermissions()
        );

        return new LoginResponse(token);
    }
}

11. 受保护接口

package com.example.jwtdemo.controller;

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

import java.util.Map;

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

    @GetMapping("/me")
    public Map<String, Object> me() {
        return Map.of("message", "你已通过认证");
    }

    @PreAuthorize("hasAuthority('sys:user:list')")
    @GetMapping
    public Map<String, Object> list() {
        return Map.of("message", "用户列表访问成功");
    }

    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/{id}")
    public Map<String, Object> delete(@PathVariable Long id) {
        return Map.of("message", "删除用户成功", "id", id);
    }
}

如何运行与验证

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

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. 访问认证接口

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

3. 访问权限接口

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

4. 测试角色限制接口

curl -X DELETE http://localhost:8080/users/1 \
  -H "Authorization: Bearer 你的token"

如果你用 editor / 123456 登录:

  • 可以访问 /users/me
  • 可以访问 /users
  • 不能访问 DELETE /users/1

这说明角色和权限控制都生效了。


权限控制在架构层面怎么落地

代码能跑起来只是第一步。真正的项目里,更重要的是权限如何组织到架构中

推荐分层

控制层

  • 只暴露接口
  • @PreAuthorize 声明权限要求

应用层 / 服务层

  • 编排业务流程
  • 不重复做简单接口权限判断
  • 遇到跨接口、跨场景的复杂权限,再补充业务校验

基础设施层

  • 负责 Token 生成解析
  • 负责从缓存/数据库读取用户权限
  • 负责黑名单、审计日志等

这样做有个好处:权限规则不会散落到每个 Controller 和 Service 的角落里

下面这张类关系图是一个简化版的结构参考。

classDiagram
    class AuthController {
      +login(LoginRequest) LoginResponse
    }

    class UserController {
      +me()
      +list()
      +delete(Long id)
    }

    class JwtTokenProvider {
      +generateToken(Long,String,List,List) String
      +parseClaims(String) Claims
      +validateToken(String) boolean
    }

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

    class CustomUserDetailsService {
      +loadUserByUsername(String) UserDetails
    }

    class UserService {
      +findByUsername(String) LoginUser
    }

    AuthController --> UserService
    AuthController --> JwtTokenProvider
    JwtAuthenticationFilter --> JwtTokenProvider
    CustomUserDetailsService --> UserService

常见坑与排查

这一部分非常重要,因为 JWT 鉴权“看起来简单”,但一旦失败,问题点可能在很多层。我把常见问题按现象来讲。

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

常见原因

  • 请求头没带 Authorization
  • Bearer 前缀写错
  • Token 已过期
  • 过滤器没生效
  • Token 密钥不一致

排查建议

先看请求头:

Authorization: Bearer eyJhbGciOi...

再在过滤器里打日志,确认:

  • 有没有进过滤器
  • Token 有没有拿到
  • validateToken 是否返回 true
  • SecurityContext 是否写入成功

我以前就踩过一个很低级但常见的坑:前端把 Token 放到 token 头里了,后端却只认 Authorization,结果接口全是 401。

2. 已经认证了,但权限校验返回 403

常见原因

  • 角色前缀不一致
  • hasRole('ADMIN')ROLE_ADMIN 使用混淆
  • 权限点名称不一致
  • 没启用方法级权限控制

关键规则

如果用:

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

那实际权限里要有:

ROLE_ADMIN

如果你写的是:

@PreAuthorize("hasAuthority('ADMIN')")

那权限就必须精确是 ADMIN

排查建议

打印当前认证对象:

SecurityContextHolder.getContext().getAuthentication().getAuthorities()

看看到底塞进去了什么。

3. Token 解析报错:签名不合法

常见原因

  • 开发、测试、生产环境的 secret 不一致
  • 前端拿了旧 Token
  • Token 被截断或拼接错误

排查建议

  • 确认环境变量
  • 确认没有多余空格
  • 确认数据库/缓存中是否还保留旧配置

4. 权限修改后,老 Token 还有效

这是 JWT 的典型问题,不是 bug,而是架构特性。

原因

JWT 是自包含的,签发时把角色/权限写进去了,只要 Token 没过期,它就还能用。

解决办法

按场景选:

  • 短有效期 Token:简单直接
  • Redis 黑名单:支持主动失效
  • Token 版本号:用户权限变更时版本递增
  • 权限不放 Token,运行时查缓存:实时性高,但每次请求成本更高

5. 过滤器执行了两次

如果过滤器不是继承 OncePerRequestFilter,或者配置链路有问题,可能出现重复执行。
这也是为什么示例里我直接用 OncePerRequestFilter,它在 Web 项目里更稳。


安全最佳实践

JWT 很方便,但方便不等于天然安全。下面这些实践,我建议默认就做。

1. 密钥管理不要写死在代码里

示例里为了方便演示写在 application.yml。生产环境建议:

  • 从环境变量读取
  • 使用配置中心加密项
  • 使用 KMS 或密钥管理系统

2. Token 有效期不要过长

对于中后台系统,常见做法是:

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

如果你把 Access Token 设成 30 天,一旦泄露,风险会非常高。

3. 不要在 JWT Payload 放敏感信息

不要放:

  • 密码
  • 手机号
  • 身份证号
  • 银行卡号
  • 完整权限快照中的敏感字段

建议只放:

  • 用户 ID
  • 用户名
  • 必要角色标识
  • 最少量的鉴权上下文

4. 前端存储 Token 要谨慎

严格说:

  • localStorage:实现简单,但更怕 XSS
  • 放 HttpOnly Cookie:更利于防止脚本读取,但要额外考虑 CSRF

前后端分离后台系统里,很多团队默认用 localStorage。这不是不行,但前提是你要把 XSS 防护 做好,包括:

  • 输入输出转义
  • CSP
  • 富文本内容净化

5. 登出要有兜底机制

JWT 是无状态的,服务端默认“记不住它”。所以登出不能只靠前端删本地 Token。更稳妥的办法是:

  • 维护 Redis 黑名单
  • 或记录用户当前有效 Token 版本号
  • 或缩短 Access Token 生命周期

6. 明确区分 401 与 403

  • 401 Unauthorized:没登录、Token 无效、Token 过期
  • 403 Forbidden:已登录,但没权限

这不仅影响接口语义,也影响前端怎么处理:

  • 401 -> 跳转登录页
  • 403 -> 提示“无权限”

性能最佳实践

很多人一提 JWT 就说“无状态,性能好”,这句话只对一半。
它减少了服务端 Session 存储压力,但不代表整条链路天然高效。

1. Token 不要塞太多内容

JWT 每次请求都会带上,如果你把角色、权限、组织树、菜单树都放进去:

  • Header 变大
  • 网络开销上升
  • 解析开销增加

对于高频接口,这种开销是能累计出来的。

2. 权限数据建议缓存

如果你不把完整权限放进 Token,而是请求时查库,那建议:

  • 用户基础信息走本地缓存或 Redis
  • 权限集合做缓存
  • 设置合理 TTL,并在角色变更时主动失效

3. 网关层统一做基础鉴权

如果系统是微服务架构,建议:

  • 网关统一校验 JWT
  • 下游服务只信任网关透传的用户上下文
  • 对关键接口仍保留服务内授权校验

这样可以减少每个服务重复写解析逻辑。

4. 容量估算要关注什么

一个典型中后台系统,假设:

  • 5000 活跃用户
  • 平均每秒 200 请求
  • 每个请求都解析 JWT

那么需要关注:

  • JWT 解析 CPU 开销
  • 权限缓存命中率
  • Redis 黑名单查询成本
  • 鉴权失败请求比例

如果只是单体应用,中小规模问题不大;
但当接口量上来后,建议把鉴权链路做压测,特别是:

  • 高并发登录
  • Token 过期集中刷新
  • Redis 抖动时的降级行为

可扩展设计建议

当你的中后台从“能登录”走向“可运营”,通常会继续遇到下面几类扩展需求。

1. 刷新 Token

推荐双 Token 模型:

  • Access Token:短期有效,访问接口
  • Refresh Token:长期有效,用于换新 Access Token

这样既减少用户频繁登录,也降低泄露风险。

2. 数据权限

角色权限只能回答“能不能访问接口”,但很多后台更关心“能看哪些数据”。

例如:

  • 只能看自己创建的数据
  • 只能看本部门数据
  • 只能看本部门及子部门数据

这时要在 RBAC 之外,再叠加数据范围过滤。

3. 审计日志

建议记录:

  • 谁登录了
  • 谁访问了敏感接口
  • 谁做了删除、审批、导出

这对排查问题和安全合规都很关键。

4. 动态权限加载

如果后台权限经常调整,建议把权限元数据做成:

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

再配合缓存做动态加载,而不是把权限写死在代码里。


边界条件:什么情况下不建议直接照搬

这套方案很适合中后台,但不是所有场景都最优。

不太适合的情况

1. 需要强实时踢下线

JWT 天然不擅长“立即失效”,需要配合黑名单或版本控制。

2. 超复杂统一认证体系

如果你有多个系统、多个租户、第三方身份源,直接上 OAuth2 / OIDC 往往更合适。

3. 极细粒度 ABAC 授权

如果权限要基于用户属性、资源属性、环境条件动态决策,纯 RBAC 不够,需要更复杂的策略引擎。


总结

用 Spring Boot 与 JWT 实现中后台登录鉴权,真正重要的不是“生成一个 Token”,而是把整套链路设计清楚:

  • 认证:登录校验用户名密码
  • 令牌:用 JWT 传递最小必要身份信息
  • 鉴权:过滤器统一解析 Token,建立用户上下文
  • 授权:基于角色和权限点保护接口
  • 扩展:通过缓存、黑名单、版本号、刷新机制补足 JWT 的天然短板

如果你准备在真实项目里落地,我给几个可执行建议:

  1. 先做最小闭环:登录、JWT、过滤器、@PreAuthorize
  2. 角色和权限命名规范要统一:尤其 ROLE_ 前缀
  3. Access Token 设置短有效期
  4. 权限变更频繁时,不要把完整权限长期固化到 Token
  5. 明确 401 和 403 的处理语义
  6. 预留登出、黑名单、刷新 Token 的扩展点

一句话收尾:
JWT 适合做中后台认证的“基础设施”,但权限控制的质量,最终取决于你的架构分层和边界设计。

如果你现在的系统还停留在“登录后放行所有接口”,那这篇文章里的结构,已经足够作为一次可靠的升级起点。


分享到:

上一篇
《Docker 多阶段构建与镜像瘦身实战:从构建优化到安全交付》
下一篇
《从抓包到算法还原:一次典型 Web 逆向中请求签名参数的定位、分析与复现实战》