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

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

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

Spring Boot 中基于 JWT + Spring Security 的前后端分离认证与权限控制实战

前后端分离项目里,认证和授权几乎是绕不开的基础设施。很多同学一开始会觉得:不就是“登录后返回一个 token”吗?真到项目里,问题马上就来了:

  • token 放哪?
  • Spring Security 怎么接入?
  • 认证和权限控制怎么区分?
  • 接口明明登录了,为什么还是 403?
  • 退出登录后 token 为什么还能用?

这篇文章我会用一个能跑起来的 Spring Boot 示例,带你完整走一遍:

  1. 用户登录签发 JWT
  2. 前端携带 JWT 访问接口
  3. Spring Security 解析 token 并建立登录态
  4. 基于角色/权限控制接口访问
  5. 排查常见 401/403 问题
  6. 落到实际项目的安全与性能最佳实践

整篇内容偏实战,不追求“把所有概念讲到百科全书级别”,而是尽量像我带你一起搭一遍。


一、背景与问题

在传统服务端渲染应用里,常见做法是:

  • 用户登录成功后,服务端把登录状态放进 Session
  • 浏览器靠 Cookie 自动携带 SessionId
  • 服务端每次请求根据 Session 恢复用户身份

但到了前后端分离场景,这套方式会遇到几个典型问题:

  • 前端可能是 Vue/React,也可能是 App、小程序
  • 服务端可能是多实例部署,Session 共享成本增加
  • 跨域时 Cookie、Session 配置会更复杂
  • 微服务之间传递用户身份不够方便

这时候,JWT(JSON Web Token) 就很适合作为一种无状态认证方案:

  • 登录成功后,服务端签发 JWT
  • 前端保存 JWT
  • 后续请求放到 Authorization: Bearer xxx
  • 服务端校验 JWT,解析出用户信息和权限

JWT 解决的是“你是谁”的问题,而 Spring Security 解决的是“你能访问什么”的问题。两者结合,正好构成前后端分离项目中非常常见的一套安全方案。


二、前置知识与环境准备

2.1 适合谁看

这篇文章默认你已经了解:

  • Java 基础语法
  • Spring Boot 基础项目结构
  • RESTful API 基本概念
  • Maven 依赖管理

如果你之前没系统用过 Spring Security,也没关系,我会尽量讲清楚最关键的那条链路。

2.2 环境信息

本文示例环境:

  • JDK 8+
  • Spring Boot 2.7.x
  • Spring Security 5.x
  • Maven 3.6+
  • JWT 库:jjwt

三、核心原理

先别急着写代码,我们先把这套方案背后的流程理顺。只要这张图想明白了,后面的配置就不会觉得“玄学”。

3.1 整体认证流程

flowchart TD
    A[用户提交用户名密码] --> B[登录接口 /login]
    B --> C[AuthenticationManager 校验]
    C -->|成功| D[生成 JWT]
    C -->|失败| E[返回 401]
    D --> F[前端保存 Token]
    F --> G[请求受保护接口]
    G --> H[Authorization: Bearer Token]
    H --> I[JWT 过滤器解析 Token]
    I --> J[SecurityContext 写入用户信息]
    J --> K[Spring Security 鉴权]
    K -->|有权限| L[返回业务数据]
    K -->|无权限| M[返回 403]

3.2 认证与授权要分开理解

很多人第一次上手 Spring Security,最容易混淆的就是:

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

举个简单例子:

  • 用户 admin 登录成功,说明认证通过
  • 但他访问 /user/delete 是否允许,还要看有没有 ROLE_ADMINuser:delete 权限

也就是说:

  • JWT 负责承载身份信息
  • Spring Security 负责基于身份和权限做访问控制

3.3 JWT 的基本组成

JWT 一般由三部分组成:

  • Header
  • Payload
  • Signature

格式如下:

xxxxx.yyyyy.zzzzz

其中 Payload 里通常放:

  • 用户名
  • 用户 ID
  • 角色/权限
  • 过期时间

但这里有个经验提醒:不要把敏感信息直接塞进 JWT Payload,因为它只是 Base64 编码,不是加密。

