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

《Spring Boot 3 实战:基于 JWT 与 Spring Security 6 构建可扩展的 Java Web 登录鉴权体系》

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

Spring Boot 3 实战:基于 JWT 与 Spring Security 6 构建可扩展的 Java Web 登录鉴权体系

在 Java Web 项目里,“登录鉴权”几乎是绕不过去的一步。很多团队刚开始做的时候,会先写一个能跑的版本:用户名密码登录成功后,把用户信息塞进 Session,后面请求靠 Cookie 续命。这个方式并不是错,但一旦项目开始走向前后端分离、微服务化,或者准备做多端接入,传统 Session 方案很快就会遇到扩展性和部署复杂度的问题。

这篇文章我换一个更偏“落地”的角度来讲:不是只讲 JWT 是什么,而是带你在 Spring Boot 3 + Spring Security 6 里,真正搭一个能跑、能扩展、便于后续演进的登录鉴权体系。我们会从问题出发,讲清楚核心链路,再给出一套完整代码骨架,并穿插我自己常见的踩坑点。


背景与问题

先看几个典型场景:

  • 前后端分离,前端通过 Authorization: Bearer <token> 调接口
  • 后端服务需要无状态部署,避免 Session 粘连
  • 需要支持角色权限,比如普通用户、管理员
  • 后续可能接入 Redis 黑名单、Refresh Token、多端登录控制

这时,很多人会自然想到 JWT。它的核心优势是:

  • 无状态:服务端不强依赖 Session 存储
  • 易扩展:适合 API 网关、微服务、移动端
  • 跨服务传播方便:标准化的 token 格式

但 JWT 也不是“上了就安全”,常见问题包括:

  • token 过期策略混乱
  • 过滤器顺序不对,导致明明带了 token 仍然 403
  • claim 设计随意,后续兼容性差
  • 把敏感信息直接塞进 token
  • 刷新机制和登出机制没规划

所以这篇文章的目标不是“演示登录成功”,而是构建一套结构清晰、方便维护、适合继续演进的方案。


前置知识与环境准备

建议你至少具备以下基础:

  • Java 17+
  • Maven 基本使用
  • Spring Boot 基本开发经验
  • 了解 HTTP 请求头、状态码、JSON

本文环境:

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

整体设计思路

我们先定一个简单、实用的架构:

  1. 用户通过用户名密码调用 /api/auth/login
  2. 后端校验账号密码
  3. 校验成功后签发 JWT
  4. 前端后续请求在 Authorization 头里带上 Bearer token
  5. JWT 过滤器解析 token,并将用户身份放入 Spring Security 上下文
  6. 控制器或方法通过权限规则进行访问控制

为什么用这种结构?

因为它有几个好处:

  • 登录逻辑和鉴权逻辑分离
  • token 解析集中在过滤器中
  • 业务代码只关心“当前用户是谁”
  • 后续接入数据库、Redis、网关都比较自然

核心原理

1. Spring Security 6 的基本思路

Spring Security 6 不再推荐继承 WebSecurityConfigurerAdapter,而是通过声明式的 SecurityFilterChain 来配置。

你可以把它理解成:

  • 请求先经过一串过滤器
  • 其中某个过滤器负责从 JWT 中识别用户
  • 如果识别成功,就把认证信息放进 SecurityContext
  • 后面的授权规则根据这个上下文决定是否放行

2. JWT 的组成

JWT 由三部分组成:

  • Header
  • Payload
  • Signature

其中 Payload 常见字段:

  • sub:用户标识
  • iat:签发时间
  • exp:过期时间
  • 自定义字段,如 roles

要注意:JWT 是可解码的,不是加密的。所以不要把密码、身份证号这类敏感信息放进去。

3. 认证与授权分开理解

很多初学者容易把这两个混在一起:

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

在本文方案里:

  • 登录接口负责认证
  • 请求携带 token 后,过滤器恢复用户身份
  • Spring Security 规则负责授权

请求链路图

