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

《Spring Boot 3 中实现基于 JWT 与 Spring Security 6 的统一认证授权实战》

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

Spring Boot 3 中实现基于 JWT 与 Spring Security 6 的统一认证授权实战

很多团队在升级到 Spring Boot 3 之后,安全这一块最容易“卡壳”。原因不复杂:Spring Security 6 的配置方式变了,以前那套 WebSecurityConfigurerAdapter 已经退出历史舞台;而 JWT 方案虽然常见,但一旦要做“统一认证授权”,就不只是登录发个 token 那么简单,还会牵扯到:

  • 登录接口怎么放行
  • token 怎么解析、校验、续期
  • 角色和权限怎么映射到接口
  • 认证失败和授权失败怎么统一返回
  • 无状态场景下,怎么保证系统足够安全、性能也不差

这篇文章我会带你从一个能跑起来的 Spring Boot 3 + Spring Security 6 + JWT 项目出发,把整个链路串起来。文章重点不是堆概念,而是尽量用“实战走一遍”的方式,让你知道每一层为什么这么写。


背景与问题

在传统 Session 模式下,后端通常会把用户登录态保存在服务器内存或 Redis 中。它的优点是简单直观,但在微服务、前后端分离和多实例部署下,问题会越来越明显:

  1. 服务端要保存状态

    • 横向扩容时要考虑 Session 共享
    • 网关层、鉴权层都要配合
  2. 前后端分离场景不够自然

    • Web 页面还能靠 Cookie
    • App、小程序、第三方调用往往更适合 Bearer Token
  3. 权限控制容易碎片化

    • 登录是登录,接口鉴权是接口鉴权
    • 错误返回格式也经常不统一

JWT 的价值就在这里:
认证信息随 token 一起传递,服务端按签名校验,不依赖 Session。

但我要先提醒一句:JWT 不是银弹。如果你需要强制下线、即时撤销、复杂会话管理,单纯无状态 JWT 也会遇到边界。这篇文章先聚焦于一个中型项目里非常常见的方案:登录签发 JWT,后续请求基于 Spring Security 做统一认证与授权。


前置知识与环境准备

技术栈

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

本文实现的目标

我们要做出下面这条链路:

  • POST /api/auth/login:用户名密码登录,返回 JWT
  • 访问受保护接口时,前端在请求头带上:
Authorization: Bearer xxxxx.yyyyy.zzzzz
  • Spring Security 自动完成:
    • 解析 JWT
    • 验证签名和过期时间
    • 从 token 中提取用户名、角色、权限
    • 放入当前安全上下文
  • 业务接口按角色/权限控制访问
  • 未登录、token 无效、权限不足时,返回统一 JSON

核心原理

如果把整个过程讲得最直白一点,可以理解为:

  1. 用户用用户名密码请求登录接口
  2. 服务端校验成功后,生成一个签名过的 JWT
  3. 客户端保存这个 JWT
  4. 后续每次请求都带上 JWT
  5. 服务端通过过滤器解析 JWT,恢复出用户身份
  6. Spring Security 根据用户权限决定是否允许访问接口

整体流程图

flowchart TD
    A[客户端登录<br/>POST /api/auth/login] --> B[认证控制器]
    B --> C[AuthenticationManager 校验用户名密码]
    C -->|成功| D[JWT 工具生成 Token]
    D --> E[返回 accessToken]

    F[客户端访问业务接口<br/>携带 Authorization Bearer Token] --> G[JWT 认证过滤器]
    G --> H{Token 是否合法}
    H -- 否 --> I[返回 401 未认证]
    H -- 是 --> J[构造 Authentication]
    J --> K[放入 SecurityContext]
    K --> L[Spring Security 授权判断]
    L -->|通过| M[进入 Controller]
    L -->|拒绝| N[返回 403 无权限]

Spring Security 6 的几个关键变化

升级到 Spring Security 6 后,很多人第一反应是“以前配置怎么不能用了”。核心变化主要有两点:

1)不再推荐继承 WebSecurityConfigurerAdapter

现在的主流写法是直接声明 SecurityFilterChain Bean:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    return http.build();
}

这比以前更清晰:
你不是去“继承一个大类再重写”,而是在配置一个明确的过滤器链。

2)授权写法变为 requestMatchers

比如:

.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/auth/**").permitAll()
    .anyRequest().authenticated()
)

这套 DSL 更贴近“按 URL 规则声明权限”。


JWT 在这里到底承载了什么

通常我们会把这些信息放入 JWT:

  • sub:用户标识,一般是用户名
  • roles:角色列表
  • permissions:权限列表
  • iat:签发时间
  • exp:过期时间

注意一点:
JWT 里放的是“足够用于鉴权的信息”,不是把整个用户对象全塞进去。
我见过有人把手机号、邮箱、部门、甚至头像 URL 全放进 token,结果 token 体积越来越大,请求头也越来越臃肿,没必要。

认证与授权的边界

这两个词很容易混:

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

在本文方案中:

  • 登录时做的是认证
  • 请求带 JWT 访问接口时,先恢复认证身份
  • 再由 Spring Security 判断角色/权限是否满足要求

类关系与职责图

classDiagram
    class AuthController {
      +login(LoginRequest)
    }

    class JwtAuthenticationFilter {
      +doFilterInternal(...)
    }

    class JwtTokenProvider {
      +generateToken(UserDetails)
      +parseClaims(String)
      +validateToken(String)
    }

    class CustomUserDetailsService {
      +loadUserByUsername(String)
    }

    class SecurityConfig {
      +securityFilterChain(HttpSecurity)
      +authenticationManager(...)
      +passwordEncoder()
    }

    class UserPrincipal {
      +getAuthorities()
      +getUsername()
      +getPassword()
    }

    AuthController --> JwtTokenProvider
    AuthController --> SecurityConfig
    JwtAuthenticationFilter --> JwtTokenProvider
    JwtAuthenticationFilter --> CustomUserDetailsService
    CustomUserDetailsService --> UserPrincipal
    SecurityConfig --> JwtAuthenticationFilter

实战代码(可运行)

下面给出一个最小可运行示例。为了聚焦 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>jwt-security-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>jwt-security-demo</name>

    <properties>
        <java.version>17</java.version>
        <spring-boot.version>3.3.0</spring-boot.version>
        <jjwt.version>0.11.5</jjwt.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <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. 启动类

// src/main/java/com/example/demo/JwtSecurityDemoApplication.java
package com.example.demo;

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

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

4. 请求与响应模型

// src/main/java/com/example/demo/model/LoginRequest.java
package com.example.demo.model;

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;
    }
}
// src/main/java/com/example/demo/model/LoginResponse.java
package com.example.demo.model;

public class LoginResponse {

    private String tokenType = "Bearer";
    private String accessToken;

    public LoginResponse(String accessToken) {
        this.accessToken = accessToken;
    }

    public String getTokenType() {
        return tokenType;
    }

    public String getAccessToken() {
        return accessToken;
    }
}
// src/main/java/com/example/demo/model/ApiResponse.java
package com.example.demo.model;

public class ApiResponse<T> {

    private int code;
    private String message;
    private T data;

    public ApiResponse() {
    }

    public ApiResponse(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    public static <T> ApiResponse<T> ok(T data) {
        return new ApiResponse<>(200, "success", data);
    }

    public static <T> ApiResponse<T> fail(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }

    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public T getData() {
        return data;
    }
}

5. 自定义用户服务

这里先用内存方式模拟两个用户:

  • admin / 123456
  • user / 123456

其中:

  • admin 拥有 ROLE_ADMINROLE_USERsys:user:list
  • user 只有 ROLE_USER
// src/main/java/com/example/demo/security/CustomUserDetailsService.java
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"),
                            new SimpleGrantedAuthority("sys:user:list")
                    ))
                    .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("用户不存在");
    }
}

这里为了演示简单,每次查询都动态 encode 密码。
真正接数据库时,你应该取数据库中已加密的密码,而不是每次重新生成。


6. JWT 工具类

// src/main/java/com/example/demo/security/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.*;
import java.util.stream.Collectors;

@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) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + expiration);

        List<String> authorities = userDetails.getAuthorities()
                .stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());

        return Jwts.builder()
                .setSubject(userDetails.getUsername())
                .claim("authorities", authorities)
                .setIssuedAt(now)
                .setExpiration(expireDate)
                .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 (ExpiredJwtException e) {
            return false;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

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

    @SuppressWarnings("unchecked")
    public List<String> getAuthorities(String token) {
        Object authorities = parseClaims(token).get("authorities");
        if (authorities instanceof List<?>) {
            return ((List<?>) authorities).stream()
                    .map(String::valueOf)
                    .toList();
        }
        return Collections.emptyList();
    }
}

7. JWT 认证过滤器

这个过滤器是核心之一。它做的事情是:

  • 从请求头拿到 Bearer Token
  • 校验 token
  • 读取用户和权限
  • 创建 Authentication
  • 放到 SecurityContextHolder
// src/main/java/com/example/demo/security/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.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.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 token = resolveToken(request);

        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsername(token);
            List<SimpleGrantedAuthority> authorities = jwtTokenProvider.getAuthorities(token)
                    .stream()
                    .map(SimpleGrantedAuthority::new)
                    .toList();

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

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

        filterChain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

这里我直接从 token 恢复用户信息,没有再次查数据库。
这是 JWT 的常见无状态做法,性能上很轻。
但如果你有“用户被禁用后要立刻失效”的需求,就需要配合 Redis 黑名单或每次查库校验状态。


8. 认证失败与授权失败统一返回

很多项目最开始都忽略这一层,结果就是:

  • 未登录时返回一个默认 HTML 页面
  • 权限不足时返回另一种结构
  • 前端还得写很多兼容代码

我们把它统一掉。

// src/main/java/com/example/demo/security/RestAuthenticationEntryPoint.java
package com.example.demo.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.example.demo.model.ApiResponse;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.*;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(
                ApiResponse.fail(401, "未认证或Token无效")
        ));
    }
}
// src/main/java/com/example/demo/security/RestAccessDeniedHandler.java
package com.example.demo.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.example.demo.model.ApiResponse;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.*;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write(objectMapper.writeValueAsString(
                ApiResponse.fail(403, "无权限访问")
        ));
    }
}

9. Security 配置

这是 Spring Security 6 的关键配置。

// src/main/java/com/example/demo/config/SecurityConfig.java
package com.example.demo.config;

import 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.*;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final RestAuthenticationEntryPoint restAuthenticationEntryPoint;
    private final RestAccessDeniedHandler restAccessDeniedHandler;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
                          RestAuthenticationEntryPoint restAuthenticationEntryPoint,
                          RestAccessDeniedHandler restAccessDeniedHandler) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.restAuthenticationEntryPoint = restAuthenticationEntryPoint;
        this.restAccessDeniedHandler = restAccessDeniedHandler;
    }

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

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

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                .cors(Customizer.withDefaults())
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(restAuthenticationEntryPoint)
                        .accessDeniedHandler(restAccessDeniedHandler)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/**").permitAll()
                        .requestMatchers("/error").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

这里几项很关键:

  • csrf.disable():前后端分离、JWT 无状态场景通常关闭
  • SessionCreationPolicy.STATELESS:明确告诉 Spring Security 不用 Session
  • addFilterBefore(...):把 JWT 过滤器放在用户名密码过滤器之前

10. 登录控制器

// src/main/java/com/example/demo/controller/AuthController.java
package com.example.demo.controller;

import com.example.demo.model.*;
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.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

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

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

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

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

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

        return ApiResponse.ok(new LoginResponse(token));
    }
}

11. 业务接口与方法级授权

我建议中型项目里同时用两层控制:

  • URL 级别:控制大方向
  • 方法级别:控制细粒度权限
// src/main/java/com/example/demo/controller/UserController.java
package com.example.demo.controller;

import com.example.demo.model.ApiResponse;
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/users")
public class UserController {

    @GetMapping("/me")
    public ApiResponse<?> me(Authentication authentication) {
        return ApiResponse.ok(Map.of(
                "currentUser", authentication.getName(),
                "authorities", authentication.getAuthorities()
        ));
    }

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/admin")
    public ApiResponse<?> adminOnly() {
        return ApiResponse.ok("只有 ADMIN 可访问");
    }

    @PreAuthorize("hasAuthority('sys:user:list')")
    @GetMapping("/list")
    public ApiResponse<?> list() {
        return ApiResponse.ok("拥有 sys:user:list 权限即可访问");
    }
}

注意这里:

  • hasRole('ADMIN') 实际上匹配的是 ROLE_ADMIN
  • hasAuthority('sys:user:list') 则是精确匹配权限字符串

逐步验证清单

项目启动后,可以按这个顺序验证。

1)登录获取 token

admin 登录

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

示例响应:

{
  "code": 200,
  "message": "success",
  "data": {
    "tokenType": "Bearer",
    "accessToken": "eyJhbGciOiJIUzI1NiJ9..."
  }
}

2)访问当前用户信息

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

3)访问管理员接口

curl 'http://localhost:8080/api/users/admin' \
-H 'Authorization: Bearer 你的token'
  • admin 登录拿到的 token:应返回成功
  • user 登录拿到的 token:应返回 403

4)访问权限点接口

curl 'http://localhost:8080/api/users/list' \
-H 'Authorization: Bearer 你的token'
  • admin 可以访问
  • user 会返回 403

请求时序图

这张图适合你在脑子里建立“过滤器链”的感觉。

sequenceDiagram
    participant C as Client
    participant F as JwtAuthenticationFilter
    participant S as Spring Security
    participant A as AuthController/UserController

    C->>A: POST /api/auth/login(username,password)
    A->>S: AuthenticationManager.authenticate(...)
    S-->>A: Authentication
    A-->>C: 返回 JWT

    C->>F: GET /api/users/me + Bearer Token
    F->>F: 解析并校验 JWT
    F->>S: 写入 SecurityContext
    S->>A: 执行授权判断
    A-->>C: 返回业务数据

常见坑与排查

这一节我尽量写得接地气一点,因为这些坑大多数不是“不会写”,而是“写了但为什么不生效”。

1. 登录接口明明放行了,还是 401

先检查:

  • 是否写了 .requestMatchers("/api/auth/**").permitAll()
  • 登录请求路径是不是实际就是 /api/auth/login
  • 是否被自定义过滤器误伤

尤其是自定义 JWT 过滤器,不要在没有 token 的情况下主动抛异常。
像本文这种写法:有 token 就校验,没有就直接放过,交给后续授权机制处理,通常更稳。


2. hasRole("ADMIN") 不生效

这是特别高频的问题。

原因

Spring Security 的 hasRole("ADMIN") 底层会匹配:

ROLE_ADMIN

如果你的权限里存的是:

ADMIN

那就会对不上。

解决方式

二选一:

  • 角色统一存成 ROLE_ADMIN
  • 或者不用 hasRole,改用 hasAuthority("ADMIN")

我个人建议:
角色走 ROLE_ 前缀,权限走业务字符串。
这样语义最清楚。


3. token 明明没过期,却提示无效

常见排查方向:

  • jwt.secret 是否和签发时一致
  • 是否把 Bearer 前缀也一起传进了解析方法
  • token 有没有被前端截断
  • 不同环境的时间是否严重漂移

如果是分布式部署,服务器时间误差太大也会出问题。
这个坑我以前在测试环境遇到过,两个节点时间偏了几十秒,正好卡在边界时间上,查了半天。


4. 过滤器执行了,但 Controller 里拿不到认证信息

重点检查:

  • 有没有 SecurityContextHolder.getContext().setAuthentication(authentication);
  • JWT 过滤器有没有真正加入过滤器链
  • 加入顺序是否正确,通常放在 UsernamePasswordAuthenticationFilter 之前
  • 当前请求是否被另一个过滤器清空了上下文

5. 认证成功了,但权限判断还是失败

看这几个点:

  • token 里是否真的带了 authorities
  • 恢复出来的 SimpleGrantedAuthority 是否和表达式匹配
  • hasRole 还是 hasAuthority
  • 方法级注解是否开启:@EnableMethodSecurity

这类问题最有效的做法不是“猜”,而是打印当前用户权限:

@GetMapping("/me")
public ApiResponse<?> me(Authentication authentication) {
    return ApiResponse.ok(authentication.getAuthorities());
}

先看实际值,再看表达式。


安全/性能最佳实践

JWT 方案常被误用成“只要能跑就行”。但真正上线前,有几条建议非常重要。

安全最佳实践

1)密钥要足够强,并且不要硬编码

示例里为了可运行,把密钥写在配置文件里了。生产环境建议:

  • 使用环境变量
  • 使用 KMS / Vault 等密钥管理工具
  • 定期轮换密钥

如果密钥泄漏,攻击者就能伪造合法 token。


2)access token 过期时间不要太长

常见做法:

  • access token:15 分钟 ~ 2 小时
  • refresh token:更长,但要单独设计

如果你把 access token 配成 7 天,一旦泄漏,风险窗口太大。


3)敏感信息不要放到 JWT 里

JWT 默认只是 Base64Url 编码,不是加密。
所以:

  • 不要放身份证号
  • 不要放手机号等敏感隐私
  • 不要放密码摘要
  • 不要放过多业务字段

JWT 适合放“鉴权必须信息”。


4)考虑 token 撤销机制

纯无状态 JWT 的典型短板是:
已经签发的 token,在过期前默认都有效。

如果你的系统有这些要求:

  • 强制下线
  • 修改密码后旧 token 立即失效
  • 用户被封禁后立即失效

建议增加以下机制之一:

  • Redis 黑名单
  • token version 字段 + 用户版本号校验
  • 短生命周期 access token + refresh token

5)接口统一返回,不暴露过多细节

比如 token 校验失败时,不要区分得太细:

  • 签名错误
  • 格式错误
  • 用户不存在
  • token 过期

对外可以统一返回“未认证或 token 无效”。
这能减少攻击者试探系统内部细节的机会。


性能最佳实践

1)无状态解析比每次查库更轻

像本文这种做法,请求到来时只解析 token,不查数据库,吞吐通常更好。
尤其在高并发接口中,少一次数据库访问非常划算。


2)但不要把 JWT 做得太大

token 越大:

  • 请求头越大
  • 网络传输越重
  • 反向代理日志和链路排查也更麻烦

通常把角色、权限做成紧凑字段就够了。


3)权限变化频繁的系统,要考虑缓存和一致性

如果你的权限变更非常频繁,比如后台一改角色就希望立刻生效,那么“纯 JWT 内嵌权限”会有一致性滞后问题。
此时可以折中:

  • token 里只放用户 ID
  • 请求时从缓存读取权限
  • 缓存失效时回源数据库

这会牺牲一点性能,但换来更强的一致性控制。


可扩展方向

当你把本文方案跑通后,实际项目里一般会往这几个方向扩展。

1. 接数据库

CustomUserDetailsService 改为:

  • 根据用户名查用户表
  • 查角色表、权限表
  • 转换为 GrantedAuthority

2. 增加 refresh token

让登录返回:

  • accessToken
  • refreshToken

当 access token 过期时,用 refresh token 换新 token,提升体验。

3. 接入网关统一鉴权

如果是微服务架构,可以把 JWT 校验前移到网关层,但业务服务内部仍可保留方法级授权,形成双保险。

4. 增加审计日志

记录:

  • 登录成功/失败
  • token 刷新
  • 权限拒绝
  • 关键接口访问人

这对排查问题和安全审计都很有帮助。


一个更贴近生产的思路

如果你问我:真实项目里是不是就照这个最小方案原封不动上生产?
我的答案是:通常不会。

更常见的生产组合是:

  • access token 短时有效
  • refresh token 存 Redis 或数据库
  • 用户状态变更时可主动失效
  • 权限数据适当缓存
  • 网关和服务双层校验
  • 异常响应统一规范化

本文的价值是先把主链路打通。
你一旦把这个版本吃透,再往生产级增强,其实就是做“补充机制”,而不是推翻重来。


总结

在 Spring Boot 3 + Spring Security 6 中实现统一认证授权,核心就是抓住这几件事:

  1. SecurityFilterChain 替代老配置方式
  2. 登录时通过 AuthenticationManager 完成认证
  3. 认证成功后签发 JWT
  4. 通过自定义过滤器解析 JWT 并写入 SecurityContext
  5. 用 Spring Security 的 URL 规则和方法注解做授权
  6. 统一处理 401/403 返回
  7. 在安全性、可撤销性和性能之间做权衡

如果你现在正准备在项目里落地,我建议按这个顺序推进:

  • 第一步:先做最小可运行链路,确保登录、鉴权、授权都通
  • 第二步:补统一异常、统一响应
  • 第三步:接数据库与角色权限模型
  • 第四步:根据业务要求决定是否上 refresh token、黑名单、网关鉴权

最后给一个很实用的边界建议:
如果你的系统需要“即时失效”和“强会话控制”,不要迷信纯无状态 JWT;如果你的系统更重视前后端分离和横向扩展,JWT 会非常顺手。
技术方案没有绝对优劣,关键是和业务边界匹配。

如果你已经能把本文的代码跑起来,说明你对 Spring Boot 3 下 JWT + Spring Security 6 的主流程已经掌握得差不多了。接下来要做的,就是把这个最小方案,逐步打磨成适合你业务场景的版本。


分享到:

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