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

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

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

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

很多团队做 Spring Boot 项目时,认证授权这块一开始都很“朴素”:

  • 登录接口校验用户名密码
  • 成功后把用户信息放 Session
  • Controller 里手动判断角色
  • 接口越来越多后,权限逻辑散落一地

项目小的时候还能忍,项目一旦拆前后端、接移动端、接第三方,问题就开始集中爆发:

  • Session 不方便做分布式扩展
  • 接口权限控制容易失控
  • 登录态难以跨服务传递
  • 鉴权逻辑分散,维护成本高

这篇文章我就带你完整走一遍:在 Spring Boot 里用 Spring Security + JWT 实现一套比较主流的权限认证方案,从登录签发 Token,到请求鉴权,再到接口级访问控制。文章会尽量从“能跑起来、能定位问题、能落地上线”的角度来讲,而不是只贴几段配置。


一、背景与问题

在前后端分离架构里,服务端通常不再依赖传统 Session,而是采用 无状态认证

  1. 用户登录,提交用户名密码
  2. 服务端校验成功后,签发 JWT
  3. 前端保存 JWT
  4. 后续请求在 Header 中携带 Token
  5. 服务端解析 JWT,恢复用户身份和权限
  6. Spring Security 根据当前认证信息完成接口访问控制

这种方式的好处很直接:

  • 天然适合前后端分离
  • 对分布式和微服务更友好
  • 服务端不必维护 Session 状态
  • 认证与授权逻辑更集中

但它也不是“配上就完事”:

  • Token 放哪里更安全?
  • JWT 里该存什么,不该存什么?
  • Spring Security 的过滤器链怎么接?
  • 角色和权限到底应该怎么建模?
  • 401 和 403 的区别怎么处理?
  • Token 过期、刷新、踢下线怎么做?

这些正是实战里最常见的问题。


二、前置知识与环境准备

1. 技术栈

本文示例基于:

  • JDK 17
  • Spring Boot 3.x
  • Spring Security 6.x
  • jjwt 0.11.x

2. 我们要实现的目标

最终效果如下:

  • /auth/login:匿名可访问,登录后返回 JWT
  • /api/user/profile:登录用户可访问
  • /api/admin/dashboard:只有 ADMIN 角色可访问
  • 请求未登录返回 401
  • 已登录但权限不足返回 403

3. 示例角色设计

为了讲清楚,我们先用最小模型:

  • 用户 alice:角色 USER
  • 用户 admin:角色 ADMIN

实际项目中一般会拆成:

  • 用户(User)
  • 角色(Role)
  • 权限(Permission)
  • 用户角色关联
  • 角色权限关联

本文先聚焦“能跑通主链路”。


三、核心原理

先不要急着写代码,先把链路想清楚。

1. 认证与授权分别是什么

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

在 Spring Security 里:

  • 登录时校验用户名密码,属于认证
  • 访问接口时根据角色/权限判断,属于授权

2. JWT 在链路中的位置

JWT 本质上是一个签名令牌,通常包含:

  • 用户标识
  • 用户名
  • 角色/权限
  • 过期时间

服务端收到请求后:

  1. 从 Header 里取出 Token
  2. 校验签名与过期时间
  3. 解析出用户身份和权限
  4. 封装成 Authentication
  5. 放入 SecurityContext

后续 Spring Security 才知道“当前是谁”。

3. 整体请求流程图

flowchart TD
    A[客户端登录 /auth/login] --> B[AuthenticationManager 校验用户名密码]
    B --> C{校验成功?}
    C -- 否 --> D[返回 401]
    C -- 是 --> E[生成 JWT]
    E --> F[客户端保存 Token]

    F --> G[客户端访问受保护接口]
    G --> H[JWT 过滤器解析 Authorization Header]
    H --> I{Token 合法?}
    I -- 否 --> J[返回 401]
    I -- 是 --> K[构造 Authentication 放入 SecurityContext]
    K --> L[Spring Security 做权限判断]
    L --> M{有权限?}
    M -- 否 --> N[返回 403]
    M -- 是 --> O[执行 Controller]

4. Spring Security 关键组件

这几个类你需要有基本概念:

  • UserDetailsService:根据用户名加载用户
  • PasswordEncoder:密码加密与校验
  • AuthenticationManager:执行登录认证
  • SecurityFilterChain:安全过滤器配置
  • OncePerRequestFilter:自定义 JWT 解析过滤器
  • SecurityContextHolder:保存当前请求的认证信息

5. 时序图:一次完整的鉴权过程