flowchart TD
    A[客户端发起登录请求] --> B[AuthController /login]
    B --> C[AuthenticationManager 校验用户名密码]
    C -->|成功| D[JwtService 签发 JWT]
    D --> E[返回 access token]
    E --> F[客户端后续请求携带 Bearer Token]
    F --> G[JwtAuthenticationFilter]
    G --> H[解析 JWT 并构建 Authentication]
    H --> I[放入 SecurityContext]
    I --> J[控制器/方法鉴权]

认证时序图

sequenceDiagram
    participant Client as 客户端
    participant Controller as AuthController
    participant Manager as AuthenticationManager
    participant UDS as UserDetailsService
    participant JWT as JwtService

    Client->>Controller: POST /api/auth/login
    Controller->>Manager: authenticate(username, password)
    Manager->>UDS: loadUserByUsername()
    UDS-->>Manager: UserDetails
    Manager-->>Controller: 认证成功
    Controller->>JWT: generateToken(user)
    JWT-->>Controller: accessToken
    Controller-->>Client: 200 + token

项目结构建议

我个人更推荐下面这种结构,后面扩展 Refresh Token、黑名单、租户等能力时不会太乱:

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

实战代码(可运行)

下面给出一个最小可运行版本。为了聚焦 JWT 与 Security 主线,我先用内存用户,避免文章被数据库细节冲散。你后续接数据库时,只需要替换 UserDetailsService 即可。

1. Maven 依赖

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

    <groupId>com.example</groupId>
    <artifactId>springboot3-jwt-demo</artifactId>
    <version>0.0.1-SNAPSHOT</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>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </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 DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

4. DTO

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

package com.example.demo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Collection;
import java.util.Date;
import java.util.List;

@Service
public class JwtService {

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

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

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

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

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

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

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

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

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

    private SecretKey getSignKey() {
        byte[] keyBytes = Decoders.BASE64.decode(
                java.util.Base64.getEncoder().encodeToString(secret.getBytes())
        );
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

6. 自定义 UserDetailsService

package com.example.demo.security;

import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("admin".equals(username)) {
            return User.withUsername("admin")
                    .password("$2a$10$N9qo8uLOickgx2ZMRZo5i.ej5j8xQ1OtSWT8gJ5istyhXCTITVE4G")
                    .roles("ADMIN")
                    .build();
        }

        if ("user".equals(username)) {
            return User.withUsername("user")
                    .password("$2a$10$N9qo8uLOickgx2ZMRZo5i.ej5j8xQ1OtSWT8gJ5istyhXCTITVE4G")
                    .roles("USER")
                    .build();
        }

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

上面的 BCrypt 密文对应明文密码:

123456

7. JWT 认证过滤器

这个类是整套方案最关键的部分之一。它做的事情很纯粹:

  • 从请求头取 token
  • 解析用户名
  • 加载用户
  • 验证 token
  • 构建认证对象并放入上下文
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.lang.NonNull;
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 JwtService jwtService;
    private final CustomUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtService jwtService, CustomUserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

    @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 {
            String username = jwtService.extractUsername(token);

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

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

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

                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        } catch (Exception ex) {
            // 这里先吞掉异常,让后续统一走未认证处理
        }

        filterChain.doFilter(request, response);
    }
}

8. Security 配置

package com.example.demo.config;

import com.example.demo.security.CustomUserDetailsService;
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.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomUserDetailsService userDetailsService;

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

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

        return http.build();
    }

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

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

9. 登录接口

package com.example.demo.controller;

import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.LoginResponse;
import com.example.demo.security.JwtService;
import jakarta.validation.Valid;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

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

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;

    public AuthController(AuthenticationManager authenticationManager, JwtService jwtService) {
        this.authenticationManager = authenticationManager;
        this.jwtService = jwtService;
    }

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

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

        return new LoginResponse(token);
    }
}

10. 受保护接口

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

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

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin/dashboard")
    public Map<String, Object> adminDashboard() {
        return Map.of("message", "欢迎访问管理员面板");
    }
}

逐步验证清单

建议你按这个顺序测试,排查效率会高很多。

1. 获取 token

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

预期返回:

{
  "token": "xxxxx",
  "tokenType": "Bearer"
}