3.4 Spring Security 在这里扮演什么角色

我们会借助 Spring Security 做三件事:

  1. 登录时校验用户名密码
  2. 每次请求时从 JWT 恢复用户身份
  3. 基于角色/权限控制接口访问

这个过程里最关键的是两个点:

  • UserDetailsService:负责根据用户名查用户
  • OncePerRequestFilter:负责每次请求解析 JWT

四、项目结构设计

先看一下示例结构,保持简单但完整:

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

为了聚焦主题,本文不接数据库,先用内存用户模拟。你把流程跑通后,再替换成 MySQL/JPA/MyBatis 都很顺。


五、实战代码(可运行)

5.1 添加依赖

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>2.7.18</version>
        <relativePath/>
    </parent>

    <properties>
        <java.version>8</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</artifactId>
            <version>0.9.1</version>
        </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>

5.2 启动类

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

5.3 登录请求与响应对象

LoginRequest.java

package com.example.jwtdemo.model;

import javax.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;
    }
}

LoginResponse.java

package com.example.jwtdemo.model;

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.4 JWT 工具类

这个类负责:

  • 生成 token
  • 从 token 解析用户名
  • 校验 token 是否有效

JwtTokenUtil.java

package com.example.jwtdemo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
public class JwtTokenUtil {

    private final String secret = "myJwtSecretKey123456";
    private final long expiration = 1000 * 60 * 60;

    public String generateToken(UserDetails userDetails) {
        return Jwts.builder()
                .setSubject(userDetails.getUsername())
                .claim("roles", userDetails.getAuthorities())
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }

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

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

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

    private Claims getClaims(String token) {
        return Jwts.parser()
                .setSigningKey(secret)
                .parseClaimsJws(token)
                .getBody();
    }
}

这里为了示例简化了密钥管理,真实项目不要把 secret 硬编码在代码里,后面最佳实践部分会讲。


5.5 自定义用户加载逻辑

在真实项目里,这里通常会查数据库。为了让示例更聚焦,我们先手写两个用户:

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

CustomUserDetailsService.java

package com.example.jwtdemo.security;

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

import java.util.Arrays;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final PasswordEncoder passwordEncoder;

    public CustomUserDetailsService(PasswordEncoder passwordEncoder) {
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("admin".equals(username)) {
            return new User(
                    "admin",
                    passwordEncoder.encode("123456"),
                    Arrays.asList(
                            new SimpleGrantedAuthority("ROLE_ADMIN"),
                            new SimpleGrantedAuthority("user:read"),
                            new SimpleGrantedAuthority("user:write")
                    )
            );
        }

        if ("user".equals(username)) {
            return new User(
                    "user",
                    passwordEncoder.encode("123456"),
                    Arrays.asList(
                            new SimpleGrantedAuthority("ROLE_USER"),
                            new SimpleGrantedAuthority("user:read")
                    )
            );
        }

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

一个小提醒

这里每次 loadUserByUsernameencode("123456"),在示例里可以跑,但生产里通常是把数据库里已经加密过的密码取出来比较。


5.6 JWT 认证过滤器

这是整套方案最关键的一环。每个请求进来后,它会:

  1. 读取请求头 Authorization
  2. 提取 Bearer Token
  3. 解析用户名
  4. 查用户详情
  5. 校验 token
  6. 把认证信息放入 SecurityContext

JwtAuthenticationFilter.java

package com.example.jwtdemo.filter;

import com.example.jwtdemo.security.CustomUserDetailsService;
import com.example.jwtdemo.security.JwtTokenUtil;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.security.core.userdetails.UserDetails;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
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(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        String token = null;
        String username = null;

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            token = authHeader.substring(7);
            try {
                username = jwtTokenUtil.getUsernameFromToken(token);
            } catch (Exception e) {
                logger.warn("JWT 解析失败: " + e.getMessage());
            }
        }

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

            if (jwtTokenUtil.validateToken(token, userDetails)) {
                UsernamePasswordAuthenticationToken authenticationToken =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );
                authenticationToken.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request)
                );
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
            }
        }

        filterChain.doFilter(request, response);
    }
}

5.7 Spring Security 配置

