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

《Spring Boot 3 中基于 JWT 与 Spring Security 6 的权限认证实战:从登录鉴权到接口级访问控制》

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

Spring Boot 3 中基于 JWT 与 Spring Security 6 的权限认证实战:从登录鉴权到接口级访问控制

很多团队一上来做后端权限系统,第一反应是“先把登录做出来”。结果登录能用,接口却开始混乱:

  • 有的接口谁都能调
  • 有的接口 token 明明没过期却返回 401
  • 角色权限写死在代码里,后面越改越痛苦
  • Spring Boot 2 升到 3 之后,之前的 WebSecurityConfigurerAdapter 全部失效

这篇文章我想带你完整走一遍:在 Spring Boot 3 + Spring Security 6 中,用 JWT 实现登录认证、请求鉴权,以及接口级访问控制。重点不是“把代码贴出来”,而是让你知道每一层到底在干什么,出了问题从哪里查。

文章会给出一套可运行的最小示例,适合你直接改造成自己的项目。


背景与问题

在传统的 Session 模式里,服务端需要保存登录状态。这个方案本身没问题,但在下面这些场景里会显得笨重:

  • 前后端分离
  • 多服务部署
  • 网关转发
  • 移动端 / 小程序 / 第三方调用
  • 想让认证信息天然支持“无状态”

于是很多项目会选择 JWT(JSON Web Token):

  • 用户登录成功后,服务端签发 token
  • 客户端后续请求都带上 token
  • 服务端验证 token 是否有效,并从中恢复用户身份与权限

听起来很顺,但真正落地时会遇到两个典型问题:

  1. 认证和授权混在一起

    • 认证:你是谁
    • 授权:你能访问什么
  2. Spring Security 6 配置方式变了

    • 不再用 WebSecurityConfigurerAdapter
    • 要通过 SecurityFilterChain、Bean 配置和过滤器链来组织

如果你只会“抄一段配置”,后面很容易在 401/403、过滤器顺序、权限表达式这些地方卡住。


前置知识与环境准备

本文示例基于以下环境:

  • JDK 17+
  • Spring Boot 3.x
  • Spring Security 6
  • Maven
  • JWT 库:jjwt
  • 密码加密:BCryptPasswordEncoder

我们要实现的目标

  1. 用户访问 /api/auth/login,提交用户名密码
  2. 后端校验成功后返回 JWT
  3. 客户端请求受保护接口时在 Header 中带上 token
  4. Spring Security 解析 token,恢复用户身份
  5. 根据角色或权限控制接口访问

核心原理

先别急着写代码,先把整个链路捋顺。

1. 认证流程

sequenceDiagram
    participant C as Client
    participant A as AuthController
    participant AM as AuthenticationManager
    participant U as UserDetailsService
    participant J as JwtService

    C->>A: POST /api/auth/login 用户名/密码
    A->>AM: authenticate(username, password)
    AM->>U: loadUserByUsername()
    U-->>AM: UserDetails(含角色权限)
    AM-->>A: 认证成功
    A->>J: 生成 JWT
    J-->>A: token
    A-->>C: 返回 token

2. 请求鉴权流程

flowchart LR
    A[客户端请求] --> B[JwtAuthenticationFilter]
    B --> C{Header中有Bearer Token?}
    C -- 否 --> D[匿名访问/进入后续鉴权]
    C -- 是 --> E[解析并校验JWT]
    E --> F{Token有效?}
    F -- 否 --> G[返回401]
    F -- 是 --> H[构造Authentication放入SecurityContext]
    H --> I[Spring Security执行权限判断]
    I --> J{有权限?}
    J -- 否 --> K[返回403]
    J -- 是 --> L[进入Controller]

3. 认证与授权的边界

这个边界一定要清楚:

  • 认证 Authentication

    • 校验用户名密码
    • 校验 token 是否有效
    • 得出“当前用户是谁”
  • 授权 Authorization

    • 当前用户是否具备 ROLE_ADMIN
    • 当前用户是否拥有 sys:user:list
    • 是否允许访问某个 URL 或方法

4. JWT 里通常放什么

一般会放这些字段:

  • sub:用户名或用户唯一标识
  • iat:签发时间
  • exp:过期时间
  • 自定义 claims:
    • roles
    • userId
    • permissions

不过我个人建议:

  • 角色可以放
  • 细粒度权限是否放 token,要看场景
    • 放进去:减少查库,但权限变更不能立刻生效
    • 不放:每次查库更实时,但开销更大