2. 访问当前用户接口

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

3. 普通用户访问管理员接口

curl http://localhost:8080/api/admin/dashboard \
  -H "Authorization: Bearer 普通用户token"

预期是 403 Forbidden

4. 管理员登录并访问管理员接口

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

然后用返回 token 访问:

curl http://localhost:8080/api/admin/dashboard \
  -H "Authorization: Bearer 管理员token"

权限模型图

classDiagram
    class UserDetails {
        +String username
        +String password
        +Collection authorities
    }

    class JwtService {
        +generateToken(userDetails)
        +extractUsername(token)
        +isTokenValid(token, userDetails)
    }

    class JwtAuthenticationFilter {
        +doFilterInternal(request, response, chain)
    }

    class SecurityContextHolder {
        <<static>>
    }

    JwtAuthenticationFilter --> JwtService
    JwtAuthenticationFilter --> UserDetails
    JwtAuthenticationFilter --> SecurityContextHolder

常见坑与排查

这部分非常重要。很多人代码“看起来都对”,但就是登录后访问不了接口,基本都栽在这些地方。

坑 1:过滤器加错位置

如果你没有把 JWT 过滤器放在 UsernamePasswordAuthenticationFilter 之前,Spring Security 的认证流程可能不会按预期工作。

正确写法:

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

坑 2:权限前缀混乱

Spring Security 的 roles("ADMIN") 实际会自动加上前缀 ROLE_。所以:

  • roles("ADMIN")
  • 判断时用 hasRole("ADMIN")

不要混着写成:

  • authority 是 ADMIN
  • 判断却是 hasRole("ADMIN")

否则很容易 403。我当时第一次接权限判断时,就在这个点上绕了半小时。

坑 3:密码编码器不一致

如果你数据库里存的是 BCrypt 密文,那认证时也必须用 BCrypt。
常见现象是:

  • 用户明明存在
  • 密码也确认没错
  • 但一直报 Bad credentials

检查点:

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

坑 4:JWT 密钥长度不合适

如果你用 HMAC 签名算法,密钥长度要满足要求。太短会报错,太随意也不安全。

实际项目建议:

  • 使用足够长度的随机密钥
  • 放在环境变量、配置中心或密钥管理系统中

坑 5:请求头没带 Bearer 前缀

后端通常约定:

Authorization: Bearer eyJhbGci...

如果你只传 token 本体,不带 Bearer ,过滤器大概率直接跳过。

坑 6:登录接口被拦截

如果忘了放行登录接口:

.requestMatchers("/api/auth/login").permitAll()

那么你会发现一个很尴尬的现象:连登录接口都要求先登录

坑 7:异常被吞掉后难排查

上面的示例里,为了保证最小实现,我们在过滤器中简单吞掉了异常。但真实项目里更推荐记录日志,例如:

catch (Exception ex) {
    logger.warn("JWT 解析失败: {}", ex.getMessage());
}

否则线上 token 失效、签名错误、格式异常时,你只能靠猜。


安全/性能最佳实践

这里给的是更贴近生产环境的建议,不一定每项都要立刻做,但建议你在设计时预留位置。

1. Access Token 短时效,别无限期

推荐思路:

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

原因很简单:token 一旦泄露,生命周期越长,风险越大。

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

JWT 常适合放:

  • 用户 ID
  • 用户名
  • 角色
  • 租户 ID(按需)

不要放:

  • 密码
  • 手机号明文
  • 身份证号
  • 银行卡号
  • 大块业务数据

3. 登出不是“删前端 token”就完了

很多文章讲到这里就结束了,但实际业务常常要求“立即失效”。
JWT 天生无状态,所以如果你要支持服务端主动失效,可以考虑:

  • Redis 黑名单
  • token 版本号
  • 用户状态校验
  • 刷新 token 轮换

尤其是后台管理系统,我更建议加黑名单或版本机制。

4. 用户权限变更后的失效策略要提前想好

比如:

  • 某用户刚被取消管理员权限
  • 但他手里的旧 token 还没过期

