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

《Spring Boot 中基于 JWT 与 Spring Security 的前后端分离认证授权实战指南-39》

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

Spring Boot 中基于 JWT 与 Spring Security 的前后端分离认证授权实战指南

前后端分离项目里,登录认证几乎是绕不开的一环。很多同学一开始会问:“我只是想做个登录,为什么 Spring Security 一上来就这么多概念?”
这个感觉我非常理解,因为我第一次把 JWT 和 Spring Security 串起来时,也被过滤器链、认证管理器、权限表达式绕得有点晕。

这篇文章我不打算只讲概念,而是按“能跑起来、知道为什么、出了问题能排查”的顺序,带你搭一套适合中小型项目的认证授权方案:

  • 用户登录后签发 JWT
  • 前端后续请求携带 Token
  • 后端通过 Spring Security 校验 Token
  • 基于角色/权限控制接口访问

文章面向已经会写基本 Spring Boot 接口的中级开发者。


一、背景与问题

在传统服务端渲染应用里,常见方案是 Session + Cookie。服务端把登录态存在 Session 中,浏览器自动带上 Cookie,后端据此识别用户。

但到了前后端分离场景,情况变了:

  1. 前端可能是 Vue/React,也可能是 App、小程序
  2. 接口常常要支持跨域
  3. 服务可能会水平扩容,多实例部署
  4. 我们更希望服务端尽量无状态,减轻 Session 管理负担

这时候,JWT(JSON Web Token) 就成了常见选择。它的典型用法是:

  • 登录成功后,服务端签发一个 Token
  • Token 中携带用户标识、角色等声明
  • 客户端保存 Token
  • 之后请求时在 Authorization: Bearer xxx 中携带
  • 服务端验证 Token 后,还原用户身份

再配合 Spring Security,我们就可以把“认证”和“授权”这两件事拆清楚:

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

二、前置知识与环境准备

1. 环境版本

本文示例基于:

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

如果你还在用 Spring Boot 2.x,核心思路一样,但配置方式会略有不同,特别是 WebSecurityConfigurerAdapter 在新版本里已经不推荐使用。

2. 示例目标

我们做一个最小可运行示例,包含以下接口:

  • POST /api/auth/login:登录,返回 JWT
  • GET /api/user/profile:登录用户可访问
  • GET /api/admin/dashboard:只有 ADMIN 角色可访问

3. 项目依赖

pom.xml

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

三、核心原理

很多教程直接上代码,但如果脑子里没有一个整体流程,后面一报错就只能“复制粘贴式调参”。先把骨架建立起来。

1. JWT 认证流程图

flowchart TD
    A[用户提交用户名密码] --> B[后端认证用户名密码]
    B -->|通过| C[生成 JWT]
    B -->|失败| D[返回 401]
    C --> E[前端保存 JWT]
    E --> F[请求受保护接口时携带 Bearer Token]
    F --> G[JWT 过滤器解析并校验 Token]
    G -->|有效| H[写入 SecurityContext]
    G -->|无效/过期| I[返回 401]
    H --> J[Spring Security 做权限判断]
    J -->|通过| K[返回业务数据]
    J -->|拒绝| L[返回 403]

2. 认证与授权的职责划分

可以这样理解:

  • 登录接口只负责校验用户名密码,并生成 JWT
  • JWT 过滤器负责从请求头取出 Token,校验并恢复用户信息
  • Spring Security根据用户拥有的角色/权限决定能否访问接口

也就是说:

  • 登录时做一次“账号密码认证”
  • 每次请求做一次“Token 认证”
  • 授权始终交给 Security 规则处理

3. Spring Security 在这套方案里的关键对象

UserDetailsService

根据用户名加载用户信息。

PasswordEncoder

负责密码加密与匹配,必须用,别明文存密码。

AuthenticationManager

登录时验证用户名密码。

OncePerRequestFilter

JWT 校验过滤器的常用实现方式,每个请求执行一次。

SecurityContextHolder

当前请求线程里的“安全上下文”,一旦放入认证信息,后续就可以拿到当前登录用户。

4. 一张时序图看完整链路

sequenceDiagram
    participant Client as 前端
    participant Auth as 登录接口
    participant Security as Spring Security
    participant Filter as JWT过滤器
    participant API as 业务接口

    Client->>Auth: POST /api/auth/login 用户名+密码
    Auth->>Security: AuthenticationManager.authenticate()
    Security-->>Auth: 认证成功
    Auth-->>Client: 返回 JWT

    Client->>Filter: GET /api/user/profile + Bearer Token
    Filter->>Filter: 解析JWT、校验签名和过期时间
    Filter->>Security: 写入 Authentication 到 SecurityContext
    Security->>API: 校验是否已登录/是否有权限
    API-->>Client: 返回数据

