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

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

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

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

前后端分离项目里,登录认证几乎是绕不开的一环。很多同学第一次做接口安全时,常见路径是:

  • 一开始用 Session,前后端跨域后发现状态不好维护
  • 然后改成 JWT,能跑起来,但很快遇到:
    • 401 Unauthorized
    • token 过期后用户体验差
    • 角色权限判断总是失效
    • Spring Security 过滤器链顺序不对

这篇文章我会带你从一个可运行的 Spring Boot 示例出发,完整实现:

  • 登录获取 JWT
  • 请求携带 JWT 访问受保护接口
  • 基于角色的鉴权
  • 统一异常处理
  • 常见问题排查
  • 安全与性能优化建议

我会尽量用“做项目时真正会遇到的问题”来组织内容,而不是只贴一堆概念。


背景与问题

传统的 Session 认证,在服务端保存用户会话状态。它在单体项目里很顺手,但到了前后端分离、微服务或者多实例部署场景,问题会慢慢暴露:

  1. 跨域与 Cookie 管理复杂
  2. 服务端有状态,不利于水平扩展
  3. 移动端、小程序、多终端接入不够统一
  4. 网关转发、多服务认证传递麻烦

JWT(JSON Web Token)的思路是:把认证信息放进一个签名后的 token 中,由客户端持有,每次请求携带,服务端校验其合法性即可。

但是,JWT 不是“用了就安全”。如果只是“登录返回 token,接口随便 decode 一下”,那实际项目里很容易埋坑。真正的关键是:

  • 如何与 Spring Security 正确整合
  • 如何把 token 中的信息转成 Spring Security 的认证上下文
  • 如何做好鉴权、异常、过期处理
  • 如何避免常见的安全误用

前置知识与环境准备

你需要具备

  • Java 基础
  • Spring Boot 基本使用
  • Maven 依赖管理
  • RESTful API 概念

环境版本

本文示例使用:

  • JDK 17
  • Spring Boot 3.x
  • Spring Security 6.x
  • Maven 3.8+

如果你还在用 Spring Boot 2.x,核心思路一致,但 WebSecurityConfigurerAdapter 等写法已经过时,本文采用的是新式配置。


核心原理

先别急着上代码,我们先把链路理顺。

1. 登录认证流程

用户提交用户名密码后:

  1. 后端调用 AuthenticationManager 校验账号密码
  2. 校验通过后生成 JWT
  3. JWT 返回给前端
  4. 前端保存 token(常见做法是内存或安全存储)
  5. 后续请求通过 Authorization: Bearer <token> 携带

2. 请求鉴权流程

访问受保护接口时:

  1. JWT 过滤器拦截请求
  2. 从请求头中提取 token
  3. 校验签名、过期时间、载荷
  4. 解析出用户名、角色
  5. 构造 Authentication 放入 SecurityContext
  6. Spring Security 根据接口配置判断是否有权限访问

整体流程图

flowchart TD
    A[用户登录] --> B[提交用户名密码到 /api/auth/login]
    B --> C[AuthenticationManager 校验]
    C -->|成功| D[生成 JWT]
    C -->|失败| E[返回 401]
    D --> F[前端保存 JWT]
    F --> G[访问受保护接口]
    G --> H[JWT 过滤器解析 Authorization 头]
    H -->|合法| I[写入 SecurityContext]
    H -->|非法/过期| J[返回 401]
    I --> K[Spring Security 做权限判断]
    K -->|有权限| L[返回业务数据]
    K -->|无权限| M[返回 403]

Spring Security 与 JWT 的关系

很多人会误解:用了 JWT 就不需要 Spring Security 了。

其实不是。我的经验是:

  • JWT:负责“你是谁”的凭证传递
  • Spring Security:负责“你能做什么”的认证鉴权框架

更准确地说:

  • JWT 解决的是无状态认证载体
  • Spring Security 提供的是过滤器链、认证模型、权限模型、上下文管理

二者搭配,才是完整方案。


认证对象关系图