这时系统可能会出现“权限撤销不即时”的问题。常见做法:

  • token 过期时间缩短
  • token 中带权限版本号
  • 服务端按需二次查库校验关键操作

5. 方法级鉴权比 URL 鉴权更稳

URL 规则适合做粗粒度控制,但业务复杂后,更推荐在关键方法上加:

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

这样代码可读性更高,也更不容易因为路径调整导致权限漏配。

6. 控制 JWT 体积

token 不是越全越好。体积过大会带来:

  • 请求头膨胀
  • 网络开销增加
  • 网关/代理兼容性问题

所以 claim 要克制,只放必要字段。

7. 为鉴权链路补齐日志与监控

至少要监控这些数据:

  • 登录成功/失败次数
  • token 解析失败次数
  • 403/401 比例
  • 高频用户异常访问
  • 管理员接口访问日志

生产环境里,没有日志和指标的安全系统,定位问题会非常痛苦。


可扩展演进方向

如果你准备把这个 demo 演进到正式项目,我建议按下面顺序升级:

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

替换 CustomUserDetailsService

  • 从数据库查用户
  • 查角色、权限
  • 判断账号状态:禁用、锁定、过期

阶段 2:加入 Refresh Token

适用场景:

  • 用户希望保持较长登录态
  • Access Token 需要缩短生命周期

阶段 3:支持登出与强制失效

推荐:

  • Redis 存储 token 黑名单
  • 或按用户维度维护 token version

阶段 4:支持多终端登录控制

例如:

  • 同账号最多同时登录 3 台设备
  • 管理员可踢掉历史登录

阶段 5:网关统一鉴权

如果系统走向微服务,可将 JWT 校验前移到网关,但核心服务仍应保留关键权限校验,避免单点失守。


一些实现边界条件

这套方案不是银弹,下面这些情况需要单独评估:

1. 高安全后台

如果是金融、政企、核心管理后台,仅仅 JWT + 密码登录还不够,建议增加:

  • MFA 多因子认证
  • IP 白名单
  • 设备指纹
  • 操作二次确认

2. 强一致的会话控制

如果你强依赖:

  • 单点登出
  • 秒级强制失效
  • 精确在线人数

那纯 JWT 无状态方案会变得“理论优雅,工程补丁很多”。这时可以考虑:

  • JWT + Redis
  • 或基于 Session / OAuth2 授权中心的集中式方案

3. 超复杂权限模型

如果权限细到菜单、按钮、数据行级别,那么角色 claim 往往不够。建议:

  • token 放基础身份
  • 细粒度权限由服务端动态判定

总结

如果你要在 Spring Boot 3 里做一套现代化、适合前后端分离的登录鉴权体系,Spring Security 6 + JWT 依然是非常主流且实用的选择。

这篇文章我们完成了几件关键事情:

  • 搞清了 JWT 与 Spring Security 6 的协作方式
  • SecurityFilterChain 替代旧式配置
  • 实现了登录、token 签发、请求鉴权、角色控制
  • 梳理了常见踩坑点和生产实践建议

如果你现在正准备在项目里落地,我给你一个很实用的执行建议:

  1. 先跑通本文最小版本
  2. 再替换成数据库用户体系
  3. 上线前补齐异常处理、日志、过期策略
  4. 根据业务要求决定是否引入 Refresh Token 和黑名单
  5. 关键接口一定保留方法级鉴权

最后再强调一句:JWT 解决的是“身份携带”问题,不会自动解决所有安全问题。真正可用的鉴权体系,靠的是合理的 token 生命周期、清晰的权限模型、完备的日志审计,以及对边界场景的预案。

如果你是中级开发者,我建议你别只停留在“会写 demo”,而是从现在开始,把“可扩展、可排查、可演进”当成安全设计的一部分。这样你做出来的登录体系,才能真的扛住后续业务增长。


分享到:

上一篇
《Web逆向实战:基于浏览器开发者工具定位并还原前端加密请求参数的完整方法》
下一篇
《Spring Boot 3 中构建高并发 Java Web 接口的实战:限流、幂等与接口性能优化》