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

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

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

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

前后端分离项目里,登录认证这件事,几乎是绕不过去的。很多人第一次接触 Spring Security 时,会觉得它“又强又绕”:过滤器链长、配置项多、异常处理分散。再叠加 JWT,就更容易出现“能登录但访问不了接口”“Token 明明没过期却被判无效”这类问题。

这篇文章我会从实战落地的角度,带你完成一套可运行的认证授权方案:
Spring Boot + Spring Security + JWT,适合前后端分离的 REST API 项目。

我会尽量不讲空话,而是像带你做一个小项目一样,把关键点串起来。


一、背景与问题

在传统服务端渲染项目中,常见做法是:

  • 用户登录后,服务端创建 Session
  • 浏览器持有 Cookie
  • 后续请求通过 Session 判断用户身份

但在前后端分离架构里,会遇到几个现实问题:

  1. 前端可能不是浏览器

    • 可能是 Web、App、小程序,Cookie/Session 不总是方便管理
  2. 服务扩展后,Session 共享麻烦

    • 多实例部署要做 Session 共享或粘性会话
  3. API 更适合无状态认证

    • 请求自带凭证,服务端不保存登录状态,更利于扩展

所以很多团队会采用 JWT(JSON Web Token)

  • 登录成功后,服务端签发一个 Token
  • 前端保存 Token
  • 每次请求放在 Authorization: Bearer xxx
  • 服务端校验 Token,识别用户身份与权限

但 JWT 不是“用了就安全”,和 Spring Security 结合时,还有这些常见问题:

  • 认证过滤器放错位置
  • UserDetailsService 没接好
  • 权限字段和 hasRole/hasAuthority 不匹配
  • 异常处理没统一,前端收到 403/401 但看不懂
  • 忘了关 CSRF,导致接口请求异常
  • Token 过期策略、刷新策略设计不清

所以本文的目标很明确:
做一套中小型项目可直接参考的标准实现。


二、前置知识与环境准备

1. 技术栈

本文示例基于:

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

2. 示例场景

我们实现这几个接口:

  • POST /auth/login:登录,返回 JWT
  • GET /api/hello:登录用户可访问
  • GET /api/admin:仅管理员可访问

示例用户先用内存方式模拟:

  • user / 123456,角色:USER
  • admin / 123456,角色:ADMIN

这样能把重点放在认证授权流程上。如果你要接数据库,后面我也会说明如何替换。


三、核心原理

先别急着上代码,先把整个链路建立起来。

1. JWT 在认证中的位置

flowchart LR
    A[前端提交用户名密码] --> B[后端认证用户名密码]
    B --> C[认证成功后签发 JWT]
    C --> D[前端保存 JWT]
    D --> E[请求接口时携带 Authorization Bearer Token]
    E --> F[JWT 过滤器校验 Token]
    F --> G[写入 SecurityContext]
    G --> H[Spring Security 按权限放行或拒绝]

JWT 本身只是一个令牌格式,它不负责“拦截请求”和“授权决策”。
真正负责安全链路的是 Spring Security

2. Spring Security 做了什么

Spring Security 的核心思路可以粗暴理解成两件事:

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

在 JWT 场景下:

  • 登录接口:拿用户名密码做认证
  • 普通接口:不再走用户名密码,而是从 JWT 中恢复用户身份

3. 一次请求的处理顺序

sequenceDiagram
    participant Client as 前端
    participant Filter as JwtAuthenticationFilter
    participant SC as SecurityContext
    participant Controller as Controller
    participant Security as Spring Security

    Client->>Filter: 请求 /api/hello + Bearer Token
    Filter->>Filter: 解析并校验 JWT
    Filter->>SC: 设置 Authentication
    Filter->>Security: 进入后续过滤链
    Security->>Controller: 权限校验通过后放行
    Controller-->>Client: 返回业务数据

4. 为什么要把用户信息写入 SecurityContext