sequenceDiagram
    participant C as Client
    participant A as AuthController
    participant AM as AuthenticationManager
    participant U as UserDetailsService
    participant J as JwtService
    participant F as JwtAuthenticationFilter
    participant S as SecurityContext
    participant API as Protected API

    C->>A: POST /auth/login 用户名密码
    A->>AM: authenticate()
    AM->>U: loadUserByUsername()
    U-->>AM: UserDetails
    AM-->>A: 认证成功
    A->>J: generateToken()
    J-->>A: JWT
    A-->>C: 返回 Token

    C->>F: GET /api/user/profile + Authorization Bearer JWT
    F->>J: validateToken()
    J-->>F: Token 有效
    F->>S: 设置 Authentication
    F->>API: 放行请求
    API-->>C: 返回业务数据

四、实战代码(可运行)

下面给出一个最小可运行版本。为了让重点更聚焦,用户数据先放内存里,实际项目中再替换为数据库查询。

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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

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

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

    <properties>
        <java.version>17</java.version>
    </properties>

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

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
    </dependencies>
</project>

2. application.yml

server:
  port: 8080

jwt:
  secret: 12345678901234567890123456789012
  expiration: 3600000

这里的 secret 只是示例。生产环境一定要更长、更随机,并通过配置中心或环境变量管理。


3. 启动类

package com.example.demo;

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

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

4. DTO 定义

LoginRequest.java

package com.example.demo.dto;

import jakarta.validation.constraints.NotBlank;

public class LoginRequest {

    @NotBlank
    private String username;

    @NotBlank
    private String password;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

LoginResponse.java

package com.example.demo.dto;

public class LoginResponse {

    private String token;
    private String tokenType = "Bearer";

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

    public String getToken() {
        return token;
    }

    public String getTokenType() {
        return tokenType;
    }
}

5. JWT 工具类

JwtService.java

package com.example.demo.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.beans.factory.annotation.Value;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

@Service
public class JwtService {

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

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

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

        return Jwts.builder()
                .setClaims(Map.of("roles", roles))
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSignKey(), SignatureAlgorithm.HS256)
                .compact();
    }

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

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

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

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

    private boolean isTokenExpired(String token) {
        return extractClaim(token, Claims::getExpiration).before(new Date());
    }

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

6. 用户加载服务

CustomUserDetailsService.java

package com.example.demo.security;

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 ("alice".equals(username)) {
            return User.withUsername("alice")
                    .password("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy") // password
                    .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER")))
                    .build();
        }

        if ("admin".equals(username)) {
            return User.withUsername("admin")
                    .password("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy") // password
                    .authorities(List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))
                    .build();
        }

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

这里两个用户的密码都是 password。为了演示方便,直接写死了 bcrypt 密文。


7. JWT 过滤器

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.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final CustomUserDetailsService userDetailsService;

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

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

        final String authHeader = request.getHeader("Authorization");
        final String jwt;
        final String username;

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

        jwt = authHeader.substring(7);

        try {
            username = jwtService.extractUsername(jwt);
        } catch (Exception e) {
            filterChain.doFilter(request, response);
            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);
    }
}

8. 未认证与未授权处理器

JwtAuthenticationEntryPoint.java

package com.example.demo.security;

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 JwtAuthenticationEntryPoint 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("{\"code\":401,\"message\":\"未认证或Token无效\"}");
    }
}

JwtAccessDeniedHandler.java

package com.example.demo.security;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException)
            throws IOException, ServletException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("{\"code\":403,\"message\":\"权限不足\"}");
    }
}

9. Security 配置

SecurityConfig.java

package com.example.demo.config;

import com.example.demo.security.CustomUserDetailsService;
import com.example.demo.security.JwtAccessDeniedHandler;
import com.example.demo.security.JwtAuthenticationEntryPoint;
import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.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.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 JwtAuthenticationEntryPoint authenticationEntryPoint;
    private final JwtAccessDeniedHandler accessDeniedHandler;
    private final CustomUserDetailsService userDetailsService;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
                          JwtAuthenticationEntryPoint authenticationEntryPoint,
                          JwtAccessDeniedHandler accessDeniedHandler,
                          CustomUserDetailsService userDetailsService) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.authenticationEntryPoint = authenticationEntryPoint;
        this.accessDeniedHandler = accessDeniedHandler;
        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))
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(authenticationEntryPoint)
                        .accessDeniedHandler(accessDeniedHandler)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/login").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated()
                )
                .authenticationProvider(authenticationProvider())
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public DaoAuthenticationProvider 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();
    }
}

10. 登录接口

AuthController.java

package com.example.demo.controller;

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

@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(@Valid @RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        request.getUsername(),
                        request.getPassword()
                )
        );

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

        return new LoginResponse(token);
    }
}

11. 受保护接口

UserController.java

package com.example.demo.controller;

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/user")
public class UserController {

    @GetMapping("/profile")
    public String profile(Authentication authentication) {
        return "当前登录用户:" + authentication.getName();
    }
}

AdminController.java

package com.example.demo.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/admin")
public class AdminController {