我们要做几件事:

  • 放行 /login
  • 其他接口都要认证
  • 使用无状态会话
  • 注册 JWT 过滤器
  • 开启密码加密器

SecurityConfig.java

package com.example.jwtdemo.config;

import com.example.jwtdemo.filter.JwtAuthenticationFilter;
import com.example.jwtdemo.security.CustomUserDetailsService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final CustomUserDetailsService userDetailsService;
    private final JwtAuthenticationFilter jwtAuthenticationFilter;

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

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated();

        http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

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

5.8 登录接口

登录接口通过 AuthenticationManager 完成用户名密码校验。校验通过后,生成 JWT 返回给前端。

AuthController.java

package com.example.jwtdemo.controller;

import com.example.jwtdemo.model.LoginRequest;
import com.example.jwtdemo.model.LoginResponse;
import com.example.jwtdemo.security.JwtTokenUtil;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

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

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

        return new LoginResponse(token);
    }
}

5.9 业务接口与权限控制

这里我演示两种控制方式:

  1. 在配置类里通过 URL 做角色限制
  2. 在方法上用 @PreAuthorize 做更细粒度权限控制

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

import java.util.HashMap;
import java.util.Map;

@RestController
public class UserController {

    @GetMapping("/profile")
    public Map<String, Object> profile(Authentication authentication) {
        Map<String, Object> result = new HashMap<>();
        result.put("username", authentication.getName());
        result.put("authorities", authentication.getAuthorities());
        return result;
    }

    @GetMapping("/admin/dashboard")
    public String adminDashboard() {
        return "管理员面板访问成功";
    }

    @PreAuthorize("hasAuthority('user:read')")
    @GetMapping("/user/list")
    public String userList() {
        return "用户列表读取成功";
    }

    @PreAuthorize("hasAuthority('user:write')")
    @PostMapping("/user/create")
    public String createUser() {
        return "用户创建成功";
    }
}

六、接口调用验证清单

到这里,项目已经可以跑了。下面我们按顺序验证,建议你别跳步骤。

6.1 启动项目

mvn spring-boot:run

默认端口:

http://localhost:8080

6.2 登录获取 token

使用 admin 登录

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

返回示例:

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

使用 user 登录

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

6.3 访问个人信息接口

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

返回示例:

{
  "username": "admin",
  "authorities": [
    {
      "authority": "ROLE_ADMIN"
    },
    {
      "authority": "user:read"
    },
    {
      "authority": "user:write"
    }
  ]
}

6.4 访问管理员接口

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

返回:

管理员面板访问成功

如果换成 user 的 token:

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

会得到 403 Forbidden


6.5 验证细粒度权限

user 可读用户列表

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

user 不可创建用户

curl -X POST http://localhost:8080/user/create \
  -H "Authorization: Bearer user的token"

这时会是 403,因为 user 没有 user:write 权限。


七、请求链路解析

很多问题其实都出在“不知道请求到底走到哪一步了”。下面这张时序图,把登录和访问接口的关键链路串一下。

sequenceDiagram
    participant Client as 前端/客户端
    participant Auth as AuthController
    participant AM as AuthenticationManager
    participant UDS as UserDetailsService
    participant JWT as JwtTokenUtil
    participant Filter as JwtAuthenticationFilter
    participant SC as SecurityContext
    participant API as 业务接口

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

    Client->>Filter: 请求 /profile + Bearer Token
    Filter->>JWT: 解析 Token
    JWT-->>Filter: username
    Filter->>UDS: loadUserByUsername()
    UDS-->>Filter: UserDetails
    Filter->>SC: 设置 Authentication
    Filter->>API: 放行请求
    API-->>Client: 返回业务数据

八、常见坑与排查

这一部分很重要。很多同学代码看着和教程差不多,但就是不通,往往都卡在这些地方。我自己也踩过不少。

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

常见原因

  1. 请求头没带 Authorization
  2. 请求头格式不对,少了 Bearer
  3. 过滤器没有注册到 Spring Security 链
  4. token 过期了
  5. token 被截断了

排查方式

先打印请求头:

System.out.println(request.getHeader("Authorization"));