教程里我们先放角色,保持实现清晰。


项目结构

一个比较实用的结构如下:

src/main/java/com/example/securitydemo
├── SecurityDemoApplication.java
├── config
│   └── SecurityConfig.java
├── controller
│   ├── AuthController.java
│   └── UserController.java
├── dto
│   ├── LoginRequest.java
│   └── LoginResponse.java
├── entity
│   └── LoginUser.java
├── filter
│   └── JwtAuthenticationFilter.java
├── service
│   ├── CustomUserDetailsService.java
│   └── JwtService.java
└── exception
    └── RestAuthenticationEntryPoint.java

实战代码(可运行)

下面给出一个最小可运行版本。为了让重点放在安全链路上,这里先用内存用户模拟用户数据。

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>security-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>
    </dependencies>
</project>

2. application.yml

JWT 密钥建议放配置中心或环境变量,这里先放配置文件演示。

server:
  port: 8080

jwt:
  secret: 01234567890123456789012345678901
  expiration: 3600000

secret 至少要足够长。HS256 下太短会直接报错。


3. 启动类

package com.example.securitydemo;

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

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

4. DTO

LoginRequest.java

package com.example.securitydemo.dto;

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

LoginResponse.java

package com.example.securitydemo.dto;

public class LoginResponse {
    private String token;
    private String tokenType = "Bearer";

    public LoginResponse() {
    }

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

    public String getToken() {
        return token;
    }

    public String getTokenType() {
        return tokenType;
    }

    public void setToken(String token) {
        this.token = token;
    }

    public void setTokenType(String tokenType) {
        this.tokenType = tokenType;
    }
}

5. 自定义用户对象

这里直接继承 Spring Security 的 User 也可以,但为了说明结构,我单独封装一个用户类。

LoginUser.java

package com.example.securitydemo.entity;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;

public class LoginUser implements UserDetails {

    private final String username;
    private final String password;
    private final Collection<? extends GrantedAuthority> authorities;

    public LoginUser(String username, String password,
                     Collection<? extends GrantedAuthority> authorities) {
        this.username = username;
        this.password = password;
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

6. UserDetailsService

我们先用内存模拟两个用户:

  • admin / 123456,角色 ADMIN
  • user / 123456,角色 USER

CustomUserDetailsService.java

package com.example.securitydemo.service;

import com.example.securitydemo.entity.LoginUser;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("admin".equals(username)) {
            return new LoginUser(
                    "admin",
                    "$2a$10$DowJonesIndexf8mQv8nM9uM2KQ4x1vYl6k5YvVxj6QeQ1B1Xx7G", // 123456
                    List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
            );
        }
        if ("user".equals(username)) {
            return new LoginUser(
                    "user",
                    "$2a$10$DowJonesIndexf8mQv8nM9uM2KQ4x1vYl6k5YvVxj6QeQ1B1Xx7G", // 123456
                    List.of(new SimpleGrantedAuthority("ROLE_USER"))
            );
        }
        throw new UsernameNotFoundException("用户不存在");
    }
}

上面的加密串为了演示不够稳妥。为了避免你复制后因为密文不匹配踩坑,我更建议你直接在配置类里打印一个新的 BCrypt 值,或者换成下面这个方式:启动时统一生成测试用户。

更可靠的版本如下。

CustomUserDetailsService.java(建议使用)

package com.example.securitydemo.service;

import com.example.securitydemo.entity.LoginUser;
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 new LoginUser(
                    "admin",
                    passwordEncoder.encode("123456"),
                    List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
            );
        }
        if ("user".equals(username)) {
            return new LoginUser(
                    "user",
                    passwordEncoder.encode("123456"),
                    List.of(new SimpleGrantedAuthority("ROLE_USER"))
            );
        }
        throw new UsernameNotFoundException("用户不存在");
    }
}

不过这里又有一个问题:每次查询都会重新 encode,导致密码比对失败。这是我见过很多人第一次写 demo 时都会踩的坑。

所以最终正确写法是:预先保存加密后的密码,而不是每次查询再加密

正确版 CustomUserDetailsService.java

package com.example.securitydemo.service;

import com.example.securitydemo.entity.LoginUser;
import jakarta.annotation.PostConstruct;
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;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final PasswordEncoder passwordEncoder;
    private final Map<String, UserDetails> userStore = new ConcurrentHashMap<>();

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

    @PostConstruct
    public void init() {
        userStore.put("admin", new LoginUser(
                "admin",
                passwordEncoder.encode("123456"),
                List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
        ));
        userStore.put("user", new LoginUser(
                "user",
                passwordEncoder.encode("123456"),
                List.of(new SimpleGrantedAuthority("ROLE_USER"))
        ));
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        UserDetails user = userStore.get(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在");
        }
        return user;
    }
}