因为 Spring Security 后面的授权判断,依赖的是当前上下文里的 Authentication
如果你的 JWT 校验成功了,但没有正确设置 SecurityContextHolder,那后续依然会被当成未登录用户。

5. JWT 的基本组成

JWT 由三部分组成:

  • Header
  • Payload
  • Signature

Payload 通常会放:

  • 用户名
  • 用户 ID
  • 权限信息
  • 过期时间

但要注意:

JWT 的 Payload 是可以被解码看到的,不要放密码、身份证号、银行卡等敏感信息


四、项目结构设计

一个比较清晰的目录大概是这样:

src/main/java/com/example/jwtdemo
├── JwtDemoApplication.java
├── config
│   └── SecurityConfig.java
├── controller
│   ├── AuthController.java
│   └── TestController.java
├── dto
│   ├── LoginRequest.java
│   └── LoginResponse.java
├── security
│   ├── JwtAuthenticationFilter.java
│   ├── JwtTokenProvider.java
│   └── CustomUserDetailsService.java

这种划分的好处是:

  • controller 管接口
  • security 管认证授权相关能力
  • dto 管请求响应结构

后面你接数据库时,只需要继续加 entity/repository/service 即可。


五、实战代码(可运行)

下面给你一套尽量精简、但能真正跑起来的代码。

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-demo</artifactId>
    <version>1.0.0</version>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </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: 01234567890123456789012345678901
  expiration: 86400000

说明:

  • secret 至少保证足够长度,别写太短
  • expiration 单位毫秒,这里是 24 小时

3. 启动类

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

4. 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;
    }
}

5. JWT 工具类

JwtTokenProvider.java

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

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

@Component
public class JwtTokenProvider {

    private final SecretKey key;
    private final long expiration;

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

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

        Date now = new Date();
        Date expireDate = new Date(now.getTime() + expiration);

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

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

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

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

这里为了简洁,validateToken 直接统一返回布尔值。
实际项目里我更建议把异常细分,比如:

  • 过期
  • 签名错误
  • Token 格式不合法

这样前端和日志都更好排查。


6. 自定义用户加载服务

CustomUserDetailsService.java

package com.example.jwtdemo.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 ("user".equals(username)) {
            return new User(
                    "user",
                    "$2a$10$7EqJtq98hPqEX7fNZaFWoOHi5M1M9VwZ0pniS3pSkeCZMt2rtI8Aa",
                    List.of(new SimpleGrantedAuthority("ROLE_USER"))
            );
        }

        if ("admin".equals(username)) {
            return new User(
                    "admin",
                    "$2a$10$7EqJtq98hPqEX7fNZaFWoOHi5M1M9VwZ0pniS3pSkeCZMt2rtI8Aa",
                    List.of(
                            new SimpleGrantedAuthority("ROLE_ADMIN"),
                            new SimpleGrantedAuthority("ROLE_USER")
                    )
            );
        }

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

上面两个用户的密码都是 123456 的 BCrypt 值。

这里我用内存写死用户,是为了聚焦认证流程。
实际接数据库时,把 loadUserByUsername 改成查表即可。


7. JWT 认证过滤器

JwtAuthenticationFilter.java

package com.example.jwtdemo.security;

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.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final CustomUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider,
                                   CustomUserDetailsService userDetailsService) {
        this.jwtTokenProvider = jwtTokenProvider;
        this.userDetailsService = userDetailsService;
    }

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

        String token = getTokenFromRequest(request);

        if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
            String username = jwtTokenProvider.getUsernameFromToken(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);
    }

    private String getTokenFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

这个过滤器的职责很单纯:

  • 从请求头取 Token
  • 校验 Token
  • 解析用户名
  • 加载用户权限
  • 放入 SecurityContext

8. Spring Security 配置

SecurityConfig.java

package com.example.jwtdemo.config;