确认是不是这种格式:

Authorization: Bearer eyJhbGciOi...

再看过滤器里是否成功解析出 username


8.2 登录接口返回 403

这个问题非常常见,通常是因为 /login 没放行。

确认配置里有:

.antMatchers("/login").permitAll()

如果你的前端是跨域请求,还要确认是不是 CORS 预检请求 被拦住了。尤其是浏览器发 OPTIONS 请求时,很容易误判成“登录失败”。


8.3 明明是管理员,却访问 /admin/** 还是 403

这通常和角色前缀有关。

Spring Security 对 hasRole("ADMIN") 的判断,实际上要求权限中存在:

ROLE_ADMIN

如果你写的是:

new SimpleGrantedAuthority("ADMIN")

hasRole("ADMIN") 是匹配不上的。

规则记住一句就够了

  • hasRole("ADMIN") 对应权限值 ROLE_ADMIN
  • hasAuthority("user:write") 对应权限值 user:write

8.4 token 里存了权限,为什么接口权限没生效

因为这篇示例里,权限判断真正依赖的是 UserDetailsService 查出来的 authorities,不是直接从 token claim 恢复权限对象。

这是一种比较稳妥的做法:

  • token 主要承载身份
  • 权限以服务端查库结果为准

这样做的好处是,用户权限变更后更容易及时生效。不然 token 一旦签发,里面的权限就“冻结”了,直到过期前都可能继续生效。


8.5 使用 Postman 正常,浏览器前端请求失败

十有八九是跨域问题。

你需要补充 CORS 配置,例如:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.cors().and()
        .csrf().disable()
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .authorizeRequests()
        .antMatchers("/login").permitAll()
        .anyRequest().authenticated();

    http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}

再提供一个 CORS Bean:

@Bean
public org.springframework.web.cors.CorsConfigurationSource corsConfigurationSource() {
    org.springframework.web.cors.CorsConfiguration configuration = new org.springframework.web.cors.CorsConfiguration();
    configuration.addAllowedOriginPattern("*");
    configuration.addAllowedHeader("*");
    configuration.addAllowedMethod("*");
    configuration.setAllowCredentials(true);

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

生产环境不要直接 * 全放开,最好只允许你的前端域名。


8.6 密码明明对了,登录却一直失败

如果你用了 BCryptPasswordEncoder,要特别注意:

  • 数据库存的是加密后的密码
  • 登录时传的是明文密码
  • Spring Security 会自动做 matches

不要手动把前端传来的密码再次加密后去比,那样大概率永远不相等。


九、安全/性能最佳实践

跑通只是第一步。真正上线时,这部分比“能不能工作”更重要。

9.1 不要把密钥写死在代码里

示例里为了简单,直接写了:

private final String secret = "myJwtSecretKey123456";

真实项目建议:

  • 放到环境变量
  • 放到配置中心
  • 至少放到 application.yml,并通过部署环境覆盖
  • 更高要求场景可接 KMS/HSM

例如:

jwt:
  secret: ${JWT_SECRET:defaultSecretKey}
  expiration: 3600000

9.2 token 过期时间不要太长

很多项目图省事,把 token 设成 7 天、30 天,甚至永久有效。这其实风险很高。

更稳妥的做法:

  • Access Token:15 分钟~2 小时
  • Refresh Token:更长一些,但要单独管理

如果你只有 JWT 而没有刷新机制,那我建议先把有效期控制在一个合理范围内,而不是无限拉长。


9.3 退出登录要考虑“失效机制”

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

  • 只要 token 没过期
  • 且签名正确
  • 服务端就能认它有效

所以“退出登录”不是天然成立的。常见解决方案有:

  1. 短期 token + 刷新 token
  2. 服务端维护黑名单
  3. 用户版本号/密码更新时间校验

我在业务系统里更常用的是:

  • Access Token 短时有效
  • Refresh Token 持久一点
  • 敏感操作再校验一次权限或二次认证

9.4 不要在 JWT 里放敏感信息

不要放:

  • 明文手机号
  • 身份证号
  • 银行卡信息
  • 密码
  • 详细业务权限快照(如果变化频繁)

JWT 更适合放:

  • 用户 ID
  • 用户名
  • 签发时间
  • 过期时间
  • 少量稳定 claim

9.5 权限控制优先服务端校验

前端当然也可以做按钮级权限控制,但那只是用户体验优化,不是安全措施

真正的权限控制必须在服务端完成:

  • URL 访问权限
  • 方法级权限
  • 数据级权限

前端把按钮隐藏了,不代表接口就安全了。这个误区我见过太多次。


9.6 注意过滤器异常处理

如果 JWT 解析抛异常,不要让整个请求直接炸成 500。更好的方式是:

  • 记录日志
  • 清空上下文
  • 继续走后续流程
  • 由 Spring Security 统一返回 401

你还可以自定义:

  • AuthenticationEntryPoint:未认证时返回统一 401 JSON
  • AccessDeniedHandler:无权限时返回统一 403 JSON

这样前端会更好处理。


9.7 权限信息是否放进 JWT,要看业务边界

这里没有绝对标准,但可以这样取舍:

放进 JWT

优点:

  • 少一次查库
  • 性能更好

缺点:

  • 权限变更不容易实时生效
  • token 体积变大

每次查服务端

优点:

  • 权限变更能及时生效
  • 更容易统一控制

缺点:

  • 会多一次查询

如果是中后台管理系统,我通常更偏向于服务端实时获取权限,尤其是管理员权限经常调整的场景。


十、一个更贴近真实项目的演进思路

如果你准备把本文示例落到生产项目,可以按这个顺序升级:

flowchart LR
    A[内存用户示例] --> B[接入数据库用户表]
    B --> C[引入统一异常返回]
    C --> D[加入 CORS 与前后端联调]
    D --> E[接入 Redis 黑名单或刷新机制]
    E --> F[细化角色/菜单/按钮权限]
    F --> G[审计日志与风控增强]

这个顺序比较务实。很多项目一开始就想把 RBAC、菜单树、刷新 token、单点登录全部一步到位,结果反而把基础链路搞得很脆。我的建议是:先把认证链路打通,再逐步增强授权模型。


十一、边界条件与方案取舍

虽然 JWT 很常见,但也不是所有场景都必须上它。

适合 JWT 的场景

  • 前后端分离
  • 多终端接入
  • 微服务之间需要传递身份
  • 希望减少 Session 依赖

不一定适合 JWT 的场景

  • 纯后端渲染的传统 Web 系统
  • 强依赖“服务端立即失效”的安全要求
  • 认证链路非常简单、规模不大

如果你的系统只是一个内部小工具、单体部署、浏览器访问为主,其实 Session + Spring Security 可能更省心。技术选型不是“越流行越好”,而是“越适合越好”。


十二、总结

我们这篇文章完成了一个完整的实战闭环:

  • 用 Spring Security 做用户名密码认证
  • 用 JWT 在前后端分离场景中承载身份
  • 用过滤器在每次请求中恢复用户登录态
  • 用角色与权限实现接口访问控制
  • 了解了 401/403 的常见排查思路
  • 梳理了上线前必须关注的安全与性能实践

如果你准备真正落地,我建议你按下面这个清单执行:

逐步落地清单

  • 先用本文示例跑通登录与鉴权
  • 把内存用户替换成数据库用户表
  • 给接口统一返回 401/403 JSON 结构
  • 配置好 CORS,完成前后端联调
  • 缩短 access token 有效期
  • 设计 refresh token 或退出失效机制
  • 把 secret 从代码中移出
  • 对关键接口开启操作审计日志

最后给一个很实用的建议:遇到权限问题,不要一上来怀疑 Spring Security 太复杂,先把链路拆成“请求头 -> 过滤器 -> SecurityContext -> 权限表达式”四步逐一验证。 大多数问题,其实都能在这四步里定位出来。

如果你把这套流程真正理解了,后面不管是接数据库、做 RBAC,还是扩展到微服务网关鉴权,都会顺很多。


分享到:

上一篇
《Java中基于CompletableFuture构建高并发异步任务编排的实战指南》
下一篇
《从源码到生产:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南-201》