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

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

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

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

前后端分离项目里,“登录能不能通、接口能不能控、用户身份稳不稳”基本决定了系统上线后会不会频繁报警。很多同学第一次接触 Spring Security 时,往往卡在两个地方:

  1. 默认表单登录那一套不适合前后端分离
  2. JWT、过滤器、权限控制几个点串不起来

这篇文章我会从一个能跑起来的最小实战出发,带你一步步搭建一个基于 Spring Boot + Spring Security + JWT 的认证鉴权方案。我们不追求“功能最全”,而是先把核心链路打通,再补上安全和性能上的细节。


背景与问题

在传统服务端渲染应用中,登录状态往往靠 Session 维护。浏览器拿到 JSESSIONID,后续请求自动带上 Cookie,服务端就能识别用户。

但到了前后端分离场景,问题就变了:

  • 前端可能是 Vue/React,也可能是小程序、App
  • 服务端更希望是无状态
  • 多服务部署时,Session 共享会增加复杂度
  • 接口调用需要更清晰的身份凭证

这时,JWT 就很适合:

  • 登录成功后,服务端签发 Token
  • 前端存储 Token
  • 后续请求通过 Authorization: Bearer xxx 传递
  • 服务端验签后恢复用户身份

但是只用 JWT 还不够,谁来接管认证流程、谁来做权限判断、谁来把用户信息放进上下文?这就是 Spring Security 的价值。


前置知识与环境准备

本文示例环境:

  • JDK 17
  • Spring Boot 3.x
  • Spring Security 6.x
  • Maven
  • JWT 库:jjwt

你最好已经知道这些概念:

  • HTTP 请求头
  • Spring Boot 基本开发方式
  • RESTful 接口
  • 用户、角色、权限的基本关系

整体方案设计

我们先明确一下登录链路:

  1. 用户提交用户名密码到 /auth/login
  2. 服务端校验用户名密码
  3. 校验成功后生成 JWT
  4. 前端保存 JWT
  5. 后续访问业务接口时携带 JWT
  6. JWT 过滤器解析 Token,写入 Spring Security 上下文
  7. 控制器或权限注解读取当前登录用户信息并做鉴权

架构流程图

flowchart TD
    A[前端登录请求 /auth/login] --> B[认证控制器]
    B --> C[AuthenticationManager 校验用户名密码]
    C --> D[UserDetailsService 查询用户]
    D --> E[生成 JWT]
    E --> F[返回 Token 给前端]
    F --> G[前端携带 Authorization Bearer Token]
    G --> H[JWT 过滤器解析 Token]
    H --> I[写入 SecurityContext]
    I --> J[访问受保护接口]

核心原理

要把这套方案真正用明白,建议先搞清楚 4 个核心角色。

1. JWT 是什么

JWT 本质上是一个经过签名的字符串,通常分为三段:

  • Header
  • Payload
  • Signature

其中 Payload 可以放用户名、用户 ID、角色等信息,但要注意:

  • JWT 不是加密,只是编码
  • 不要把密码、身份证号这类敏感数据放进去
  • JWT 一旦签发,在过期前通常默认有效

2. Spring Security 做了什么

Spring Security 的核心不是“登录页面”,而是这几件事:

  • 拦截请求
  • 认证当前请求是不是合法用户
  • 判断当前用户是否有权限访问资源
  • 把用户信息保存到 SecurityContext

前后端分离场景里,我们通常禁用默认 Session 和表单登录,改成自己处理登录接口 + 自定义 JWT 过滤器。

3. Authentication、UserDetails、SecurityContext 的关系

可以简单理解为:

  • UserDetails:用户对象,描述“你是谁”
  • Authentication:认证结果,描述“你已经被认证了”
  • SecurityContext:当前线程中的安全上下文,描述“这次请求对应的登录用户是谁”

4. 过滤器为什么重要

JWT 的使用不依赖 Session,所以每次请求都要重新校验 Token。这一步最适合放在过滤器中完成:

  • 从请求头中取出 Token
  • 解析并验证签名
  • 校验是否过期
  • 查询用户信息
  • 构造 Authentication
  • 放入 SecurityContextHolder

认证时序图