    @GetMapping("/dashboard")
    public String dashboard() {
        return "管理员控制台数据";
    }
}

12. 用方法级注解做更细粒度控制

如果你不想把权限规则都堆在 URL 配置里,可以用注解。

ReportController.java

package com.example.demo.controller;

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

@RestController
@RequestMapping("/api/report")
public class ReportController {

    @GetMapping("/list")
    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    public String list() {
        return "报表列表";
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public String delete(@PathVariable Long id) {
        return "删除报表:" + id;
    }
}

权限模型关系图

classDiagram
    class User {
        +Long id
        +String username
        +String password
    }

    class Role {
        +Long id
        +String code
    }

    class Permission {
        +Long id
        +String code
    }

    User --> Role : many-to-many
    Role --> Permission : many-to-many

五、逐步验证清单

我建议你不要一上来就全功能联调,按下面顺序验证,定位最省时间。

1. 先测试登录

请求:

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

预期返回:

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

2. 用普通用户访问用户接口

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

预期返回:

当前登录用户:alice

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

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

预期返回:

{"code":403,"message":"权限不足"}

4. 不带 Token 访问受保护接口

curl http://localhost:8080/api/user/profile

预期返回:

{"code":401,"message":"未认证或Token无效"}

5. 用管理员账号登录再访问

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

拿到 Token 后访问:

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

预期返回:

管理员控制台数据

六、常见坑与排查

这部分很重要。我自己最开始接 Spring Security 时,真正耗时间的不是“不会写”,而是“明明写了却没生效”。

1. 401 和 403 搞混

这是最常见的。

  • 401 Unauthorized:你还没通过认证,比如没带 Token、Token 无效、Token 过期
  • 403 Forbidden:你已经登录了,但没有访问该资源的权限

排查方法:

  • SecurityContext 里有没有认证信息
  • 看 JWT 过滤器有没有成功设置 Authentication
  • 看用户 authorities 是否包含目标角色

2. hasRole("ADMIN")ROLE_ADMIN 对不上

Spring Security 默认对 hasRole("ADMIN") 做了前缀处理,实际匹配的是:

ROLE_ADMIN

所以你在 UserDetails 里通常要写:

new SimpleGrantedAuthority("ROLE_ADMIN")

如果你写成:

new SimpleGrantedAuthority("ADMIN")

那大概率就会出现“明明是管理员却被拒绝”的情况。


3. JWT 过滤器位置不对

JWT 过滤器应该放在:

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

如果位置不对,可能会导致:

  • 请求还没解析 JWT 就已经走到权限判断
  • 结果所有接口都像“未登录”

4. 密码明文与加密不一致

如果你数据库里存的是 bcrypt 密文,那就必须配 BCryptPasswordEncoder

常见现象:

  • 用户名正确,密码也看起来对,但始终登录失败
  • 控制台会有 BadCredentialsException

排查建议:

  • 确认数据库密码是否是 bcrypt
  • 确认登录时传的是明文密码
  • 确认 PasswordEncoder 与存储方式一致

5. Token 解析失败但接口还返回 403

这个问题经常让人误判。

原因通常是:

  • 过滤器里 Token 异常被吞掉
  • 没有设置认证信息
  • 后续访问受保护接口,Spring Security 触发未认证或权限不足逻辑

建议:

  • 在 JWT 过滤器里增加日志
  • 明确区分“解析失败”和“权限不足”
  • 生产环境不要把异常堆栈直接回给前端,但日志一定要有

6. 开启了 CSRF 导致 POST 请求异常

前后端分离、基于 JWT 的无状态接口,一般会关闭 CSRF:

http.csrf(csrf -> csrf.disable());

如果你没关,某些 POST/PUT/DELETE 请求可能出现莫名其妙的 403。


7. 方法级权限不生效

如果 @PreAuthorize 没生效,先检查是否加了:

@EnableMethodSecurity

这个很小,但很关键。我踩过一次,查了半天 Controller,最后发现只是少了这个注解。


七、安全/性能最佳实践

JWT 很方便,但不是“只要能用就安全”。下面这些建议比较实用。

1. JWT 里不要放敏感信息

不要把这些内容直接塞进 JWT:

  • 用户密码
  • 手机号、身份证号等隐私信息
  • 过多业务数据

JWT 默认只是 Base64Url 编码,不是加密。别人拿到后是能解出来看的。

建议只放:

  • 用户 ID 或用户名
  • 角色/权限标识
  • 签发时间
  • 过期时间
  • 必要的业务声明

2. 过期时间不要太长

如果 Access Token 有效期太长,一旦泄露,风险窗口就很大。

常见做法:

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

本文示例只演示了 Access Token。生产环境通常还会加一套 Refresh Token 机制。

Token 生命周期示意

stateDiagram-v2
    [*] --> Issued : 登录成功签发Token
    Issued --> Active : 客户端携带访问
    Active --> Expired : 到达过期时间
    Active --> Revoked : 主动注销/踢下线
    Expired --> [*]
    Revoked --> [*]

3. 考虑 Token 吊销策略

JWT 是无状态的,天然不方便“立即失效”。如果你有这些需求:

