Spring Boot 中基于 JWT 与 Spring Security 的前后端分离认证授权实战指南
前后端分离项目里,登录认证这件事,几乎是绕不过去的。很多人第一次接触 Spring Security 时,会觉得它“又强又绕”:过滤器链长、配置项多、异常处理分散。再叠加 JWT,就更容易出现“能登录但访问不了接口”“Token 明明没过期却被判无效”这类问题。
这篇文章我会从实战落地的角度,带你完成一套可运行的认证授权方案:
Spring Boot + Spring Security + JWT,适合前后端分离的 REST API 项目。
我会尽量不讲空话,而是像带你做一个小项目一样,把关键点串起来。
一、背景与问题
在传统服务端渲染项目中,常见做法是:
- 用户登录后,服务端创建 Session
- 浏览器持有 Cookie
- 后续请求通过 Session 判断用户身份
但在前后端分离架构里,会遇到几个现实问题:
-
前端可能不是浏览器
- 可能是 Web、App、小程序,Cookie/Session 不总是方便管理
-
服务扩展后,Session 共享麻烦
- 多实例部署要做 Session 共享或粘性会话
-
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:登录,返回 JWTGET /api/hello:登录用户可访问GET /api/admin:仅管理员可访问
示例用户先用内存方式模拟:
user / 123456,角色:USERadmin / 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);
}
}
这个登录过程是标准姿势:
- 前端提交用户名密码
AuthenticationManager调用UserDetailsService- 校验密码
- 成功后生成 JWT
- 返回给前端
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;
}
}
九、如何从内存用户切换到数据库用户
实战中,你大概率会接数据库。思路其实不复杂:
- 用户表查询用户名、密码、状态
- 用户角色表查询角色列表
- 组装成
UserDetails - JWT 里只放必要字段
- 每次请求根据用户名或用户 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 完成接口权限控制
如果你现在是中级开发者,我建议你优先记住这几个最关键的点:
- JWT 只是令牌,真正执行安全控制的是 Spring Security
- 过滤器里一定要正确设置
SecurityContext hasRole("ADMIN")对应的是ROLE_ADMIN- 前后端分离项目通常要禁用 Session、formLogin、CSRF
- 生产环境要补上统一异常、CORS、刷新机制和注销策略
最后给一个可执行建议:
- 如果你是做内部中后台,本文这套结构已经能作为起点
- 如果你是做面向公网的系统,务必补充 Refresh Token、黑名单和统一安全响应
- 如果你的权限模型很复杂,优先把“身份认证”和“业务权限”分层,不要把所有逻辑都堆进 JWT
先把链路跑通,再逐步增强安全性,这通常比一开始追求“最完整方案”更靠谱。