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

《Java Web开发实战:基于Spring Boot与JWT实现前后端分离的登录鉴权与权限控制》

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

Java Web开发实战:基于Spring Boot与JWT实现前后端分离的登录鉴权与权限控制

前后端分离项目里,登录这件事看起来简单,真正落地时却经常“坑味十足”:

  • 登录接口能返回 token,但后续接口总是 401
  • token 能解析,但权限判断不生效
  • 前端退出登录了,后端 token 还有效
  • 测试环境好好的,一到线上就因为时区、密钥、跨域、网关转发出问题

这篇文章我不打算只讲概念,而是带你从一个可运行的 Spring Boot 示例出发,完整实现:

  1. 用户登录
  2. 服务端签发 JWT
  3. 请求自动携带 token
  4. Spring Security 校验身份
  5. 基于角色/权限做接口访问控制

如果你已经有 Spring Boot 基础,但对 JWT 和权限控制还停留在“会配,不太懂原理”的阶段,这篇文章适合你。


一、背景与问题

在传统服务端渲染时代,登录态通常保存在 Session 中。浏览器第一次登录后,后端把 Session ID 放进 Cookie,后续请求靠 Cookie 找回用户状态。

但到了前后端分离场景,尤其是:

  • 前端是 Vue / React
  • 后端是 REST API
  • 还可能有移动端、小程序、第三方系统接入
  • 系统可能被拆成多个服务

这时候基于 Session 的方案会遇到几个问题:

1. 服务端状态难扩展

Session 保存在服务端,多实例部署要共享 Session,否则登录后请求打到另一台机器就失效。

2. 前后端跨域下处理更麻烦

Cookie、跨域、SameSite、CSRF 这些问题会成套出现。

3. 接口认证需要更统一

移动端、Web、网关、微服务之间更适合走一个统一的 Bearer Token 机制。

于是 JWT 成了非常常见的方案。


二、核心原理

先别急着上代码,先把登录鉴权链路想清楚。

2.1 JWT 是什么

JWT(JSON Web Token)本质上是一段字符串,通常由三部分组成:

  • Header:声明算法类型
  • Payload:声明用户信息、过期时间等
  • Signature:签名,防止篡改

格式像这样:

header.payload.signature

JWT 不是加密,默认只是 Base64Url 编码,所以不要把敏感信息直接塞进 payload,比如密码、身份证号、银行卡号。

2.2 一次完整的登录流程

sequenceDiagram
    participant U as 用户
    participant F as 前端
    participant B as Spring Boot后端
    participant S as Spring Security

    U->>F: 输入用户名密码
    F->>B: POST /auth/login
    B->>S: 校验用户名密码
    S-->>B: 校验通过
    B-->>F: 返回 JWT
    F->>F: 本地保存 token
    F->>B: 请求业务接口并携带 Authorization: Bearer token
    B->>S: JWT过滤器解析 token
    S-->>B: 设置认证信息到上下文
    B-->>F: 返回业务数据

2.3 权限控制是怎么生效的

权限控制通常分成两层:

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

