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

《Spring Boot 3 实战:基于 Spring Security 与 JWT 的前后端分离鉴权体系搭建与权限控制》

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

Spring Boot 3 实战:基于 Spring Security 与 JWT 的前后端分离鉴权体系搭建与权限控制

前后端分离之后,登录鉴权这件事不再像传统 MVC 那样“放在 Session 里就完事”。接口要无状态、移动端和 Web 端要共用、权限还得细到接口级别,这时候 Spring Security + JWT 基本就是一条很常见也很实用的路线。

这篇文章我不打算只讲概念,而是带你从一个可运行的 Spring Boot 3 项目出发,真正搭一套:

  • 登录接口
  • JWT 签发与校验
  • Spring Security 过滤器链接入
  • 基于角色/权限的接口访问控制
  • 常见报错与排查方式
  • 安全与性能上的实际建议

如果你已经会 Spring Boot,但对 Spring Security 3.x 时代的新写法还不太熟,这篇会比较适合你。


背景与问题

在前后端分离场景里,常见的几个痛点非常典型:

  1. 接口怎么识别用户身份?
    浏览器不再依赖服务端 Session 页面跳转,API 需要自带身份信息。

  2. 怎么做无状态认证?
    服务部署成多实例后,Session 共享会带来额外复杂度。

  3. 权限控制怎么落地?
    比如:

    • 普通用户可以查看资料
    • 管理员可以访问后台接口
    • 某些操作需要 user:createuser:delete 这样的细粒度权限
  4. Spring Boot 3 / Spring Security 6 和旧版本写法差异大
    以前那套 WebSecurityConfigurerAdapter 已经被废弃,很多教程还停留在旧写法,照抄就会踩坑。

所以,这篇文章的目标不是“讲 JWT 是什么”,而是把这些问题串起来,给出一套中级开发者可以直接上手的方案。


前置知识与环境准备

技术栈

  • JDK 17
  • Spring Boot 3.x
  • Spring Security 6
  • Maven
  • jjwt(JWT 生成与解析)
  • Lombok(可选)

本文示例功能

我们实现以下接口:

  • POST /auth/login:登录,返回 JWT
  • GET /api/profile:已登录用户可访问
  • GET /api/admin/hello:管理员角色可访问
  • GET /api/user/list:拥有 user:read 权限才可访问

Maven 依赖

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

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

核心原理

先别急着写代码,先把链路理顺。很多人不是不会写,而是没把整个认证授权过程串起来。

一次 JWT 鉴权请求的完整流程

flowchart TD
    A[用户提交用户名密码] --> B[/auth/login]
    B --> C[AuthenticationManager 认证]
    C --> D[校验用户名/密码]
    D --> E[生成 JWT]
    E --> F[返回 access token]
    F --> G[前端保存 token]
    G --> H[请求业务接口时携带 Authorization Bearer token]
    H --> I[JWT 过滤器解析 token]
    I --> J[SecurityContext 写入认证信息]
    J --> K[Spring Security 做权限判断]
    K --> L[返回业务数据或 401/403]

这个流程里最关键的是两件事:

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

JWT 解决的是“无状态地传递身份信息”,Spring Security 负责“在请求生命周期内识别用户,并做权限决策”。

Spring Security 在这里扮演什么角色

你可以把 Spring Security 理解成一个总控系统:

  • 接管请求过滤链
  • 处理未登录、无权限、认证失败等场景
  • 把登录用户信息放进 SecurityContext
  • 支持 URL 级和方法级权限控制

JWT 里通常放什么

实际项目中,JWT 里一般放这些内容:

  • sub:用户标识,比如用户名
  • iat:签发时间
  • exp:过期时间
  • 自定义 claims:
    • roles
    • permissions

不过我个人建议:JWT 中只放必要信息。如果你把所有用户详情都塞进去,token 会膨胀,而且权限一旦变更,旧 token 仍可能继续生效直到过期。

认证和授权的关系

sequenceDiagram
    participant Client as 前端
    participant Filter as JWT过滤器
    participant Security as Spring Security
    participant Controller as Controller

    Client->>Filter: 携带 Bearer Token 请求
    Filter->>Filter: 解析并验证 JWT
    alt Token 合法
        Filter->>Security: 写入 Authentication
        Security->>Controller: 放行并检查权限
        Controller-->>Client: 返回数据
    else Token 无效/过期
        Filter-->>Client: 401 Unauthorized
    end

认证通过后,Spring Security 才有基础去判断权限。
如果连用户是谁都不知道,那后面的 hasRole()hasAuthority() 都无从谈起。


项目结构设计