四、项目结构设计

为了让代码更清晰,我们按下面结构组织:

src/main/java/com/example/jwtdemo
├── JwtDemoApplication.java
├── config
│   └── SecurityConfig.java
├── controller
│   ├── AuthController.java
│   ├── UserController.java
│   └── AdminController.java
├── dto
│   ├── LoginRequest.java
│   └── LoginResponse.java
├── filter
│   └── JwtAuthenticationFilter.java
├── service
│   ├── CustomUserDetailsService.java
│   └── JwtService.java
└── model
    └── LoginUser.java

五、实战代码(可运行)

下面的代码是一个可跑通的最小版本。为了聚焦认证授权逻辑,用户数据先写死在内存中。你后面接 MySQL、Redis 都可以平滑替换。

1. 启动类

package com.example.jwtdemo;

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

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

2. DTO

LoginRequest.java

package com.example.jwtdemo.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.jwtdemo.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;
    }
}

3. 用户模型

LoginUser.java

package com.example.jwtdemo.model;

import java.util.Collection;
import java.util.List;

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

public class LoginUser implements UserDetails {

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

    public LoginUser(String username, String password, List<? 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;
    }
}

4. 用户加载服务

CustomUserDetailsService.java

package com.example.jwtdemo.service;

import java.util.List;

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

import com.example.jwtdemo.model.LoginUser;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("admin".equals(username)) {
            return new LoginUser(
                    "admin",
                    "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", // 123456
                    List.of(new SimpleGrantedAuthority("ROLE_ADMIN"))
            );
        }

        if ("user".equals(username)) {
            return new LoginUser(
                    "user",
                    "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", // 123456
                    List.of(new SimpleGrantedAuthority("ROLE_USER"))
            );
        }

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

这里密码是 BCrypt 编码后的 123456
实际项目里请从数据库查用户、角色、权限。

5. JWT 工具服务

JwtService.java

package com.example.jwtdemo.service;

import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.Map;

import javax.crypto.SecretKey;

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

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;

@Service
public class JwtService {

    private static final String SECRET = "mySuperSecretKeyForJwtDemoProject1234567890";
    private static final long EXPIRATION = 1000 * 60 * 60 * 2; // 2小时

    private Key getSignKey() {
        byte[] keyBytes = SECRET.getBytes(StandardCharsets.UTF_8);
        SecretKey key = Keys.hmacShaKeyFor(keyBytes);
        return key;
    }

    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
                .setClaims(Map.of("username", userDetails.getUsername()))
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
                .signWith(getSignKey(), SignatureAlgorithm.HS256)
                .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);
    }

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

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

6. JWT 过滤器

JwtAuthenticationFilter.java

package com.example.jwtdemo.filter;

import java.io.IOException;

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 com.example.jwtdemo.service.CustomUserDetailsService;
import com.example.jwtdemo.service.JwtService;

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

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

7. Spring Security 配置

SecurityConfig.java

package com.example.jwtdemo.config;

import org.springframework.context.annotation.*;
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.*;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.*;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

import com.example.jwtdemo.filter.JwtAuthenticationFilter;
import com.example.jwtdemo.service.CustomUserDetailsService;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomUserDetailsService userDetailsService;

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

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

8. 登录接口

AuthController.java

package com.example.jwtdemo.controller;

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

import com.example.jwtdemo.dto.LoginRequest;
import com.example.jwtdemo.dto.LoginResponse;
import com.example.jwtdemo.service.JwtService;

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

9. 业务接口

UserController.java

package com.example.jwtdemo.controller;

import java.security.Principal;
import java.util.Map;

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 Map<String, Object> profile(Principal principal) {
        return Map.of(
                "message", "用户信息读取成功",
                "username", principal.getName()
        );
    }
}

AdminController.java

package com.example.jwtdemo.controller;

import java.util.Map;

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 Map<String, Object> dashboard() {
        return Map.of(
                "message", "管理员面板访问成功"
        );
    }
}

六、逐步验证清单

我建议你不要一口气把代码全贴进去再启动,而是按下面顺序验证,这样定位问题快很多。

1. 启动项目

默认端口 8080

2. 调用登录接口

使用 user / 123456

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

返回类似:

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

3. 访问用户接口

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

返回:

{
  "message": "用户信息读取成功",
  "username": "user"
}

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

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

预期结果:403 Forbidden

5. 用管理员账号登录

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

再拿 token 调管理员接口,应返回成功。


七、权限控制的工作机制

到这里你可能会问:“为什么 admin 能进,user 不能进?”

核心是这一句:

.requestMatchers("/api/admin/**").hasRole("ADMIN")

而用户权限里我们给的是:

new SimpleGrantedAuthority("ROLE_ADMIN")