classDiagram
    class JwtTokenProvider {
        +generateToken(username, roles)
        +validateToken(token)
        +getUsername(token)
        +getRoles(token)
    }

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

    class CustomUserDetailsService {
        +loadUserByUsername(username)
    }

    class SecurityConfig {
        +securityFilterChain(http)
        +authenticationManager(config)
        +passwordEncoder()
    }

    class AuthenticationManager
    class SecurityContextHolder

    JwtAuthenticationFilter --> JwtTokenProvider
    JwtAuthenticationFilter --> CustomUserDetailsService
    SecurityConfig --> JwtAuthenticationFilter
    SecurityConfig --> AuthenticationManager
    JwtAuthenticationFilter --> SecurityContextHolder

实战代码(可运行)

下面我们直接搭一个最小可用项目。

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>jwt-security-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>
        <!-- web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

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

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

        <!-- lombok,可选 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- test,可选 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

2. 配置文件

src/main/resources/application.yml

server:
  port: 8080

jwt:
  secret: 01234567890123456789012345678901
  expiration: 3600000

说明:

  • secret 至少要足够长,HS256 建议使用强密钥
  • expiration 这里配置为 1 小时,单位毫秒

3. 启动类

package com.example.jwtsecuritydemo;

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

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

4. 定义请求响应对象

LoginRequest

package com.example.jwtsecuritydemo.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

package com.example.jwtsecuritydemo.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 工具类

package com.example.jwtsecuritydemo.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.stereotype.Component;

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

@Component
public class JwtTokenProvider {

    private final SecretKey secretKey;
    private final long expiration;

    public JwtTokenProvider(@Value("${jwt.secret}") String secret,
                            @Value("${jwt.expiration}") long expiration) {
        this.secretKey = Keys.hmacShaKeyFor(secret.getBytes());
        this.expiration = expiration;
    }

    public String generateToken(String username, List<String> roles) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + expiration);

        return Jwts.builder()
                .setSubject(username)
                .claim("roles", roles)
                .setIssuedAt(now)
                .setExpiration(expireDate)
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

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

    @SuppressWarnings("unchecked")
    public List<String> getRoles(String token) {
        return getClaims(token).get("roles", List.class);
    }

    public boolean validateToken(String token) {
        try {
            getClaims(token);
            return true;
        } catch (Exception ex) {
            return false;
        }
    }

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

这里为了演示清晰,validateToken 统一返回布尔值。生产环境里建议区分过期、签名错误、格式非法等异常类型,便于定位。


6. 自定义用户服务

这里先用内存用户模拟数据库,方便快速跑通。

package com.example.jwtsecuritydemo.security;

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

import java.util.List;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) {
        if ("admin".equals(username)) {
            return new User(
                    "admin",
                    "$2a$10$DowJonesIndexyU3w4YQx2z8E2KhS0bD0q1m4gY5J1z8w2f9V1x7Q7Yy",
                    List.of(
                            new SimpleGrantedAuthority("ROLE_ADMIN"),
                            new SimpleGrantedAuthority("ROLE_USER")
                    )
            );
        }

        if ("user".equals(username)) {
            return new User(
                    "user",
                    "$2a$10$DowJonesIndexyU3w4YQx2z8E2KhS0bD0q1m4gY5J1z8w2f9V1x7Q7Yy",
                    List.of(new SimpleGrantedAuthority("ROLE_USER"))
            );
        }

        throw new org.springframework.security.core.userdetails.UsernameNotFoundException("用户不存在");
    }
}

上面这段里,密码是 BCrypt 加密后的字符串。为了让代码真正可运行,我们还需要明确明文密码。这里约定:

  • admin / 123456
  • user / 123456

如果你担心密文不匹配,最稳妥的方式是启动时动态生成,下面我给一个更可靠的版本。

更稳妥的写法:配置内存用户

package com.example.jwtsecuritydemo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;

@Configuration
public class UserConfig {

    @Bean
    public InMemoryUserDetailsManager inMemoryUserDetailsManager(PasswordEncoder passwordEncoder) {
        return new InMemoryUserDetailsManager(
                User.withUsername("admin")
                        .password(passwordEncoder.encode("123456"))
                        .roles("ADMIN", "USER")
                        .build(),
                User.withUsername("user")
                        .password(passwordEncoder.encode("123456"))
                        .roles("USER")
                        .build()
        );
    }
}

如果你采用这个方案,可以删除上面的 CustomUserDetailsService 类,后面注入 UserDetailsService 即可。为了保证示例可运行,本文后续按 InMemoryUserDetailsManager 方案继续


7. JWT 认证过滤器