为了让示例清晰,我们先约定一个简单结构:

src/main/java/com/example/securityjwt
├── SecurityJwtApplication.java
├── config
│   └── SecurityConfig.java
├── controller
│   ├── AuthController.java
│   └── DemoController.java
├── dto
│   ├── LoginRequest.java
│   └── LoginResponse.java
├── security
│   ├── JwtAuthenticationFilter.java
│   ├── JwtService.java
│   └── UserDetailsServiceImpl.java
└── service
    └── InMemoryUserService.java

为了方便演示,本文先用内存用户,核心逻辑跑通后你再接数据库。


实战代码(可运行)

1)启动类

package com.example.securityjwt;

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

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

2)登录请求与响应 DTO

LoginRequest.java

package com.example.securityjwt.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.securityjwt.dto;

import java.util.List;

public class LoginResponse {
    private String token;
    private String username;
    private List<String> authorities;

    public LoginResponse(String token, String username, List<String> authorities) {
        this.token = token;
        this.username = username;
        this.authorities = authorities;
    }

    public String getToken() {
        return token;
    }

    public String getUsername() {
        return username;
    }

    public List<String> getAuthorities() {
        return authorities;
    }
}

3)JWT 工具服务

JwtService.java

package com.example.securityjwt.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
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;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
public class JwtService {

    // 这里用 Base64 编码后的 256 bit key,生产环境请放到安全配置中心或环境变量
    private static final String SECRET_KEY =
            "Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyMTIzNDU2Nzg5MDEyMw==";

    private static final long EXPIRATION = 1000L * 60 * 60 * 2; // 2小时

    public String generateToken(UserDetails userDetails) {
        Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
        List<String> authorityList = authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList());

        return Jwts.builder()
                .setSubject(userDetails.getUsername())
                .claim("authorities", authorityList)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
                .signWith(getSignInKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public List<String> extractAuthorities(String token) {
        Claims claims = extractAllClaims(token);
        Object authorities = claims.get("authorities");
        if (authorities instanceof List<?>) {
            return ((List<?>) authorities).stream()
                    .map(String::valueOf)
                    .collect(Collectors.toList());
        }
        return List.of();
    }

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

    private boolean isTokenExpired(String token) {
        return extractAllClaims(token).getExpiration().before(new Date());
    }

    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSignInKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    private SecretKey getSignInKey() {
        byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

4)用户服务:先用内存模拟

这里我建议把“用户数据来源”跟 Spring Security 的 UserDetailsService 分开,这样后面你切数据库时不会全改。

InMemoryUserService.java

package com.example.securityjwt.service;

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

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

@Service
public class InMemoryUserService {

    private final Map<String, AppUser> users = new HashMap<>();

    public InMemoryUserService(PasswordEncoder passwordEncoder) {
        users.put("admin", new AppUser(
                "admin",
                passwordEncoder.encode("123456"),
                List.of("ROLE_ADMIN", "ROLE_USER", "user:read", "user:create", "user:delete")
        ));

        users.put("tom", new AppUser(
                "tom",
                passwordEncoder.encode("123456"),
                List.of("ROLE_USER", "user:read")
        ));
    }

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

    public static class AppUser {
        private final String username;
        private final String password;
        private final List<String> authorities;

        public AppUser(String username, String password, List<String> authorities) {
            this.username = username;
            this.password = password;
            this.authorities = authorities;
        }

        public String getUsername() {
            return username;
        }

        public String getPassword() {
            return password;
        }

        public List<String> getAuthorities() {
            return authorities;
        }
    }
}

5)实现 UserDetailsService

UserDetailsServiceImpl.java

package com.example.securityjwt.security;

import com.example.securityjwt.service.InMemoryUserService;
import com.example.securityjwt.service.InMemoryUserService.AppUser;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.stream.Collectors;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final InMemoryUserService inMemoryUserService;

    public UserDetailsServiceImpl(InMemoryUserService inMemoryUserService) {
        this.inMemoryUserService = inMemoryUserService;
    }

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

        return User.builder()
                .username(appUser.getUsername())
                .password(appUser.getPassword())
                .authorities(appUser.getAuthorities().stream()
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList()))
                .build();
    }
}

6)JWT 认证过滤器

这个过滤器的作用是:

  • 从请求头拿 Authorization
  • 提取 Bearer Token
  • 解析用户名
  • 加载用户
  • 校验 token
  • 成功后写入 SecurityContext

JwtAuthenticationFilter.java