  • 用户退出后 Token 立刻失效
  • 修改密码后旧 Token 失效
  • 管理员踢人下线

可以考虑:

  • Redis 黑名单
  • 维护 Token 版本号
  • 用户表增加 lastPasswordChangeTime
  • 短期 Access Token + Refresh Token

如果系统安全要求高,我更建议 短 Access Token + 可控 Refresh Token,这是更常见的折中方案。


4. 用 HTTPS 传输

JWT 一旦被截获,本质上就相当于“别人拿到了你的登录态”。

所以生产环境一定要:

  • 全站 HTTPS
  • 禁止明文 HTTP 传输 Token
  • 反向代理层做好 TLS 配置

5. 前端存储位置要谨慎

这个问题没有绝对标准,但要了解取舍:

  • localStorage:实现简单,但更容易受 XSS 影响
  • HttpOnly Cookie:更安全,不易被 JS 读取,但要配合 CSRF 防护设计

如果是纯 API + 前后端分离场景,很多团队会先用 Authorization: Bearer Token。但上线前一定要把 XSS 防护补好。


6. 权限信息尽量轻量

有些项目会把几十上百个权限点都塞进 JWT。这样做的问题是:

  • Token 变大,请求头开销上升
  • 权限调整后旧 Token 不易同步失效
  • 网关和服务之间传输成本增加

更稳妥的做法:

  • JWT 里只放核心标识
  • 细粒度权限可从缓存/数据库动态查询
  • 高频接口配合本地缓存或 Redis 缓存

7. 数据库方案落地建议

当你从示例代码切到生产实现时,建议表结构至少包括:

  • sys_user
  • sys_role
  • sys_permission
  • sys_user_role
  • sys_role_permission

授权加载时:

  1. 先查用户
  2. 再查角色
  3. 再查权限
  4. 最终统一转换为 GrantedAuthority

比如:

  • 角色:ROLE_ADMIN
  • 权限:report:delete

这样 URL 级和方法级控制都比较灵活。


八、从示例到生产:怎么演进

示例代码适合入门和主链路打通,但真实项目还需要继续往前走。

阶段 1:先跑通认证主链路

目标:

  • 登录成功拿 Token
  • Token 可解析
  • 接口权限能区分 USER / ADMIN

阶段 2:接入数据库

替换 CustomUserDetailsService 的内存实现,改成:

  • 根据用户名查用户
  • 查询角色和权限
  • 封装为 UserDetails

阶段 3:增加统一返回与异常处理

建议补上:

  • 统一响应结构
  • 全局异常处理
  • 登录失败提示规范化
  • 审计日志

阶段 4:增加 Refresh Token 和退出登录

适用场景:

  • 需要较好用户体验
  • 需要支持续期
  • 需要支持主动下线

阶段 5:网关/微服务统一鉴权

如果是微服务架构,还可以把 JWT 校验前移到:

  • Spring Cloud Gateway
  • API Gateway
  • 统一认证中心

这样业务服务只关注授权,不必每个服务重复写一套。


九、总结

这篇文章我们完整实现了一个 Spring Boot + Spring Security + JWT 的权限认证示例,核心链路包括:

  • AuthenticationManager 做登录认证
  • JwtService 签发与解析 Token
  • 用自定义过滤器把用户信息写入 SecurityContext
  • 用 URL 规则和 @PreAuthorize 实现接口级访问控制
  • 区分 401 与 403,提升问题定位效率

如果你准备把它真正用到项目里,我的建议很明确:

  1. 先做最小闭环:登录、鉴权、角色控制先跑通
  2. 不要急着把所有权限都塞进 JWT
  3. Access Token 设置短有效期
  4. 生产环境必须上 HTTPS
  5. 需要“踢下线/注销立即失效”时,引入 Redis 黑名单或 Token 版本控制
  6. 中大型系统尽量采用用户-角色-权限三层模型

最后给一个很实用的边界判断:

  • 如果你的系统只是一个简单后台,角色很少,JWT + 角色控制已经够用
  • 如果你的系统有复杂菜单、按钮、数据范围权限,就不要只停留在 hasRole(),要继续往权限点和领域授权演进

认证授权这块,最怕的不是“复杂”,而是“半懂不懂还到处散着写”。把链路收拢到 Spring Security 里,你后面维护会轻松很多。


分享到:

上一篇
《Java 中基于 CompletableFuture 与线程池的异步编排实战:性能优化、异常处理与最佳实践》
下一篇
《集群架构中服务发现与流量治理的实战设计:从注册中心到故障隔离》