7. JWT 服务

JwtService.java

package com.example.securitydemo.service;

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

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

@Service
public class JwtService {

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

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

    private SecretKey getSignKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

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

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

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

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

    public boolean isTokenValid(String token, UserDetails userDetails) {
        final 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();
    }
}

8. JWT 过滤器

这个过滤器负责:

  • 从请求头取出 token
  • 校验 token
  • 加载用户信息
  • 构造 Authentication
  • 放入 SecurityContext

JwtAuthenticationFilter.java

package com.example.securitydemo.filter;

import com.example.securitydemo.service.CustomUserDetailsService;
import com.example.securitydemo.service.JwtService;
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.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(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        String token = null;
        String username = null;

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

        token = authHeader.substring(7);

        try {
            username = jwtService.extractUsername(token);
        } catch (Exception e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"message\":\"无效或过期的Token\"}");
            return;
        }

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

            if (jwtService.isTokenValid(token, userDetails)) {
                UsernamePasswordAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );
                authenticationToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

9. 认证失败处理器

默认返回内容对前后端分离不太友好,我们自定义一下 401 响应。

RestAuthenticationEntryPoint.java

package com.example.securitydemo.exception;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException)
            throws IOException, ServletException {

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"message\":\"未认证,请先登录\"}");
    }
}

10. Security 配置

这是 Spring Security 6 的核心配置点。

SecurityConfig.java

package com.example.securitydemo.config;

import com.example.securitydemo.exception.RestAuthenticationEntryPoint;
import com.example.securitydemo.filter.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 RestAuthenticationEntryPoint authenticationEntryPoint;

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

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .cors(Customizer.withDefaults())
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .exceptionHandling(exception ->
                        exception.authenticationEntryPoint(authenticationEntryPoint))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/login").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
                        .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();
    }
}

11. 登录接口

AuthController.java

package com.example.securitydemo.controller;

import com.example.securitydemo.dto.LoginRequest;
import com.example.securitydemo.dto.LoginResponse;
import com.example.securitydemo.service.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.security.core.AuthenticationException;
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 LoginRequest request) {
        try {
            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);
        } catch (AuthenticationException e) {
            throw new RuntimeException("用户名或密码错误");
        }
    }
}

生产环境里别直接抛 RuntimeException,最好统一异常处理,这里先聚焦主线。


12. 受保护资源接口

UserController.java