package com.example.securityjwt.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
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 UserDetailsServiceImpl userDetailsService;

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

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

        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

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

        String jwt = authHeader.substring(7);
        String username;

        try {
            username = jwtService.extractUsername(jwt);
        } 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(jwt, userDetails)) {
                UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );

                authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

7)Spring Security 配置

Spring Boot 3 下最重要的变化之一:使用 SecurityFilterChain Bean,而不是继承 WebSecurityConfigurerAdapter

SecurityConfig.java

package com.example.securityjwt.config;

import com.example.securityjwt.security.JwtAuthenticationFilter;
import com.example.securityjwt.security.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.annotation.web.configuration.EnableWebSecurity;
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
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserDetailsServiceImpl userDetailsService;

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

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

        return http.build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

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

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

8)登录接口

AuthController.java

package com.example.securityjwt.controller;

import com.example.securityjwt.dto.LoginRequest;
import com.example.securityjwt.dto.LoginResponse;
import com.example.securityjwt.security.JwtService;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/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) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(),
                        request.getPassword()
                )
        );

        org.springframework.security.core.userdetails.UserDetails userDetails =
                (org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal();

        String token = jwtService.generateToken(userDetails);

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

        return new LoginResponse(token, userDetails.getUsername(), authorities);
    }
}

9)受保护接口

DemoController.java

这里我同时演示 URL 级权限控制和方法级权限控制。

package com.example.securityjwt.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
public class DemoController {

    @GetMapping("/api/profile")
    public Map<String, Object> profile(Authentication authentication) {
        return Map.of(
                "message", "这是当前登录用户的信息",
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }

    @GetMapping("/api/admin/hello")
    public Map<String, Object> adminHello() {
        return Map.of("message", "你好,管理员");
    }

    @PreAuthorize("hasAuthority('user:read')")
    @GetMapping("/api/user/list")
    public Map<String, Object> userList() {
        return Map.of(
                "message", "你拥有 user:read 权限",
                "data", java.util.List.of("tom", "jack", "lucy")
        );
    }
}

10)可选:统一异常返回更友好

默认的 401/403 返回有时候不够前后端协作使用,建议统一成 JSON。

SecurityExceptionHandlers.java

package com.example.securityjwt.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;

import java.util.Map;

@Configuration
public class SecurityExceptionHandlers {

    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint() {
        return (request, response, authException) -> {
            response.setStatus(401);
            response.setContentType("application/json;charset=UTF-8");
            new ObjectMapper().writeValue(response.getWriter(),
                    Map.of("code", 401, "message", "未登录或 Token 无效"));
        };
    }

    @Bean
    public AccessDeniedHandler accessDeniedHandler() {
        return (request, response, accessDeniedException) -> {
            response.setStatus(403);
            response.setContentType("application/json;charset=UTF-8");
            new ObjectMapper().writeValue(response.getWriter(),
                    Map.of("code", 403, "message", "无权限访问"));
        };
    }
}

然后把它们接入 SecurityConfig

package com.example.securityjwt.config;

import com.example.securityjwt.security.JwtAuthenticationFilter;
import com.example.securityjwt.security.UserDetailsServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
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.annotation.web.configuration.EnableWebSecurity;
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.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final UserDetailsServiceImpl userDetailsService;
    private final AuthenticationEntryPoint authenticationEntryPoint;
    private final AccessDeniedHandler accessDeniedHandler;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
                          UserDetailsServiceImpl userDetailsService,
                          AuthenticationEntryPoint authenticationEntryPoint,
                          AccessDeniedHandler accessDeniedHandler) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.userDetailsService = userDetailsService;
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.accessDeniedHandler = accessDeniedHandler;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .cors(Customizer.withDefaults())
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/login").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/admin/**").hasRole("ADMIN")
                        .requestMatchers(HttpMethod.GET, "/api/user/**").hasAuthority("user:read")
                        .anyRequest().authenticated()
                )
                .authenticationProvider(authenticationProvider())
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

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

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

逐步验证清单

代码写完后,不要一把梭直接说“怎么 403 了”。我一般会按这个顺序验证。

第一步:登录获取 Token

请求:

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

响应示例:

{
  "token": "eyJhbGciOiJIUzI1NiJ9.xxx.yyy",
  "username": "tom",
  "authorities": ["ROLE_USER", "user:read"]
}

第二步:访问登录用户接口

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

第三步:验证管理员接口

使用 tom 的 token 访问:

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

预期结果:403

使用 admin 登录后再访问,预期结果:200

第四步:验证权限接口

使用 tomadmin 的 token 访问:

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

预期结果:都能访问,因为两者都有 user:read


权限控制怎么设计更合理