比如:

  • /user/profile:只要登录即可访问
  • /admin/**:必须具备 ROLE_ADMIN
  • /order/delete:必须具备 order:delete 权限

在 Spring Security 中,我们通常会把:

  • 用户身份放进 Authentication
  • 角色/权限放进 GrantedAuthority

后续通过注解或配置做控制。

2.4 核心组件关系

flowchart LR
    A[前端登录请求] --> B[认证控制器 AuthController]
    B --> C[AuthenticationManager]
    C --> D[UserDetailsService]
    D --> E[加载用户/角色/权限]
    C --> F[认证成功]
    F --> G[JwtService生成Token]
    G --> H[前端保存Token]
    H --> I[携带Bearer Token访问接口]
    I --> J[JwtAuthenticationFilter]
    J --> K[SecurityContextHolder]
    K --> L[接口权限判断]

三、前置知识与环境准备

3.1 技术栈

本文示例基于:

  • JDK 17
  • Spring Boot 3.2.x
  • Spring Security 6
  • jjwt 0.11.5
  • Maven

3.2 示例目标

我们要实现下面这组接口:

  • POST /auth/login:登录并返回 token
  • GET /user/profile:登录用户可访问
  • GET /admin/hello:仅管理员可访问

3.3 项目结构

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

四、实战代码(可运行)

下面这套代码可以直接拼成一个最小可运行项目。为了聚焦 JWT 流程,我这里先用内存用户模拟数据库用户;你接入 MySQL 时,我后面会讲改造点。


4.1 Maven 依赖

pom.xml

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

    <groupId>com.example</groupId>
    <artifactId>jwt-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>

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

    <properties>
        <java.version>17</java.version>
        <jjwt.version>0.11.5</jjwt.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>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

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

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

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

4.2 配置文件

src/main/resources/application.yml

server:
  port: 8080

jwt:
  secret: 12345678901234567890123456789012
  expiration: 3600000

spring:
  jackson:
    serialization:
      indent-output: true

这里的 jwt.secret 只是演示用途。线上一定要换成高强度随机密钥,后面会细讲。


4.3 启动类

JwtDemoApplication.java

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.4 登录请求与响应模型

model/LoginRequest.java

package com.example.jwtdemo.model;

import jakarta.validation.constraints.NotBlank;
import lombok.Data;

@Data
public class LoginRequest {

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

    @NotBlank(message = "密码不能为空")
    private String password;
}

model/LoginResponse.java

package com.example.jwtdemo.model;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class LoginResponse {
    private String token;
    private String tokenType;
}

4.5 JWT 工具服务

security/JwtService.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.Service;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

@Service
public class JwtService {

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

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

    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("authorities", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toList()));

        return Jwts.builder()
                .setClaims(claims)
                .setSubject(userDetails.getUsername())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSignInKey(), SignatureAlgorithm.HS256)
                .compact();
    }

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

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

    public boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

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

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

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

说明:这里为了演示方便,用字符串配置生成密钥。更规范的方式是直接提供 Base64 编码后的强随机密钥。


4.6 自定义用户加载服务

security/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 ("admin".equals(username)) {
            return User.withUsername("admin")
                    .password("$2a$10$N9qo8uLOickgx2ZMRZo5i.u1vY8D3RT5tZ342XQI3/OQvPsvBLn8y")
                    .authorities(List.of(
                            new SimpleGrantedAuthority("ROLE_ADMIN"),
                            new SimpleGrantedAuthority("user:read"),
                            new SimpleGrantedAuthority("admin:read")
                    ))
                    .build();
        }

        if ("user".equals(username)) {
            return User.withUsername("user")
                    .password("$2a$10$N9qo8uLOickgx2ZMRZo5i.u1vY8D3RT5tZ342XQI3/OQvPsvBLn8y")
                    .authorities(List.of(
                            new SimpleGrantedAuthority("ROLE_USER"),
                            new SimpleGrantedAuthority("user:read")
                    ))
                    .build();
        }

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

上面两个用户的密码都是:

password

4.7 JWT 过滤器

这个过滤器负责:

  1. 从请求头提取 Authorization
  2. 判断是否是 Bearer token
  3. 解析 token 中的用户名
  4. 加载用户信息
  5. 校验 token
  6. 把认证信息放入 Spring Security 上下文

security/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 lombok.RequiredArgsConstructor;
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
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final CustomUserDetailsService 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);
    }
}

4.8 Spring Security 配置

这是整篇文章最关键的地方。

config/SecurityConfig.java

package com.example.jwtdemo.config;

import com.example.jwtdemo.security.JwtAuthenticationFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
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;
import com.example.jwtdemo.security.CustomUserDetailsService;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;
    private final CustomUserDetailsService 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("/auth/login").permitAll()
                        .requestMatchers("/error").permitAll()
                        .anyRequest().authenticated()
                )
                .authenticationProvider(authenticationProvider())
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

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

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

这里有两个要点:

  • SessionCreationPolicy.STATELESS:明确告诉 Spring Security,我们不用 Session
  • addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class):JWT 过滤器要在用户名密码认证过滤器之前执行

4.9 登录接口

controller/AuthController.java

package com.example.jwtdemo.controller;

import com.example.jwtdemo.model.LoginRequest;
import com.example.jwtdemo.model.LoginResponse;
import com.example.jwtdemo.security.JwtService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

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

    private final AuthenticationManager authenticationManager;
    private final JwtService jwtService;

    @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 = jwtService.generateToken(userDetails);

            return new LoginResponse(token, "Bearer");
        } catch (BadCredentialsException e) {
            throw new RuntimeException("用户名或密码错误");
        }
    }
}

4.10 业务接口与权限注解

controller/UserController.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 UserController {

    @GetMapping("/user/profile")
    @PreAuthorize("hasAnyRole('USER', 'ADMIN')")
    public Map<String, Object> profile(Authentication authentication) {
        return Map.of(
                "message", "当前用户信息",
                "username", authentication.getName(),
                "authorities", authentication.getAuthorities()
        );
    }
}

controller/AdminController.java

package com.example.jwtdemo.controller;

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

import java.util.Map;

@RestController
public class AdminController {

    @GetMapping("/admin/hello")
    @PreAuthorize("hasRole('ADMIN')")
    public Map<String, Object> hello() {
        return Map.of(
                "message", "只有管理员才能访问这个接口"
        );
    }
}

五、运行与验证

项目启动后,我们按顺序验证。


5.1 获取 token

使用 user/password 登录:

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

返回示例:

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

5.2 访问普通用户接口

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

返回示例:

{
  "message": "当前用户信息",
  "username": "user",
  "authorities": [
    {
      "authority": "ROLE_USER"
    },
    {
      "authority": "user:read"
    }
  ]
}

5.3 访问管理员接口

如果用 user 的 token:

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

你会得到 403。

如果用 admin/password 登录,再访问,就会成功。


5.4 权限判断流程图

flowchart TD
    A[请求进入接口] --> B{是否携带Bearer Token}
    B -- 否 --> C[匿名访问]
    B -- 是 --> D[解析JWT]
    D --> E{签名/过期校验是否通过}
    E -- 否 --> C
    E -- 是 --> F[加载用户详情]
    F --> G[写入SecurityContext]
    G --> H{接口权限是否满足}
    H -- 否 --> I[返回403]
    H -- 是 --> J[返回业务结果]

六、接入数据库时怎么改

上面的示例为了简单,用户信息写死在 CustomUserDetailsService 里。实际项目一般会从数据库读取。

一个常见做法是设计三张表:

  • sys_user
  • sys_role
  • sys_permission

关系通常是:

  • 用户和角色:多对多
  • 角色和权限:多对多

比如:

classDiagram
    class User {
      Long id
      String username
      String password
      Integer status
    }

    class Role {
      Long id
      String roleCode
      String roleName
    }

    class Permission {
      Long id
      String permCode
      String permName
    }

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

然后在 loadUserByUsername 中:

  1. 根据用户名查用户
  2. 查用户拥有的角色
  3. 查角色对应的权限
  4. 组装为 GrantedAuthority

伪代码大概是这样:

@Override
public UserDetails loadUserByUsername(String username) {
    SysUser user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("用户不存在"));

    List<String> roles = roleRepository.findRoleCodesByUserId(user.getId());
    List<String> permissions = permissionRepository.findPermCodesByUserId(user.getId());

    List<GrantedAuthority> authorities = new ArrayList<>();
    roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
    permissions.forEach(perm -> authorities.add(new SimpleGrantedAuthority(perm)));

    return User.withUsername(user.getUsername())
            .password(user.getPassword())
            .authorities(authorities)
            .disabled(user.getStatus() != 1)
            .build();
}

注意这里角色前缀的处理:

  • hasRole("ADMIN") 对应权限字符串应为 ROLE_ADMIN
  • 如果你只存 ADMIN,那加载时要手动补上 ROLE_

这是我见过非常高频的一个坑。


七、常见坑与排查

这一节很重要。很多人其实不是不会写,而是写完后“不知道为什么不生效”。


7.1 明明登录成功,访问接口还是 401

可能原因

1)没带 Authorization 请求头

应该是:

Authorization: Bearer xxxxx

不是:

Authentication: Bearer xxxxx

也不是只传 token 字符串。

2)Bearer 前缀少了空格

必须是:

Bearer 空格 token

3)JWT 过滤器没有加入过滤链

确认你配置了:

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

4)token 已经过期

可以打印过期时间排查:

System.out.println(jwtService.extractExpiration(token));

7.2 明明 token 解析成功,但权限注解不生效

常见原因

1)没开启方法级权限

确认配置类上有:

@EnableMethodSecurity

2)角色前缀写错

如果你用了:

@PreAuthorize("hasRole('ADMIN')")

那么权限列表里必须是:

ROLE_ADMIN

如果你的权限实际是 ADMIN,就会失败。

3)把角色和权限混用了

比如你写的是:

@PreAuthorize("hasAuthority('ADMIN')")

但实际存的是:

ROLE_ADMIN

这也不匹配。


7.3 返回 403,不是 401,到底说明什么

这个问题很经典:

  • 401 Unauthorized:通常表示“你还没通过认证”,比如没 token、token 无效
  • 403 Forbidden:通常表示“你身份是有效的,但权限不够”

排查思路:

  • 先确认 SecurityContext 里有没有认证信息
  • 再打印当前用户的 authorities
  • 最后核对注解表达式

可以临时在接口里打印:

@GetMapping("/debug/me")
public Object me(Authentication authentication) {
    return authentication;
}

如果这里拿到的是 null,说明认证链路就没走通。


7.4 登录接口总是 403

通常是因为:

  • 没放行 /auth/login
  • CSRF 没关闭,但你又是纯前后端分离 API 场景

当前文中的配置已经处理了:

.requestMatchers("/auth/login").permitAll()
.csrf(csrf -> csrf.disable())

7.5 跨域导致前端看起来像“登录失败”

实际上后端可能已经成功返回 token,但浏览器因为跨域策略拦截了响应。

如果前端和后端域名不同,需要配置 CORS。比如:

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

    var source = new org.springframework.web.cors.UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}

如果你线上需要多个来源,别偷懒直接全放开,风险不小。


八、安全/性能最佳实践

JWT 好用,但不是“配上就安全”。这里给你几个真正落地时必须考虑的点。


8.1 不要把敏感信息放进 JWT

JWT payload 虽然有签名,但默认不加密。别人拿到 token 后,可以轻松解码出内容。

不要放:

  • 明文密码
  • 手机号全量
  • 身份证号
  • 银行卡号
  • 超大对象

建议放:

  • 用户 ID
  • 用户名
  • 角色/权限摘要
  • 签发时间
  • 过期时间
  • token 唯一标识 jti

8.2 token 过期时间别设太长

很多项目为了“省事”,把 token 有效期设成 7 天、30 天,甚至永久有效。这样一旦泄露,风险非常大。

更稳妥的做法:

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

本文为了简化没有展开 Refresh Token,但生产环境强烈建议你采用双 token 机制


8.3 退出登录不能只靠前端删 token

前端删除本地 token 只能算“用户设备上的退出”,并不能让服务端立即失效。

如果业务对安全要求高,建议做:

  • token 黑名单
  • refresh token 存库
  • 单点登录踢下线
  • 修改密码后让旧 token 全部失效

一种常见思路是把 jti 存进 Redis,退出时加入黑名单,过滤器校验时额外检查。


8.4 权限变更后的旧 token 问题

如果用户在 token 生效期间被撤销权限,但旧 token 里还带着历史权限,就会出现“权限延迟生效”。

解决办法通常有三种:

  1. 缩短 token 生命周期
  2. 每次请求都查数据库权限
  3. 引入 token 版本号 / 权限版本号

经验上:

  • 权限变更不频繁、接口并发高:优先短 token
  • 权限要求极高:每次实时查权限或加 Redis 缓存

8.5 密钥管理要正规

不要把密钥:

  • 写死在代码里
  • 提交到 Git
  • 多环境共用同一份

建议:

  • 使用环境变量或配置中心
  • 区分 dev/test/prod
  • 定期轮换密钥
  • 轮换时支持双密钥过渡验证

8.6 注意过滤器里的异常处理

本文示例中,为了让流程清晰,token 解析异常时直接放过请求,让后续流程返回未认证结果。
真实项目里你最好统一返回标准错误码,比如:

{
  "code": 40101,
  "message": "token无效或已过期"
}

可以通过自定义:

  • AuthenticationEntryPoint
  • AccessDeniedHandler

来统一处理 401/403 响应格式。


8.7 性能上的现实建议

JWT 常被认为“无状态、性能高”,但也要看你怎么用。

如果你每次请求都:

  • 解析 token
  • 查数据库用户
  • 查数据库角色
  • 查数据库权限

那它并不会特别轻。

更合理的做法:

  • token 中带必要的身份信息
  • 用户基础信息放缓存
  • 权限信息放 Redis 或本地缓存
  • 对高敏感接口再走实时校验

不要一上来就追求“绝对无状态”,那在复杂权限系统里通常不现实。


九、逐步验证清单

如果你准备自己从零搭一遍,我建议按下面顺序验证,别一口气全写完再查 bug。

第一步:只打通登录

  • /auth/login 能返回 token

第二步:只做登录态识别

  • 携带 token 调 /user/profile
  • 能拿到当前用户名称

第三步:再做角色控制

  • user 能访问 /user/profile
  • user 不能访问 /admin/hello
  • admin 能访问 /admin/hello

第四步:补统一异常处理

  • token 缺失返回 401
  • token 过期返回 401
  • 权限不足返回 403

第五步:接数据库与缓存

  • 用户表
  • 角色表
  • 权限表
  • Redis 缓存或黑名单机制

这个节奏很重要。很多人是登录、权限、异常处理、数据库、刷新 token 一起上,最后根本分不清是哪一层坏了。


十、适合生产环境的扩展方向

如果你已经把本文示例跑通,接下来可以继续演进:

1. 引入 Refresh Token

减少频繁登录,同时避免 access token 有效期过长。

2. 支持基于菜单/资源的权限模型

比如后端权限码和前端路由按钮联动。

3. 接入 Redis 黑名单

实现退出登录、踢人下线、密码修改后强制失效。

4. 统一认证中心

多个服务共享签发与校验规则,避免每个服务各写一套。

5. 网关层前置校验

如果是微服务架构,可在网关先做 token 校验和基础身份透传。


十一、总结

这篇文章我们做了一件很“实战”的事:
用 Spring Boot + Spring Security + JWT,完整走通了前后端分离项目中最核心的认证授权链路。

你应该掌握了这些关键点:

  • JWT 解决的是前后端分离中的无状态认证问题
  • Spring Security 负责认证上下文与权限判断
  • JWT 过滤器负责把 token 解析后放进 SecurityContext
  • @PreAuthorize 可以优雅地做角色/权限控制
  • 401 和 403 的含义不同,排查方向也不同
  • 生产环境里必须考虑 token 过期、退出登录、权限变更、密钥管理等问题

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

  1. 先跑通最小链路:登录、带 token、识别用户、拦管理员接口
  2. 再接数据库:别一开始就上完整 RBAC
  3. 最后补安全细节:刷新 token、黑名单、统一异常、密钥轮换

边界条件也要记住:
JWT 不是万能钥匙。
如果你的系统对“立即失效”“精细权限变更”“会话可控性”要求极高,就不要迷信纯 JWT 无状态方案,适当引入 Redis、会话存储或认证中心,反而更稳。

如果你愿意,我个人建议你在自己的项目里先复刻这篇文章的最小 demo,再把用户信息从内存实现替换成数据库查询。走完这一步,你对 Spring Security + JWT 的理解会比只看配置强很多。


分享到:

上一篇
《自动化测试中的测试数据管理实战:从环境隔离到数据构造与回收策略》
下一篇
《Spring Boot 3 中基于 JWT 与 Spring Security 6 的前后端分离认证授权实战》