import com.example.jwtdemo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
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;

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

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(AbstractHttpConfigurer::disable)
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/login").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/hello").authenticated()
                        .requestMatchers(HttpMethod.GET, "/api/admin").hasRole("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();
    }
}

这段配置非常关键,几个重点要记住:

  • 禁用 Session
  • 禁用表单登录
  • 放行登录接口
  • 把 JWT 过滤器加到用户名密码过滤器前面

我当时第一次写 JWT 方案,最容易出错的就是过滤器顺序。
如果位置不对,你会发现 Token 明明有,但上下文里就是没用户。


9. 登录接口

AuthController.java

package com.example.jwtdemo.controller;

import com.example.jwtdemo.dto.LoginRequest;
import com.example.jwtdemo.dto.LoginResponse;
import com.example.jwtdemo.security.JwtTokenProvider;
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.web.bind.annotation.*;

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

        String token = jwtTokenProvider.generateToken(
                (org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal()
        );

        return new LoginResponse(token);
    }
}

这个登录过程是标准姿势:

  1. 前端提交用户名密码
  2. AuthenticationManager 调用 UserDetailsService
  3. 校验密码
  4. 成功后生成 JWT
  5. 返回给前端

10. 测试接口

TestController.java

package com.example.jwtdemo.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/hello")
    public Map<String, Object> hello(Authentication authentication) {
        return Map.of(
                "message", "hello, " + authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }

    @PreAuthorize("hasRole('ADMIN')")
    @GetMapping("/api/admin")
    public Map<String, Object> admin(Authentication authentication) {
        return Map.of(
                "message", "admin api success",
                "user", authentication.getName()
        );
    }
}

这里我故意演示了两种授权方式:

  • SecurityConfig 中配路径权限
  • 在方法上用 @PreAuthorize

中小项目里这两种都常见。
我的建议是:

  • 通用接口规则放配置里
  • 细粒度业务权限放方法注解里

六、逐步验证清单

到这一步,项目已经能跑了。下面按步骤验证。

1. 获取 Token

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

返回示例:

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

2. 访问普通认证接口

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

返回示例:

{
  "message": "hello, user",
  "authorities": [
    {
      "authority": "ROLE_USER"
    }
  ]
}

3. 访问管理员接口

如果你拿的是 user 的 Token:

curl http://localhost:8080/api/admin \
  -H "Authorization: Bearer 用户token"

会返回 403。

如果你用管理员登录:

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

再拿这个 Token 访问 /api/admin,应该成功。


七、认证与授权整体关系图

classDiagram
    class AuthController {
      +login(LoginRequest) LoginResponse
    }

    class JwtTokenProvider {
      +generateToken(UserDetails) String
      +getUsernameFromToken(String) String
      +validateToken(String) boolean
    }

    class JwtAuthenticationFilter {
      +doFilterInternal(HttpServletRequest, HttpServletResponse, FilterChain)
    }

    class CustomUserDetailsService {
      +loadUserByUsername(String) UserDetails
    }

    class SecurityConfig {
      +securityFilterChain(HttpSecurity) SecurityFilterChain
      +authenticationManager(AuthenticationConfiguration) AuthenticationManager
    }

    AuthController --> JwtTokenProvider
    AuthController --> SecurityConfig
    JwtAuthenticationFilter --> JwtTokenProvider
    JwtAuthenticationFilter --> CustomUserDetailsService
    SecurityConfig --> JwtAuthenticationFilter

八、常见坑与排查

这一段很重要。很多 JWT 项目不是“不会写”,而是“写完跑不通”。

1. 登录成功,但访问接口一直 401

现象

  • /auth/login 正常返回 Token
  • 请求受保护接口却提示未认证

重点排查

检查请求头格式是否正确

必须是:

Authorization: Bearer xxxxx

不是:

authorization: xxxxx

也不是:

Authorization: token xxxxx

检查过滤器是否加入了 Spring Security 链