很多团队在这里容易混淆 ROLE_ 和普通权限字符串,我建议分层设计。

角色与权限的建议分工

  • 角色(Role):偏粗粒度,面向身份

    • ROLE_ADMIN
    • ROLE_USER
  • 权限(Authority/Permission):偏细粒度,面向动作

    • user:read
    • user:create
    • order:refund

一个常见关系是:

  • 管理员角色 -> 拥有多个权限
  • 普通角色 -> 拥有有限权限

为什么 hasRole("ADMIN") 对应的是 ROLE_ADMIN

因为 Spring Security 对 hasRole("ADMIN") 会自动加前缀 ROLE_
所以你的用户权限里应该存的是:

ROLE_ADMIN

而不是单独的:

ADMIN

这真的是高频坑,我自己第一次切 Spring Security 时也在这里卡过半天。

一张关系图看明白

classDiagram
    class User {
        +String username
        +String password
    }

    class Role {
        +String code
    }

    class Permission {
        +String code
    }

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

如果你接数据库,最终一般会把:

  • 用户 -> 角色
  • 角色 -> 权限

查出来后,统一转成 GrantedAuthority 集合交给 Spring Security。


常见坑与排查

这一部分非常重要。很多“代码看起来没问题”的问题,其实都在细节里。

1)明明登录成功了,请求接口还是 401

常见原因

  • 没带 Authorization 请求头
  • 请求头格式不对,少了 Bearer
  • token 已过期
  • JWT 密钥不一致
  • 过滤器没有加入过滤链

排查建议

先打印请求头:

System.out.println(request.getHeader("Authorization"));

确认格式必须像这样:

Authorization: Bearer eyJhbGciOi...

再确认过滤器是否执行到了:

System.out.println("JwtAuthenticationFilter executed");

2)接口返回 403,不是 401

这说明一件事:你已经登录了,但权限不足

常见原因

  • hasRole("ADMIN") 但用户只有 ADMIN,没有 ROLE_ADMIN
  • hasAuthority("user:read") 但 token 里的权限字符串不一致
  • URL 规则和方法注解同时存在,结果被更严格规则拦了

排查建议

打印当前用户权限:

authentication.getAuthorities()

如果结果不是你预期的那组权限,优先排查:

  • UserDetailsService 是否加载正确
  • JWT 是否包含你想要的权限
  • JWT 过滤器里是否真的把认证信息写进了 SecurityContext

3)密码明明一样,登录总是失败

常见原因

  • 你数据库里存的是明文,但配置了 BCryptPasswordEncoder
  • 你每次启动都重新编码,拿新 hash 去比旧 hash
  • 前端传值字段不对

正确理解

BCrypt 每次编码结果都可能不同,但 matches(raw, encoded) 仍然能验证通过。
所以不要自己拿两个编码后的字符串做 equals


4)跨域请求失败,前端说被 CORS 拦住了

如果你的前端和后端不是同域,JWT 带请求头时很容易碰到 CORS 问题。

可以加一个简单的 CORS 配置:

package com.example.securityjwt.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.cors.CorsConfigurationSource;

import java.util.List;

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:3000"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setExposedHeaders(List.of("Authorization"));
        config.setAllowCredentials(false);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

如果你是纯 JWT + Header 模式,通常不需要 allowCredentials(true)


5)升级到 Spring Boot 3 后旧教程代码全失效

这个问题太常见了。

典型失效点

  • WebSecurityConfigurerAdapter 不再推荐使用
  • antMatchers() 改为 requestMatchers()
  • javax.servlet.* 变成 jakarta.servlet.*

解决思路

看到旧教程时先做三件事:

  1. 查 Spring Security 版本
  2. 查 Spring Boot 版本
  3. 看它是不是基于 Boot 2.x 写的

如果版本代际不一致,照抄大概率会出问题。


安全/性能最佳实践

JWT 很方便,但不是“用了就安全”。下面这些建议我认为比“会写代码”更重要。

1)不要把敏感信息放进 JWT

JWT 的 payload 只是 Base64Url 编码,不是加密。
所以不要放:

  • 明文密码
  • 手机号全量
  • 身份证号
  • 银行卡信息

建议只放最小必要信息,比如 sub 和少量权限标识。


2)Access Token 要短有效期

我更推荐:

  • Access Token:15 分钟 ~ 2 小时
  • Refresh Token:更长,比如 7 天

本文为了聚焦主线,只实现了 access token。
如果是正式系统,建议补上 refresh token 机制,而不是把 access token 过期时间拉很长。


3)密钥一定要妥善管理