sequenceDiagram
    participant Client as 前端
    participant Filter as JwtAuthFilter
    participant Security as Spring Security
    participant Service as UserDetailsService
    participant API as Controller

    Client->>Security: 请求 /api/user/profile + Bearer Token
    Security->>Filter: 进入 JWT 过滤器
    Filter->>Filter: 解析并校验 Token
    Filter->>Service: 按用户名加载用户
    Service-->>Filter: 返回 UserDetails
    Filter->>Security: 设置 Authentication 到 SecurityContext
    Security->>API: 放行请求
    API-->>Client: 返回业务数据

项目依赖

先创建一个 Spring Boot 项目,加入以下依赖:

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

实战代码(可运行)

下面这套代码是一个最小可运行版本。为了突出认证流程,用户数据先写死在内存中。你后续完全可以替换成数据库查询。


第一步:配置 application.yml

server:
  port: 8080

jwt:
  secret: 12345678901234567890123456789012
  expiration: 3600000

这里的 secret 至少要足够长。我自己就踩过这个坑:太短会导致签名密钥强度不够,运行时报错。


第二步:启动类

package com.example.jwtsecurity;

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

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

第三步:定义登录请求与响应对象

package com.example.jwtsecurity.dto;

import jakarta.validation.constraints.NotBlank;

public class LoginRequest {

    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "密码不能为空")
    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;
    }
}
package com.example.jwtsecurity.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;
    }
}

第四步:JWT 工具类

package com.example.jwtsecurity.util;

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.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtTokenUtil {

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

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

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

    public String generateToken(UserDetails userDetails) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setSubject(userDetails.getUsername())
                .setIssuedAt(now)
                .setExpiration(expireDate)
                .signWith(getSignKey(), SignatureAlgorithm.HS256)
                .compact();
    }

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

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

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

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

说明:为了简化示例,这里只放了用户名。生产环境中可以额外放用户 ID、角色版本号等必要信息,但不要贪多。


第五步:实现 UserDetailsService

package com.example.jwtsecurity.service;

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

@Service
public class CustomUserDetailsService implements UserDetailsService {

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

这里两个用户的密码都是:password


第六步:JWT 认证过滤器

package com.example.jwtsecurity.security;

import com.example.jwtsecurity.service.CustomUserDetailsService;
import com.example.jwtsecurity.util.JwtTokenUtil;
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 JwtTokenUtil jwtTokenUtil;
    private final CustomUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtTokenUtil jwtTokenUtil,
                                   CustomUserDetailsService userDetailsService) {
        this.jwtTokenUtil = jwtTokenUtil;
        this.userDetailsService = userDetailsService;
    }

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

        String authHeader = request.getHeader("Authorization");

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

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

        try {
            username = jwtTokenUtil.extractUsername(token);
        } catch (Exception e) {
            filterChain.doFilter(request, response);
            return;
        }

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

            if (jwtTokenUtil.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);
    }
}

第七步:Security 配置

这是最关键的一段。Spring Security 6 推荐直接配置 SecurityFilterChain

package com.example.jwtsecurity.config;

import com.example.jwtsecurity.security.JwtAuthenticationFilter;
import com.example.jwtsecurity.service.CustomUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.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 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())
                .httpBasic(Customizer.withDefaults())
                .formLogin(form -> form.disable())
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**").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 configuration)
            throws Exception {
        return configuration.getAuthenticationManager();
    }

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