看配置有没有这一句:

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

检查过滤器里是否写入了 SecurityContext

关键代码:

SecurityContextHolder.getContext().setAuthentication(authentication);

没有这一句,后续就还是匿名用户。


2. 明明有角色,却一直 403

这是我见过最多的坑之一。

原因

你可能写了:

hasRole("ADMIN")

但权限实际存的是:

ADMIN

而 Spring Security 的 hasRole("ADMIN") 底层会找:

ROLE_ADMIN

正确做法

要么存 ROLE_ADMIN,然后用:

hasRole("ADMIN")

要么直接用:

hasAuthority("ADMIN")

本文示例采用第一种,即权限统一存成 ROLE_ 前缀格式。


3. 登录时报密码不匹配

可能原因

  • 明文密码和加密密码没有用同一种编码方式
  • 你数据库里存的是 BCrypt,但配置里不是 BCryptPasswordEncoder

排查方式

确认是否定义了:

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

如果你把数据库里的密码写成明文 "123456",那肯定过不了。
Spring Security 默认会按加密密码处理。


4. Token 过期后接口直接报错,没有统一返回

原因

JWT 解析异常没做统一拦截。

本文示例为了易懂,没有把异常处理展开太多。实际项目建议补充:

  • AuthenticationEntryPoint:处理未认证
  • AccessDeniedHandler:处理无权限
  • 全局异常处理器:统一 JSON 响应

否则前端会看到默认错误页,体验很差。


5. 跨域请求失败

前后端分离时,尤其本地联调,常见报错是:

  • No ‘Access-Control-Allow-Origin’
  • 预检请求失败

此时需要显式配置 CORS。