不要把密钥硬编码在代码仓库里。生产环境建议放在:

  • 环境变量
  • Kubernetes Secret
  • 配置中心
  • 云 KMS / Secret Manager

如果密钥泄露,攻击者可以伪造合法 token,这个风险是致命的。


4)考虑 Token 失效与踢下线问题

JWT 是无状态的,但这也带来一个经典问题:
服务端默认无法“立即废掉”已签发 token。

常见做法有:

  • 缩短 access token 生命周期
  • 配合 refresh token
  • 使用 Redis 做 token 黑名单
  • 在 token 中带版本号,用户被禁用后提升版本使旧 token 失效

如果你的业务有强制下线、账号封禁、修改密码后立即失效的需求,这个点必须提前设计。


5)权限不要完全只信任 JWT

理论上可以把权限列表直接放 JWT 里,但生产中要看场景:

  • 如果权限变化不频繁,可以接受 token 周期内的延迟生效
  • 如果权限变化非常频繁,建议每次请求从缓存/数据库动态获取最新权限,或者做 token 版本校验

这本质上是“性能和一致性”的平衡问题。


6)接口层之外,方法级权限控制更稳

只在 URL 上做权限校验,后期很容易漏。
我更建议:

  • 网关/过滤链做基础鉴权
  • Controller 或 Service 上做 @PreAuthorize
  • 关键操作再做业务层校验

这样即便接口路径变化,也不容易出现“URL 改了但权限漏配”的问题。


7)给认证链路加日志,但别打敏感信息

建议记录:

  • 登录成功/失败
  • token 解析失败次数
  • 权限拒绝事件
  • 异常 IP / 用户行为

但不要记录:

  • 明文密码
  • 完整 JWT
  • 敏感个人信息

日志里最多打印 token 前几位做定位即可。


可扩展方向:接数据库应该怎么改

如果你要把示例升级成真实项目,通常改这几处就够了:

数据表设计建议

最基础的 RBAC 设计:

  • sys_user
  • sys_role
  • sys_permission
  • sys_user_role
  • sys_role_permission

后端改造点

  1. InMemoryUserService 替换为数据库查询
  2. 登录后从数据库查出角色和权限
  3. 组装成 GrantedAuthority
  4. JWT 中只保留必要标识
  5. 高频权限可放 Redis 缓存

一般查询流程

flowchart LR
    A[username] --> B[查询用户]
    B --> C[查询用户角色]
    C --> D[查询角色权限]
    D --> E[组装 GrantedAuthority]
    E --> F[交给 Spring Security]

如果用户量大、权限关系复杂,这一段建议缓存,不然每次认证都打数据库会比较重。


一个更贴近项目的落地建议

如果你是团队里要真正把方案落地的人,我建议按下面的优先级推进:

第一阶段:先跑通主链路

  • 登录成功
  • JWT 发放
  • 业务接口能识别当前用户
  • 401 和 403 区分清楚

第二阶段:补权限模型

  • 角色
  • 权限点
  • @PreAuthorize
  • 管理端菜单权限映射

第三阶段:补生产能力

  • refresh token
  • Redis 黑名单
  • 登录审计日志
  • 密钥安全管理
  • 限流与暴力破解防护

很多系统之所以后面难维护,不是因为技术选型错,而是第一版把“演示代码”直接当“生产方案”用了。


总结

这篇文章我们用 Spring Boot 3 + Spring Security 6 + JWT 搭了一套前后端分离鉴权体系,核心落地点有这几个:

  • 使用 SecurityFilterChain 配置 Spring Security
  • AuthenticationManager 完成用户名密码认证
  • 用 JWT 保存无状态登录凭证
  • 用自定义过滤器在每次请求中恢复用户身份
  • 同时支持角色控制和细粒度权限控制
  • 区分好 401(未认证)和 403(无权限)
  • 提前考虑 token 失效、权限变更和密钥管理

如果你现在只是要做一个中小型后台系统,这套方案已经足够实用。
但边界条件也要明确:

  • 权限变更需要立刻生效:仅靠纯 JWT 不够,要加缓存校验或黑名单
  • 需要多端长期登录:建议引入 refresh token
  • 安全要求高:补上登录限流、设备管理、审计日志和密钥轮换

最后给一个很实用的建议:
先把“认证链路跑通”,再去追求“权限设计优雅”
因为认证没跑通时,你看到的所有 401/403 都像一团雾;一旦主链路清晰了,后面的角色、权限、菜单、数据范围控制,都会顺很多。


分享到:

上一篇
《区块链中智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-297》
下一篇
《安卓逆向实战:从 SO 层入手定位并绕过常见签名校验与反调试机制》