这里要特别注意:

  • SessionCreationPolicy.STATELESS:无状态
  • /auth/** 放行
  • JWT 过滤器要加到 UsernamePasswordAuthenticationFilter 之前

第八步:登录接口

package com.example.jwtsecurity.controller;

import com.example.jwtsecurity.dto.LoginRequest;
import com.example.jwtsecurity.dto.LoginResponse;
import com.example.jwtsecurity.util.JwtTokenUtil;
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.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

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

    private final AuthenticationManager authenticationManager;
    private final JwtTokenUtil jwtTokenUtil;

    public AuthController(AuthenticationManager authenticationManager,
                          JwtTokenUtil jwtTokenUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenUtil = jwtTokenUtil;
    }

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

            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            String token = jwtTokenUtil.generateToken(userDetails);
            return new LoginResponse(token);
        } catch (AuthenticationException e) {
            throw new RuntimeException("用户名或密码错误");
        }
    }
}

第九步:受保护接口

package com.example.jwtsecurity.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();
    }
}
package com.example.jwtsecurity.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 "欢迎访问管理员面板";
    }
}

第十步:统一异常返回(建议加上)

如果不处理,Spring Security 默认异常风格不一定适合前端。建议统一返回 JSON。

package com.example.jwtsecurity.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;
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.HashMap;
import java.util.Map;

@Configuration
public class SecurityExceptionConfig {

    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint() {
        return (request, response, authException) -> {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            Map<String, Object> result = new HashMap<>();
            result.put("code", 401);
            result.put("message", "未认证或Token无效");
            response.getWriter().write(new ObjectMapper().writeValueAsString(result));
        };
    }

    @Bean
    public AccessDeniedHandler accessDeniedHandler() {
        return (request, response, accessDeniedException) -> {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setContentType("application/json;charset=UTF-8");
            Map<String, Object> result = new HashMap<>();
            result.put("code", 403);
            result.put("message", "无权限访问");
            response.getWriter().write(new ObjectMapper().writeValueAsString(result));
        };
    }
}

然后在 SecurityConfig 中接入:

.exceptionHandling(ex -> ex
    .authenticationEntryPoint(authenticationEntryPoint())
    .accessDeniedHandler(accessDeniedHandler())
)

同时通过构造器注入这两个 Bean。


过滤器链与鉴权关系图

classDiagram
    class JwtAuthenticationFilter {
        +doFilterInternal()
    }
    class JwtTokenUtil {
        +generateToken()
        +extractUsername()
        +isTokenValid()
    }
    class CustomUserDetailsService {
        +loadUserByUsername()
    }
    class SecurityContextHolder
    class AuthenticationManager

    JwtAuthenticationFilter --> JwtTokenUtil
    JwtAuthenticationFilter --> CustomUserDetailsService
    JwtAuthenticationFilter --> SecurityContextHolder
    AuthController --> AuthenticationManager
    AuthController --> JwtTokenUtil

逐步验证清单

建议你不要一口气全写完再跑,而是按下面顺序验证。

1. 登录获取 Token

请求:

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

返回示例:

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

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

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

返回:

当前登录用户:user

3. 用 user 访问管理员接口

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

预期:

  • 返回 403
  • 说明认证成功,但权限不足

4. 用 admin 登录再访问管理员接口

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

然后访问 /api/admin/dashboard,应返回成功。


常见坑与排查

这一部分我建议你收藏。JWT + Spring Security 最大的问题往往不是“不会写”,而是“看起来写对了但就是不生效”。

坑 1:接口一直返回 403

现象:

  • 登录接口能通
  • 访问业务接口总是 403

排查点:

  1. requestMatchers 是否配置正确
  2. 角色是否带 ROLE_ 前缀
  3. hasRole("ADMIN") 实际匹配的是 ROLE_ADMIN
  4. JWT 过滤器是否真正执行了

建议:

在过滤器里打印日志:

System.out.println("Authorization: " + authHeader);
System.out.println("username: " + username);

如果用户名都解析不到,优先检查请求头格式。


坑 2:Token 明明有值,但认证信息拿不到

现象:

控制器里 Authentication authentication 是空的,或者匿名用户。

排查点:

  • JWT 过滤器是否放在正确位置
  • 是否成功执行了: SecurityContextHolder.getContext().setAuthentication(authenticationToken);
  • 是否在异常分支里直接 return 了

坑 3:密码一直校验失败

现象:

用户名正确,但总提示用户名或密码错误。

原因通常有两个:

  1. 数据库存的是明文,但配置的是 BCryptPasswordEncoder
  2. 数据库存的是 BCrypt,但你登录时做了额外加密,导致不匹配

建议:

Spring Security 中常见做法是:

  • 数据库存 BCrypt 哈希
  • 登录时前端传明文密码
  • 后端使用 PasswordEncoder.matches() 校验

不要手动再做一次 MD5 后传入,不然很容易错位。


坑 4:Token 过期后前端表现混乱

现象:

前端页面看起来还在登录状态,但接口突然 401。

建议:

前端要明确区分:

  • 401:未登录或登录过期,跳转登录页
  • 403:已登录但无权限,展示无权限提示

这两个状态码千万别混着用。


坑 5:跨域后 Authorization 请求头丢失

如果前端和后端不在同域,浏览器会有 CORS 限制。你需要明确允许 Authorization 请求头。

package com.example.jwtsecurity.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.filter.CorsFilter;

import java.util.List;

@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOriginPatterns(List.of("*"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("*"));
        config.setAllowCredentials(false);

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

安全最佳实践

JWT 方案看起来简单,但真正上线时,安全细节比“能不能跑”更重要。

1. Access Token 过期时间不要太长

建议:

  • 管理后台:30 分钟 ~ 2 小时
  • 普通用户系统:按业务需求控制
  • 高敏感系统:更短,并配合刷新机制

JWT 最大的问题之一是:签发后难以主动失效。过期时间越长,风险窗口越大。


2. 敏感信息不要放进 JWT

JWT Payload 只是 Base64 编码,不是加密。任何拿到 Token 的人都能解码看到内容。

不要放:

  • 密码
  • 手机号
  • 身份证号
  • 银行卡号
  • 过多业务状态

3. 推荐使用短 Token + Refresh Token 机制

如果你项目规模稍大,我更建议:

  • Access Token:短有效期
  • Refresh Token:更长有效期
  • 刷新时重新签发新的 Access Token

这样能兼顾安全和用户体验。


4. 服务端要考虑注销和强制下线

纯 JWT 最大的痛点是“服务端无状态”,但这也意味着:

  • 用户点了退出,旧 Token 在过期前理论上还可用
  • 管理员强制下线某用户也不容易立刻生效

常见做法:

  • 建立 Token 黑名单
  • Redis 记录失效 Token
  • Token 中带版本号,服务端校验用户当前版本

如果系统涉及金融、管理后台、数据敏感操作,我不建议只靠“自然过期”。


5. 全站必须 HTTPS

JWT 一旦泄露,本质上就像“临时通行证”丢了。HTTPS 不是可选项,而是基础项。


6. 登录接口要做限流与风控

JWT 只是认证方式,不会帮你防暴力破解。建议至少加上:

  • 登录失败次数限制
  • IP 限流
  • 验证码
  • 异常登录告警

性能最佳实践

很多人觉得 JWT 一定比 Session 快,其实不绝对。JWT 的优势主要在于无状态和跨服务传递方便,不是“天然高性能”。

1. Token 不要塞太多内容

Token 越大:

  • 请求头越大
  • 网络开销越高
  • 每次解析成本越高

一般保留:

  • 用户唯一标识
  • 用户名
  • 少量必要声明

就够了。


2. 不要每次都查数据库太重

当前示例里,过滤器会按用户名加载用户。如果生产环境里每次都查复杂表关联,性能会受影响。

优化思路:

  • 用户基础信息放缓存
  • 权限信息做本地/分布式缓存
  • 只查必要字段

但也要注意缓存一致性,尤其是角色权限变更。


3. 统一异常输出,减少前端重试误判

如果 401、403、500 混乱,前端可能做错误重试,反而放大系统压力。接口错误语义明确,其实也是一种性能优化。


4. 网关层可以前置校验

微服务场景中,可以在 API Gateway 先做一层 JWT 校验:

  • 无效 Token 直接拦截
  • 下游服务减少重复工作

不过敏感权限判断最好仍保留在服务侧,避免单点过度耦合。


这个方案的边界条件

这套方案适合:

  • 中小型前后端分离项目
  • 管理后台
  • REST API 服务
  • 需要无状态认证的场景

这套方案不一定直接适合:

  • 需要实时强制下线的高安全系统
  • 权限变化极其频繁的系统
  • 极端高并发且 Token 策略复杂的网关体系

如果你有这些需求,建议进一步引入:

  • Refresh Token
  • Redis 黑名单
  • OAuth2 / OIDC
  • API Gateway 统一认证

总结

我们这篇文章做了几件关键的事:

  • 解释了前后端分离下为什么常用 JWT
  • 用 Spring Security 接管认证与权限控制
  • 通过自定义过滤器完成每次请求的 Token 校验
  • 给出了一套可运行的最小示例
  • 总结了常见坑、安全与性能优化方向

如果你现在就要落地,我给你一个最实用的建议顺序:

  1. 先把本文最小版本跑通
  2. 再把用户查询替换为数据库
  3. 接着补统一异常处理和 CORS
  4. 然后增加 Refresh Token
  5. 最后根据安全要求考虑黑名单、强制下线、限流

一句话概括:JWT 负责“带身份”,Spring Security 负责“认身份、控权限”。这两者结合起来,才是前后端分离项目里比较稳妥的认证鉴权方案。

如果你是第一次完整走这条链路,建议真的跟着代码打一遍。很多概念在文档里看懂了,只有自己把过滤器、上下文、权限规则串起来之后,才算真正掌握。


分享到:

上一篇
《微服务架构下的分布式事务实战:基于 Saga 模式的设计、落地与故障补偿》
下一篇
《区块链智能合约安全审计实战:从常见漏洞识别到自动化检测流程搭建-91》