Spring Security 的 hasRole("ADMIN") 会自动补上前缀,实际匹配的是 ROLE_ADMIN

这就是很多人会踩的坑之一:
你如果写成了 new SimpleGrantedAuthority("ADMIN"),那 hasRole("ADMIN") 很可能不生效。

再看一张关系图,会更直观:

classDiagram
    class UserDetails {
        +getUsername()
        +getPassword()
        +getAuthorities()
    }

    class LoginUser {
        +username
        +password
        +authorities
    }

    class JwtAuthenticationFilter {
        +doFilterInternal()
    }

    class JwtService {
        +generateToken()
        +extractUsername()
        +isTokenValid()
    }

    class SecurityContextHolder {
        +getContext()
        +setAuthentication()
    }

    UserDetails <|.. LoginUser
    JwtAuthenticationFilter --> JwtService
    JwtAuthenticationFilter --> SecurityContextHolder
    JwtAuthenticationFilter --> UserDetails

八、常见坑与排查

这一节很重要。JWT + Security 的问题,往往不是“代码不会写”,而是“为什么明明写了还是 401/403”。

1. 401 Unauthorized 和 403 Forbidden 分不清

现象

  • 401:通常是未认证,Token 没带、无效、过期
  • 403:通常是已认证,但权限不足

排查思路

先问自己两个问题:

  1. 当前请求有没有进入 JWT 过滤器?
  2. SecurityContext 里有没有成功放入认证信息?

如果认证都没成功,通常是 401。
如果认证成功但权限不够,通常是 403。


2. Token 明明带了,还是拿不到当前用户

常见原因

原因 A:请求头格式不对

必须是:

Authorization: Bearer eyJhbGciOi...

而不是:

authorization: eyJhbGciOi...

或者少了 Bearer 前缀。

原因 B:过滤器没有注册到链中

确认你写了:

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

原因 C:Token 解析异常被吞掉

在示例里我为了简化处理,异常直接放行了:

catch (Exception e) {
    filterChain.doFilter(request, response);
    return;
}

真实项目里建议至少打日志,不然排查很痛苦。


3. 角色匹配不上

现象

管理员用户访问管理员接口还是 403。

常见原因

你给的权限是:

new SimpleGrantedAuthority("ADMIN")

但配置写的是:

.hasRole("ADMIN")

这两者不等价。
要么:

new SimpleGrantedAuthority("ROLE_ADMIN")

配合:

.hasRole("ADMIN")

要么:

new SimpleGrantedAuthority("ADMIN")

配合:

.hasAuthority("ADMIN")

我个人建议统一用角色前缀规范,少给团队埋坑。


4. 密码总是校验失败

常见原因

数据库里存的是明文,配置却用了 BCrypt;或者密码已经加密了,你又拿明文直接比。

正确方式

  • 注册时:passwordEncoder.encode(rawPassword)
  • 登录时:交给 Spring Security 自动调用 matches

不要自己手写字符串比较。


5. 跨域导致前端带不上 Token

现象

浏览器里看起来前端发请求了,但后端没收到预期请求头,或者预检请求失败。

处理建议

启用 CORS,并明确允许 Authorization 请求头。
如果你的前后端域名不同,这一步几乎必做。

可以加一个配置:

package com.example.jwtdemo.config;

import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.*;

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOriginPatterns(List.of("*"));
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setExposedHeaders(List.of("Authorization"));

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

九、安全/性能最佳实践

示例代码能跑,但生产环境不能只停留在“能跑”。下面这些建议,我认为是真正上线前必须考虑的。

1. Secret 不要硬编码

示例里为了演示方便,直接写在代码中:

private static final String SECRET = "...";

实际应放到配置中心、环境变量或密钥管理系统中,例如:

jwt:
  secret: ${JWT_SECRET}
  expiration: 7200000

然后通过 @ConfigurationProperties 注入。


2. Token 过期时间别设太长

JWT 一旦签发,在无额外机制时很难主动失效。
如果过期时间设成 7 天、30 天,一旦泄露,风险非常大。

常见建议:

  • Access Token:15 分钟 ~ 2 小时
  • Refresh Token:更长,但要单独设计存储与撤销机制

中小项目如果暂时不做 Refresh Token,至少把 Access Token 控制在较短周期内。


3. 不要把敏感信息塞进 JWT

JWT 默认只是 Base64Url 编码,不是加密。
别把这些内容直接放进去:

  • 明文密码
  • 手机号、身份证号
  • 银行卡等隐私数据

通常只放必要声明,例如:

  • 用户 ID
  • 用户名
  • 角色
  • 过期时间
  • 签发时间

4. 服务端仍要做权限校验

很多前端会根据角色隐藏按钮,但这只是“体验优化”,不是安全措施。
真正的授权必须在后端做