例如:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
            .cors(Customizer.withDefaults())
            .csrf(AbstractHttpConfigurer::disable)
            .httpBasic(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .sessionManagement(session ->
                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/auth/login").permitAll()
                    .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

    return http.build();
}

再定义 CorsConfigurationSource

package com.example.jwtdemo.config;

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

import java.util.List;

@Configuration
public class CorsConfig {

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

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

九、如何从内存用户切换到数据库用户

实战中,你大概率会接数据库。思路其实不复杂:

  1. 用户表查询用户名、密码、状态
  2. 用户角色表查询角色列表
  3. 组装成 UserDetails
  4. JWT 里只放必要字段
  5. 每次请求根据用户名或用户 ID 再加载权限

一个典型用户表示意:

CREATE TABLE sys_user (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  username VARCHAR(64) NOT NULL UNIQUE,
  password VARCHAR(255) NOT NULL,
  enabled TINYINT NOT NULL DEFAULT 1
);

CREATE TABLE sys_role (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  role_code VARCHAR(64) NOT NULL UNIQUE
);

CREATE TABLE sys_user_role (
  user_id BIGINT NOT NULL,
  role_id BIGINT NOT NULL,
  PRIMARY KEY (user_id, role_id)
);

role_code 建议保存为:

ROLE_USER
ROLE_ADMIN

然后在 loadUserByUsername 中查库组装。

一个实用建议:
JWT 里可以放 userId,但权限仍建议以服务端数据库为准
这样用户角色变更后,不必等所有旧 Token 过期才能生效。


十、安全/性能最佳实践

这部分是“能跑”到“能上线”之间的差距。

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

JWT 适合放:

  • 用户 ID
  • 用户名
  • 角色标识
  • 过期时间

不要放:

  • 密码
  • 手机号
  • 身份证
  • 银行卡
  • 详细权限树

原因很简单:Payload 可被解码查看。


2. Secret 要足够强,并安全存储

不要在生产环境里把密钥写死在代码里。建议:

  • 放到环境变量
  • 放到配置中心
  • 定期轮换

而且密钥长度要够,不要用 123456 这种示例值。


3. Token 过期时间别设置太长

很多团队为了省事,直接设 7 天、30 天。
这会带来明显风险:Token 一旦泄露,攻击窗口很长。

中小项目常见方案:

  • Access Token:30 分钟到 2 小时
  • Refresh Token:7 天到 14 天

本文为了简单只实现了单 Token 模式。
如果你做正式系统,我建议再加 Refresh Token 机制。


4. 退出登录不要只靠前端删除 Token

JWT 是无状态的,这意味着:

  • 服务端默认不会“记住这个 Token 已作废”

如果业务对安全要求高,可以增加:

  • Token 黑名单
  • Redis 存储注销 Token
  • 短时 Access Token + Refresh Token

尤其是后台管理系统、金融类系统,不建议只靠“前端删本地 Token”。


5. 权限信息不要过度依赖 Token 内缓存

一个典型问题:

  • 用户 A 登录时是管理员
  • 后台取消了他的管理员角色
  • 但旧 Token 还没过期
  • 如果只信 Token 内角色,旧权限还会继续生效

更稳妥的做法:

  • Token 中只保留身份标识
  • 权限动态从数据库或缓存中加载
  • 或者权限变更时强制失效 Token

6. 给认证失败和授权失败统一返回结构

推荐区分:

  • 401 Unauthorized:没登录、Token 无效、Token 过期
  • 403 Forbidden:已登录,但没权限

前端可以据此做不同处理:

  • 401:跳登录页
  • 403:弹提示“无权限”

7. 控制过滤器中的数据库查询成本

很多人会在 JWT 过滤器里,每次请求都查完整用户信息和完整权限树。
这样在高并发下会比较重。

可以考虑:

  • 用户基础信息放缓存
  • 权限列表放缓存
  • Token 里放少量必要字段
  • 对热点接口做缓存优化

但别为了省一次查询,把所有东西都塞进 JWT。那会牺牲权限变更的实时性。


十一、一个更稳的扩展方案建议

如果你准备把这个方案用到实际项目,我建议按以下层次演进:

第一阶段:基础可用

  • 用户登录
  • JWT 校验
  • 接口权限控制
  • 统一 401/403 返回

第二阶段:适合生产

  • Refresh Token
  • Redis 黑名单
  • CORS 完整配置
  • 统一异常和审计日志
  • 登录失败次数限制

第三阶段:更强安全

  • 多端登录控制
  • 设备指纹
  • 权限缓存与失效机制
  • 密钥轮换
  • 操作审计与风险控制

这样比一开始就上“超复杂架构”更现实,也更容易维护。


十二、总结

我们这篇文章完成了一个前后端分离项目中常见的认证授权闭环:

  • AuthenticationManager 完成登录认证
  • 用 JWT 作为无状态凭证
  • OncePerRequestFilter 解析并校验 Token
  • 把用户身份写入 SecurityContext
  • 用 Spring Security 完成接口权限控制

如果你现在是中级开发者,我建议你优先记住这几个最关键的点:

  1. JWT 只是令牌,真正执行安全控制的是 Spring Security
  2. 过滤器里一定要正确设置 SecurityContext
  3. hasRole("ADMIN") 对应的是 ROLE_ADMIN
  4. 前后端分离项目通常要禁用 Session、formLogin、CSRF
  5. 生产环境要补上统一异常、CORS、刷新机制和注销策略

最后给一个可执行建议:

  • 如果你是做内部中后台,本文这套结构已经能作为起点
  • 如果你是做面向公网的系统,务必补充 Refresh Token、黑名单和统一安全响应
  • 如果你的权限模型很复杂,优先把“身份认证”和“业务权限”分层,不要把所有逻辑都堆进 JWT

先把链路跑通,再逐步增强安全性,这通常比一开始追求“最完整方案”更靠谱。


分享到:

上一篇
《分布式架构中基于一致性哈希与服务发现的灰度发布实战指南》
下一篇
《自动化测试稳定性治理实战:从脆弱用例定位到持续集成中的误报率优化》