package com.example.jwtsecuritydemo.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.core.userdetails.UserDetailsService;
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 JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
                                   UserDetailsService userDetailsService) {
        this.jwtTokenProvider = jwtTokenProvider;
        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);

        if (jwtTokenProvider.validateToken(token)
                && SecurityContextHolder.getContext().getAuthentication() == null) {

            String username = jwtTokenProvider.getUsername(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);

            UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                            userDetails,
                            null,
                            userDetails.getAuthorities()
                    );

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

        filterChain.doFilter(request, response);
    }
}

8. 自定义未认证与无权限处理器

AuthenticationEntryPoint

package com.example.jwtsecuritydemo.security;

import com.fasterxml.jackson.databind.ObjectMapper;
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;
import java.util.Map;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json;charset=UTF-8");

        new ObjectMapper().writeValue(response.getWriter(), Map.of(
                "code", 401,
                "message", "未认证或 token 无效"
        ));
    }
}

AccessDeniedHandler

package com.example.jwtsecuritydemo.security;

import com.fasterxml.jackson.databind.ObjectMapper;
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;
import java.util.Map;

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json;charset=UTF-8");

        new ObjectMapper().writeValue(response.getWriter(), Map.of(
                "code", 403,
                "message", "权限不足"
        ));
    }
}

9. Security 配置

package com.example.jwtsecuritydemo.config;