比如:

  • 前端隐藏了“删除用户”按钮
  • 但攻击者仍可以直接调用删除接口
  • 如果后端没校验权限,就等于裸奔

5. 建议区分认证异常与授权异常返回体

默认的 401/403 响应对前端不够友好。
实际项目建议自定义:

  • 未登录或 Token 无效:返回统一 401 JSON
  • 权限不足:返回统一 403 JSON

这样前端能更好处理“跳登录页”还是“提示无权限”。


6. 如需强制下线,JWT 不能只靠无状态

很多文章会把 JWT 说得很完美,但现实是:

  • 纯无状态 JWT 很适合简单认证
  • 但如果你需要“踢人下线”“单设备登录”“主动撤销 Token”
  • 就必须引入额外状态,比如黑名单、Redis、Token 版本号等

这是 JWT 的边界条件,不是缺点,而是设计取舍。


7. 过滤器中要避免重复查库

当前示例每次请求都会:

  1. 从 Token 取用户名
  2. 根据用户名加载用户详情

这很常见,也没问题。但如果权限信息复杂、数据库压力大,可以优化:

  • 把必要角色信息放入 Token
  • 或用 Redis 缓存用户权限
  • 或做短期本地缓存

不过我不建议一开始就过度优化。
先跑通、先保证安全,再根据压测结果处理。


十、进一步增强:方法级权限控制

除了在 URL 上做权限限制,Spring Security 还支持方法级授权,这在复杂业务里很好用。

例如在配置类上已经加了:

@EnableMethodSecurity

你就可以在方法上这样写:

package com.example.jwtdemo.controller;

import java.util.Map;

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

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

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

这种方式的好处是:

  • URL 规则简单时放配置里
  • 业务语义强的权限放方法上
  • 代码可读性更好

但也别两边都写一堆互相打架的规则,不然以后自己都看不懂。


十一、请求状态流转图

再补一张状态图,帮助你从“请求生命周期”角度理解这套方案。

stateDiagram-v2
    [*] --> 未认证请求
    未认证请求 --> 放行匿名访问: 访问公开接口
    未认证请求 --> 解析Token: 访问受保护接口且带Token
    解析Token --> Token无效: 签名错误/过期/格式错误
    解析Token --> 已认证: Token校验通过
    已认证 --> 授权通过: 角色/权限匹配
    已认证 --> 授权拒绝: 权限不足
    放行匿名访问 --> [*]
    Token无效 --> [*]
    授权通过 --> [*]
    授权拒绝 --> [*]

十二、适合落地的改造建议

如果你准备把本文示例改造成真实项目,我建议按这个顺序演进:

第一步:把内存用户替换为数据库

实现 UserDetailsService 查询:

  • 用户表
  • 角色表
  • 用户角色关联表
  • 权限表(如需要更细粒度控制)

第二步:统一异常响应

增加:

  • 认证失败处理器
  • 权限不足处理器
  • 全局异常处理器

保证前端拿到统一 JSON。

第三步:引入 Refresh Token

适合登录态要求较好的系统,避免用户频繁登录。

第四步:加入 Token 撤销策略

如果业务要求“修改密码后旧 Token 失效”“管理员强制下线”,建议配合 Redis 黑名单或版本号机制。

第五步:审计与监控

记录:

  • 登录成功/失败日志
  • Token 异常统计
  • 权限拒绝次数
  • 异常 IP 与接口访问频率

安全系统不是“写完认证代码就结束”,它更像一条持续运营的链路。


总结

这篇文章我们从一个很实际的目标出发:在 Spring Boot 中,用 JWT + Spring Security 实现前后端分离认证授权。

你需要记住的核心点其实不多:

  1. 登录接口负责用户名密码认证,并签发 JWT
  2. JWT 过滤器负责解析 Token 并写入 SecurityContext
  3. Spring Security负责根据角色/权限做授权
  4. 401 是未认证,403 是无权限
  5. JWT 适合无状态认证,但不天然支持强制下线
  6. 生产环境一定要处理密钥管理、过期策略、异常响应和权限边界

如果你现在正在做中小型后台系统,这套方案已经足够作为一个稳妥的基础版。
如果你的系统还需要单点登录、复杂权限模型、多端会话控制,那就要在此基础上继续扩展,而不是指望一个“纯 JWT”万能解决所有问题。

最后给一个很实用的建议:
先把最小闭环跑通,再逐步增强。
别一上来就把 RBAC、Refresh Token、黑名单、网关鉴权、SSO 全堆上去。认证系统最怕的不是功能少,而是看起来很全、实际没人能维护。


分享到:

上一篇
《Java Web 开发中基于 Spring Boot + Redis 实现接口幂等与重复提交防护实战》
下一篇
《中级开发者实战:基于大语言模型构建企业知识库问答系统的架构设计与效果优化》