package com.example.securitydemo.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("/user/profile")
    public Map<String, Object> profile(Authentication authentication) {
        return Map.of(
                "message", "这是用户资料接口",
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }

    @GetMapping("/admin/dashboard")
    public Map<String, Object> admin(Authentication authentication) {
        return Map.of(
                "message", "这是管理员面板",
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/user/list")
    public Map<String, Object> listUsers() {
        return Map.of(
                "message", "只有 ADMIN 才能看到用户列表"
        );
    }
}

接口级访问控制怎么理解

上面的控制其实分成了两层。

第一层:URL 级控制

SecurityConfig 里:

.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")

适合控制“这一类接口谁能访问”。

第二层:方法级控制

在 Controller 或 Service 上:

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

适合控制“同一类接口里,某个具体操作更严格”。

我自己的建议是:

  • URL 级:做大范围拦截
  • 方法级:做关键操作兜底

别只依赖前者。因为项目一大,路由规则很容易漏。


一次完整验证

1. 登录获取 token

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

返回示例:

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

2. 访问用户接口

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

3. 访问管理员接口

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

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

如果你用 user/123456 登录,再访问 /api/admin/dashboard,应该得到 403 Forbidden


逐步验证清单

如果你想确保每一步都正确,建议按下面的顺序验:

  • /api/auth/login 不带 token 也能访问
  • 登录成功能拿到 JWT
  • 不带 token 访问 /api/user/profile 返回 401
  • 带合法 token 访问 /api/user/profile 返回 200
  • 普通用户访问 /api/admin/** 返回 403
  • 管理员访问 /api/admin/** 返回 200
  • token 过期后访问返回 401

这个清单很简单,但真能帮你快速定位是“登录没通”、还是“过滤器没生效”、还是“权限规则写错了”。


更深入一点:类之间的关系

classDiagram
    class AuthController {
        +login(LoginRequest) LoginResponse
    }

    class JwtService {
        +generateToken(UserDetails) String
        +extractUsername(String) String
        +isTokenValid(String, UserDetails) boolean
    }

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

    class CustomUserDetailsService {
        +loadUserByUsername(String) UserDetails
    }

    class SecurityConfig {
        +securityFilterChain(HttpSecurity) SecurityFilterChain
        +passwordEncoder() PasswordEncoder
        +authenticationManager(AuthenticationConfiguration) AuthenticationManager
    }

    AuthController --> JwtService
    AuthController --> SecurityConfig
    JwtAuthenticationFilter --> JwtService
    JwtAuthenticationFilter --> CustomUserDetailsService
    SecurityConfig --> JwtAuthenticationFilter

常见坑与排查

这部分非常重要。我自己做这类认证系统时,真正耗时间的往往不是写代码,而是排这些“看起来差不多、实际上差很多”的问题。

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

先查这几个点:

检查请求头格式

必须是:

Authorization: Bearer xxxxx

注意:

  • Bearer 后面有空格
  • Header 名是 Authorization
  • token 不要多带引号

检查过滤器是否加入链路

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

如果没加,或者顺序不对,Spring Security 根本不会解析你的 token。

检查 SecurityContext 是否成功写入

过滤器里这一段是关键:

SecurityContextHolder.getContext().setAuthentication(authenticationToken);

没写进去,后续就还是匿名用户。


2. 返回 403,而不是 401

很多人会混淆:

  • 401 Unauthorized:你还没被认证
  • 403 Forbidden:你已经认证了,但没权限

比如:

  • token 缺失 / 无效 / 过期 -> 一般是 401
  • 用户是 ROLE_USER,访问 ROLE_ADMIN 接口 -> 403

如果你明明没登录却拿到 403,通常要查:

  • 是否启用了匿名用户
  • 是否异常处理配置不完整
  • 是否请求已被识别成“已认证但权限不足”

3. hasRole("ADMIN")hasAuthority("ROLE_ADMIN") 搞混

这是 Spring Security 里经典老坑。

写法一

hasRole("ADMIN")

Spring 内部会自动补成:

ROLE_ADMIN

写法二

hasAuthority("ROLE_ADMIN")

这里就要你自己写全。

所以如果你的权限数据存的是:

ADMIN

hasRole("ADMIN") 可以,hasAuthority("ADMIN") 才匹配;
如果你存的是:

ROLE_ADMIN

hasAuthority("ROLE_ADMIN") 可以,hasRole("ADMIN") 也可以;
但千万别写成:

hasRole("ROLE_ADMIN")

这样会变成匹配 ROLE_ROLE_ADMIN


4. 密码明明对,还是认证失败

最常见原因有两个:

原因一:每次查询用户时重新 encode

错误示例:

passwordEncoder.encode("123456")

如果每次加载用户都新生成密文,认证时一定失败。因为 BCrypt 每次加密结果都不同。

原因二:数据库里存的是明文,但你启用了 BCrypt

Spring Security 会用 PasswordEncoder.matches(raw, encoded) 比对。
如果库里不是合法密文,会直接失败。


5. token 没过期,但解析报错

常见原因:

  • 密钥变了
  • token 被截断
  • 本地和线上配置不一致
  • 使用了过短的 secret

尤其是在多环境部署时,我建议你把 JWT 密钥统一放环境变量,而不是手动写死在配置文件里。


6. 升级到 Spring Boot 3 后老配置不能用了

如果你之前写过这样的代码:

extends WebSecurityConfigurerAdapter

那在 Spring Security 6 里已经不推荐甚至不可用了。要改成:

  • 定义 SecurityFilterChain
  • 定义 AuthenticationManager Bean
  • 使用 Lambda DSL 配置 HttpSecurity

这不是“语法升级”那么简单,它直接影响你理解整个安全配置的组织方式。


安全/性能最佳实践

JWT 很方便,但也不是“上了就完事”。下面这些建议,基本都能直接用到生产环境。

1. token 过期时间不要过长

如果 access token 给 7 天、30 天,一旦泄露,风险很大。

建议:

  • access token:15 分钟 ~ 2 小时
  • refresh token:7 天 ~ 30 天

本文示例只做了 access token,生产里通常还会搭配 refresh token 机制。


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

JWT 虽然签名后不能随便篡改,但默认不是加密的
也就是说,别人拿到 token 后,完全可以解码出 payload。

所以不要放:

  • 明文密码
  • 手机号
  • 身份证号
  • 银行卡号
  • 过多内部业务数据

3. 权限变化频繁时,不要完全依赖 token 内权限

如果你把权限全部塞进 token,会有一个天然问题:

用户权限在后台改了,但旧 token 还没过期。

此时权限不会立刻生效。

适合放 token 的内容:

  • 用户 ID
  • 用户名
  • 稳定角色

不太适合长期放 token 的内容:

  • 高频变化的菜单权限
  • 临时授权状态
  • 租户切换上下文

4. 配合黑名单或版本号机制处理强制下线

JWT 是无状态的,这也是它的优点和短板:

  • 优点:不用查 session
  • 短板:签发出去后,天然不容易“立即作废”

常见做法有:

  • Redis 黑名单:记录被注销的 token
  • 用户 tokenVersion:数据库里版本号变了,旧 token 失效
  • 缩短 access token 生命周期 + refresh token 轮换

5. 统一异常响应

前后端分离项目里,建议统一返回格式,比如:

{
  "code": 401,
  "message": "未认证,请先登录",
  "data": null
}

这样前端可以统一处理跳转登录、刷新 token、弹错误提示。


6. 尽量把权限控制下沉到 Service 层关键方法

Controller 上写权限很直观,但如果某个 Service 方法既被 HTTP 调用,也被内部任务调用,Controller 上的限制就不够了。

更稳妥的做法:

  • Controller 做入口控制
  • Service 做关键业务控制

例如:

@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) {
    ...
}

7. 过滤器里不要做太重的逻辑

JWT 过滤器应尽量只做几件事:

  • 取 token
  • 校验 token
  • 恢复身份

不要在过滤器里塞:

  • 大量数据库查询
  • 复杂权限聚合
  • 业务逻辑处理

否则会影响每个请求的性能,也会让排查成本非常高。


生产化改造建议

本文用的是内存用户,真正落地时你大概率会这样演进:

从内存用户切到数据库

你可以把 CustomUserDetailsService 改成查库:

  • 用户表 sys_user
  • 角色表 sys_role
  • 用户角色关系表 sys_user_role
  • 权限表 sys_permission
  • 角色权限关系表 sys_role_permission

典型查询流程:

  1. 根据用户名查用户
  2. 查用户关联角色
  3. 查角色关联权限
  4. 转成 GrantedAuthority

角色和权限都可以映射成 authority:

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

然后你就可以这样写:

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

这会比单纯角色控制更细。


一个更贴近实际的权限设计思路

如果你的系统已经开始变复杂,我建议采用下面这套分层:

  • 认证层

    • 登录
    • token 签发
    • token 校验
  • 角色层

    • ADMIN / USER / AUDITOR 等
  • 权限层

    • order:create
    • order:query
    • order:refund
  • 资源层

    • URL
    • 方法
    • 菜单
    • 按钮

很多中型系统最后都会走到“角色 + 权限”并存的设计。
角色适合做粗粒度控制,权限适合做细粒度控制。两者不是二选一。


总结

Spring Boot 3 + Spring Security 6 下做 JWT 认证,真正关键的就三件事:

  1. 登录时完成认证,签发 JWT
  2. 请求时通过过滤器校验 JWT,恢复用户身份
  3. 通过 URL 规则和方法注解完成授权控制

你可以把本文的实现记成一个最小闭环:

  • AuthenticationManager:负责用户名密码认证
  • JwtService:负责生成和解析 token
  • JwtAuthenticationFilter:负责请求时恢复身份
  • SecurityFilterChain:负责整体安全策略
  • @PreAuthorize:负责接口级/方法级权限控制

如果你准备把这套方案用到实际项目,我建议按这个顺序落地:

  1. 先跑通登录与 token 校验
  2. 再加 URL 级访问控制
  3. 再加方法级权限注解
  4. 最后接数据库、Redis、刷新 token、黑名单

边界条件也要明确:

  • 小型单体项目:JWT 未必一定优于 Session
  • 多服务、前后端分离、移动端场景:JWT 更常见
  • 权限变化很频繁:别把所有权限都硬塞进 token

一句话收尾:JWT 只是认证载体,真正决定系统是否可靠的,是你如何组织 Spring Security 的认证链、权限模型和异常处理。

如果你按这篇文章把示例跑通,再去接数据库权限表,理解会稳很多。


分享到:

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