import com.example.jwtsecuritydemo.security.JwtAccessDeniedHandler;
import com.example.jwtsecuritydemo.security.JwtAuthenticationEntryPoint;
import com.example.jwtsecuritydemo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
                          JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
                          JwtAccessDeniedHandler jwtAccessDeniedHandler) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
        this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
    }

    @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(jwtAuthenticationEntryPoint)
                        .accessDeniedHandler(jwtAccessDeniedHandler)
                )
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/**").permitAll()
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

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

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

这里有两个关键点:

  • SessionCreationPolicy.STATELESS:明确告诉 Spring Security 不要用 Session 保存认证状态
  • addFilterBefore(...):把 JWT 过滤器放在用户名密码认证过滤器之前

10. 登录接口

package com.example.jwtsecuritydemo.controller;

import com.example.jwtsecuritydemo.dto.LoginRequest;
import com.example.jwtsecuritydemo.dto.LoginResponse;
import com.example.jwtsecuritydemo.security.JwtTokenProvider;
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("/api/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenProvider jwtTokenProvider;

    public AuthController(AuthenticationManager authenticationManager,
                          JwtTokenProvider jwtTokenProvider) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenProvider = jwtTokenProvider;
    }

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

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

        String token = jwtTokenProvider.generateToken(authentication.getName(), roles);
        return new LoginResponse(token);
    }
}

11. 测试接口

package com.example.jwtsecuritydemo.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 TestController {

    @GetMapping("/api/user/profile")
    public Map<String, Object> profile(Authentication authentication) {
        return Map.of(
                "message", "用户信息获取成功",
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }

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

    @GetMapping("/api/hello")
    public Map<String, Object> hello() {
        return Map.of("message", "hello jwt security");
    }
}

12. 可选:统一全局异常处理

package com.example.jwtsecuritydemo.exception;

import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BadCredentialsException.class)
    @ResponseStatus(HttpStatus.UNAUTHORIZED)
    public Map<String, Object> handleBadCredentials(BadCredentialsException ex) {
        return Map.of(
                "code", 401,
                "message", "用户名或密码错误"
        );
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Map<String, Object> handleException(Exception ex) {
        return Map.of(
                "code", 500,
                "message", ex.getMessage()
        );
    }
}

请求时序图

sequenceDiagram
    participant Client as 前端
    participant Auth as AuthController
    participant AM as AuthenticationManager
    participant JWT as JwtTokenProvider
    participant Filter as JwtAuthenticationFilter
    participant API as Protected API

    Client->>Auth: POST /api/auth/login 用户名密码
    Auth->>AM: authenticate()
    AM-->>Auth: 认证成功
    Auth->>JWT: generateToken()
    JWT-->>Auth: JWT
    Auth-->>Client: 返回 token

    Client->>Filter: GET /api/user/profile + Bearer token
    Filter->>JWT: validateToken()
    JWT-->>Filter: 合法
    Filter->>Filter: 写入 SecurityContext
    Filter->>API: 放行请求
    API-->>Client: 返回受保护数据

逐步验证清单

项目启动后,可以按下面顺序验证。

1. 登录获取 token

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

返回类似:

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

2. 访问普通用户接口

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

3. 用 user 访问管理员接口

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

预期返回 403

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

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

再拿返回的 token 请求:

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

预期返回成功。


常见坑与排查

这部分我建议你认真看。真正做项目,时间往往都花在这里。

1. 明明带了 token,还是 401

先检查这几项:

  • 请求头是否叫 Authorization
  • 值是否以 Bearer 开头,注意有空格
  • token 是否真的没过期
  • JWT 过滤器是否成功加入过滤器链
  • 放行路径有没有写错,比如 /api/auth/login/auth/login 不一致

建议临时在过滤器里打日志:

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

如果日志里拿不到请求头,优先怀疑前端没传上来,或者网关/代理层把头吞了。


2. 返回 403,不是 401

这个问题非常典型。

  • 401:说明你还没通过认证,或者 token 无效
  • 403:说明你已经认证了,但权限不够

如果是 403,重点检查:

  • token 里的角色有没有写进去
  • GrantedAuthority 是否带了 ROLE_ 前缀
  • 代码里是 hasRole("ADMIN") 还是 hasAuthority("ROLE_ADMIN")

我当时刚接 Spring Security 时就被这个前缀坑过很多次。经验规则很简单:

  • hasRole("ADMIN"),底层会匹配 ROLE_ADMIN
  • hasAuthority("ROLE_ADMIN"),那你就自己写完整

3. 过滤器执行了,但 SecurityContext 没生效

一般排查方向:

  • 是否在同一个请求线程内
  • 是否已经有别的认证对象覆盖了上下文
  • UsernamePasswordAuthenticationToken 的第三个参数(authorities)是否为空
  • 是否忘了 SecurityContextHolder.getContext().setAuthentication(authentication)

4. 登录总是用户名密码错误

排查顺序建议这样走:

  1. 用户是否存在
  2. PasswordEncoder 是否一致
  3. 数据库存的密码是否已经加密
  4. 是否错误地拿明文去比对密文

比如下面这种做法就是错的:

if (rawPassword.equals(encodedPassword)) {
    // 错误示例
}

正确方式应该交给 PasswordEncoder

passwordEncoder.matches(rawPassword, encodedPassword);

不过如果你用的是 AuthenticationManager,它会帮你完成这一步。


5. 跨域导致前端请求失败

前后端分离项目中,浏览器会先发 OPTIONS 预检请求。若 CORS 没配好,经常会误以为是认证失败。

可以加一个 CORS 配置:

package com.example.jwtsecuritydemo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.*;
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);
    }
}

如果你要带 Cookie,就不能把 allowCredentials* 随便混用,需要精确配置允许域名。


安全最佳实践

到这里,基本功能已经跑通了。但“能跑”和“适合生产”是两回事。

1. 不要把敏感信息直接放进 JWT

JWT 的 payload 只是 Base64 编码,不是加密。不要放:

  • 用户密码
  • 手机号全量
  • 身份证号
  • 银行卡号
  • 过多业务敏感字段

适合放的是:

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

2. 密钥要足够强,且不要硬编码

错误做法:

String secret = "123456";

更好的做法:

  • 从环境变量读取
  • 使用配置中心
  • 使用 KMS / Vault 管理密钥
  • 定期轮换密钥

如果密钥泄露,攻击者就可以伪造合法 token。


3. Access Token 要短效,必要时配 Refresh Token

JWT 最大的一个现实问题是:签发后难以主动失效

所以实践上通常会这样设计:

  • Access Token:有效期 15~60 分钟
  • Refresh Token:有效期更长,单独存储与校验
  • 登出或风控时,服务端拉黑 refresh token 或 token jti

如果你的业务对“强制下线”要求很高,仅靠纯 JWT 无状态方案并不够,需要配合:

  • Redis 黑名单
  • Token 版本号
  • Refresh Token 轮换机制

4. 生产环境必须走 HTTPS

如果不用 HTTPS,token 在传输过程中被抓包,别人拿到后就能直接冒充用户访问接口。

这一点不是 Spring Security 能补救的,属于传输层基础要求。


5. 不要把 token 长期存在不安全位置

前端常见存储方式各有取舍:

  • localStorage:使用方便,但受 XSS 风险影响较大
  • sessionStorage:生命周期更短
  • 内存存储:更安全一些,但刷新页面会丢
  • HttpOnly Cookie:可防止 JS 直接读取,但需要处理 CSRF

没有绝对银弹,要结合你的前端架构和安全要求决定。


6. 做好接口级权限控制

不要只在前端按钮上做权限控制。前端隐藏按钮只能算“体验控制”,真正的安全边界必须在服务端。

建议同时使用:

  • 路径级权限:requestMatchers
  • 方法级权限:@PreAuthorize

路径级适合做粗粒度控制,方法级适合做细粒度控制。


性能最佳实践

JWT 方案常被认为“天然高性能”,其实也要看你怎么用。

1. 不要每次请求都查全量用户信息

如果过滤器里每次都:

  • 解析 token
  • 再查数据库
  • 再组装权限

那高并发下数据库压力会很明显。

常见优化方式:

  • token 中携带基础角色信息
  • 用户权限变更不频繁时,可做本地缓存/Redis 缓存
  • 对高敏感接口,再做实时校验

也就是说,不同接口可以分层处理,不必“一刀切”。


2. 过滤器里逻辑要轻量

过滤器是每个请求都要走的地方,不要在里面写太重的逻辑,比如:

  • 复杂数据库联表
  • 大量远程 RPC
  • 频繁对象创建
  • 过多日志输出

JWT 过滤器的职责应该尽量单一:提取、校验、写上下文、放行


3. 合理设置 token 过期时间

过期时间太短:

  • 用户频繁重新登录
  • 前端体验差

过期时间太长:

  • 泄露后风险窗口增大

经验上可以这样取:

  • 管理后台:30 分钟到 2 小时
  • 普通业务系统:1 小时左右
  • 高安全系统:更短,并配合 refresh token 与二次校验

生产落地时的边界条件

这里给几个很实用的判断建议。

适合 JWT + Spring Security 的场景

  • 前后端分离项目
  • 移动端 / 小程序 / Web 多端统一认证
  • 网关转发、服务间传递身份信息
  • 希望服务尽量无状态,便于扩展

不适合“纯无状态 JWT”的场景

  • 需要强制实时踢人下线
  • 权限变更必须立刻生效
  • 安全合规要求极高
  • 设备管理、会话管理非常严格

这种情况下,建议使用:

  • JWT + Redis 黑名单
  • Session + Redis
  • OAuth2 / OIDC 统一认证平台

别为了“技术看起来先进”而硬上 JWT。选型一定要看业务边界。


一个更完整的改进方向

如果你准备把本文示例继续扩展到生产项目,我建议按下面路径迭代:

  1. 用户从内存改为数据库
  2. 增加注册、登出、修改密码功能
  3. 引入 Refresh Token
  4. 支持 token 黑名单
  5. 增加审计日志与登录风控
  6. 引入网关统一鉴权
  7. 最终演进到 OAuth2 授权体系

这样做比一开始就把系统设计得特别重更现实。


总结

这篇文章我们完整做了一套基于 Spring Boot + JWT + Spring Security 的前后端分离认证鉴权方案,重点包括:

  • 为什么前后端分离更适合无状态认证
  • JWT 与 Spring Security 各自负责什么
  • 如何通过过滤器把 token 转成认证上下文
  • 如何实现登录、鉴权、角色控制
  • 如何区分和排查 401 / 403
  • 如何在安全和性能之间做合理取舍

如果你现在就要落地,我给你的可执行建议是:

  1. 先跑通最小闭环:登录、带 token 访问、角色鉴权
  2. 再补生产能力:异常处理、CORS、日志、过期策略
  3. 最后考虑复杂诉求:登出、强制下线、refresh token、黑名单

一句话总结:

JWT 不是目的,安全、清晰、可维护的认证鉴权链路才是目的。

如果你的项目只是一个普通后台管理系统,本文这套方案已经足够作为稳妥起点;如果你的系统对会话控制要求很高,那就别停留在“纯 JWT”层面,尽早引入 refresh token 与服务端状态管理。


分享到:

上一篇
《Node.js 中级实战:基于 Worker Threads 与队列机制构建高并发任务处理服务》
下一篇
《区块链节点数据索引实战:面向中级开发者的链上事件抓取、清洗与查询系统设计》