Java Web 开发中基于 Spring Boot + JWT 的登录鉴权与权限控制实战指南
很多 Java Web 项目做到登录这一步时,最容易先“跑起来”,然后再慢慢补安全和权限。但现实通常是:一旦接口增多、角色变复杂、前后端分离上线后,认证和授权问题会集中爆发。
这篇文章我不打算只讲概念,而是带你从一个典型的 Spring Boot 项目出发,把 登录、JWT 签发、请求鉴权、角色权限控制 一步步串起来。文章目标很明确:你看完后,能自己落地一套可运行的基础方案,也知道它的边界在哪,哪些坑要提前避开。
背景与问题
在传统 Java Web 开发里,登录态常常依赖 Session。它简单直接,但到了前后端分离、移动端、多服务调用的场景,Session 的问题会越来越明显:
- 服务端要保存会话状态
- 集群环境下要做 Session 共享
- 前后端跨域时处理起来别扭
- 接口调用更适合无状态认证
这时候 JWT(JSON Web Token)就很常见了。它的核心价值是:
- 用户登录成功后,服务端签发一个 Token
- 客户端后续每次请求带上 Token
- 服务端通过验签和解析 Token 来识别用户身份
- 可以结合角色、权限字段做接口访问控制
但很多人第一次做 JWT 鉴权时,容易踩下面这些坑:
- 只做了登录,没有做统一拦截
- Token 能解析,但没有做过期校验
- 角色信息放进 Token 后,改权限不生效
- 把 JWT 当成“绝对安全”方案
- 权限控制写死在 Controller 里,后面维护困难
所以我们这篇文章会重点解决两个问题:
- 怎么让 Spring Boot 正确完成登录鉴权
- 怎么把权限控制做得清晰、可维护
前置知识与环境准备
你需要具备的基础
本文默认你已经了解:
- Spring Boot 基本项目结构
- Maven 依赖管理
- RESTful API 基础
- Java 注解、过滤器、拦截器的基本概念
示例环境
本文示例基于:
- JDK 17
- Spring Boot 3.x
- Spring Security 6.x
- jjwt 0.11.x
- Maven
整体方案概览
先看我们要实现的流程:
- 用户通过用户名密码登录
- 服务端校验成功后签发 JWT
- 客户端把 JWT 放到
Authorization: Bearer xxx - 每次请求进入过滤器时校验 JWT
- 如果合法,就把用户身份和角色放入 Spring Security 上下文
- Controller 或方法级注解根据角色判断是否允许访问
核心原理
1. JWT 到底是什么
JWT 本质上是一个字符串,分成三段:
- Header:声明类型和签名算法
- Payload:保存用户数据,例如用户 ID、用户名、角色
- Signature:签名,防止内容被篡改
结构如下:
header.payload.signature
注意一点:JWT 默认只是 Base64Url 编码,不是加密。
也就是说,Payload 里的内容别人是可以解码看到的,只是不能随便篡改。
2. 认证与授权的区别
这是开发里非常容易混淆的点:
- 认证(Authentication):你是谁?
- 授权(Authorization):你能访问什么?
在本文方案中:
- 用户登录校验用户名密码,属于认证
- 根据
ROLE_ADMIN、ROLE_USER决定接口能否访问,属于授权
3. 为什么 Spring Security 适合做这件事
Spring Security 的优势在于它已经提供了成熟的安全链路:
- 过滤器链拦截请求
- SecurityContext 保存当前登录用户
- 支持角色和权限判断
- 支持注解方式控制访问
我们要做的,不是自己写一整套安全框架,而是把 JWT 接进 Spring Security 的流程里。
认证与授权流程图
flowchart TD
A[用户提交用户名密码] --> B[登录接口校验账号]
B --> C{校验是否成功}
C -- 否 --> D[返回 401 或错误信息]
C -- 是 --> E[生成 JWT]
E --> F[客户端保存 Token]
F --> G[请求受保护接口时携带 Authorization Bearer Token]
G --> H[JWT 过滤器解析并验签]
H --> I{Token 是否合法}
I -- 否 --> J[返回 401]
I -- 是 --> K[构建 Authentication]
K --> L[写入 SecurityContext]
L --> M[权限判断]
M --> N[返回业务数据]
Spring Security 与 JWT 的执行时序
sequenceDiagram
participant Client as 客户端
participant LoginApi as 登录接口
participant JwtUtil as JWT工具类
participant Filter as JWT过滤器
participant Security as Spring Security
participant Controller as 业务接口
Client->>LoginApi: POST /auth/login 用户名+密码
LoginApi->>LoginApi: 校验用户名密码
LoginApi->>JwtUtil: 生成JWT
JwtUtil-->>Client: 返回token
Client->>Filter: 请求 /admin/profile + Bearer token
Filter->>JwtUtil: 解析并校验token
JwtUtil-->>Filter: 用户信息/角色
Filter->>Security: 设置Authentication到SecurityContext
Security->>Controller: 进入接口并做权限校验
Controller-->>Client: 返回结果
实战代码(可运行)
下面给你一套简化但完整的可运行示例。
为了聚焦 JWT 和权限控制,我们先不接数据库,直接用内存用户。你把它跑通后,再替换成 MyBatis/JPA 查询即可。
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
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>springboot-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>
<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>
</dependencies>
</project>
2. 配置文件
src/main/resources/application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expiration: 3600000
这里的 secret 示例里直接写死是为了演示,生产环境一定要放配置中心或环境变量。
3. 启动类
package com.example.demo;
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
package com.example.demo.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
package com.example.demo.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 工具类
这个类负责:
- 生成 Token
- 解析 Token
- 校验 Token 是否过期、签名是否正确
package com.example.demo.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
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.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.List;
@Component
public class JwtUtil {
private final SecretKey secretKey;
private final long expiration;
public JwtUtil(@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration}") long expiration) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.expiration = expiration;
}
public String generateToken(UserDetails userDetails) {
List<String> roles = userDetails.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.toList();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setSubject(userDetails.getUsername())
.claim("roles", roles)
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
public String getUsername(String token) {
return parseClaims(token).getSubject();
}
public boolean isTokenValid(String token, UserDetails userDetails) {
String username = getUsername(token);
Date expirationDate = parseClaims(token).getExpiration();
return username.equals(userDetails.getUsername()) && expirationDate.after(new Date());
}
}
6. 自定义用户服务
为了演示权限控制,我们准备两个用户:
admin / 123456,角色ADMINuser / 123456,角色USER
package com.example.demo.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.List;
@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 User.builder()
.username("admin")
.password(passwordEncoder.encode("123456"))
.authorities(List.of(
new SimpleGrantedAuthority("ROLE_ADMIN"),
new SimpleGrantedAuthority("ROLE_USER")
))
.build();
}
if ("user".equals(username)) {
return User.builder()
.username("user")
.password(passwordEncoder.encode("123456"))
.authorities(List.of(
new SimpleGrantedAuthority("ROLE_USER")
))
.build();
}
throw new UsernameNotFoundException("用户不存在");
}
}
这里有个经验点:
如果你后面接数据库,不要直接把密码明文放库里,一定要走 BCryptPasswordEncoder。
7. JWT 过滤器
过滤器的职责是:
- 从请求头获取 Token
- 验证 Token
- 如果合法,就把用户信息放进 SecurityContext
package com.example.demo.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 JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;
public JwtAuthenticationFilter(JwtUtil jwtUtil,
CustomUserDetailsService userDetailsService) {
this.jwtUtil = jwtUtil;
this.userDetailsService = userDetailsService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
try {
String username = jwtUtil.getUsername(token);
if (StringUtils.hasText(username) &&
SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtUtil.isTokenValid(token, userDetails)) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
} catch (Exception e) {
// 这里不要吞掉所有异常后静默成功
// 示例中先放行,最终由后续鉴权失败返回 401
}
filterChain.doFilter(request, response);
}
}
8. Spring Security 配置
这是整套方案的核心装配点。
package com.example.demo.config;
import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.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.Customizer;
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(csrf -> csrf.disable())
.httpBasic(Customizer.withDefaults())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
}
为什么是无状态
这行配置很关键:
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
它表示服务端不再依赖 Session 保存登录状态。
否则你明明想做 JWT,结果系统背后还偷偷用了 Session,行为就会混乱。
9. 登录接口
登录时我们还是借助 Spring Security 的认证管理器来校验用户名密码,不自己手搓。
package com.example.demo.controller;
import com.example.demo.dto.LoginRequest;
import com.example.demo.dto.LoginResponse;
import com.example.demo.security.JwtUtil;
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 JwtUtil jwtUtil;
public AuthController(AuthenticationManager authenticationManager,
JwtUtil jwtUtil) {
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
}
@PostMapping("/login")
public LoginResponse login(@Valid @RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
String token = jwtUtil.generateToken(
(org.springframework.security.core.userdetails.UserDetails) authentication.getPrincipal()
);
return new LoginResponse(token);
}
}
10. 受保护接口与权限控制
这里演示三个接口:
- 所有登录用户可访问
- 只有
USER或ADMIN可访问 - 只有
ADMIN可访问
package com.example.demo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api")
public class DemoController {
@GetMapping("/me")
public Map<String, Object> me(Authentication authentication) {
return Map.of(
"username", authentication.getName(),
"authorities", authentication.getAuthorities()
);
}
@PreAuthorize("hasRole('USER')")
@GetMapping("/user/hello")
public Map<String, String> userHello() {
return Map.of("message", "Hello, user");
}
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/hello")
public Map<String, String> adminHello() {
return Map.of("message", "Hello, admin");
}
}
11. 权限模型关系图
classDiagram
class User {
+Long id
+String username
+String password
}
class Role {
+Long id
+String roleCode
+String roleName
}
class Permission {
+Long id
+String permCode
+String permName
}
User "many" --> "many" Role : 拥有
Role "many" --> "many" Permission : 包含
这个图对应真实项目里的常见设计:
- 用户和角色多对多
- 角色和权限多对多
- JWT 里通常放用户标识和角色摘要
- 细粒度权限建议在服务端动态判断,不要全部塞进 Token
逐步验证清单
项目跑起来之后,按这个顺序验证最稳。
1. 登录获取 Token
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"123456\"}"
返回示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9.xxx.xxx",
"tokenType": "Bearer"
}
2. 带 Token 访问个人信息接口
curl http://localhost:8080/api/me \
-H "Authorization: Bearer 你的token"
3. 用 user 账号测试普通权限接口
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"user\",\"password\":\"123456\"}"
再访问:
curl http://localhost:8080/api/user/hello \
-H "Authorization: Bearer 你的user token"
应该成功。
4. 用 user 账号访问管理员接口
curl http://localhost:8080/api/admin/hello \
-H "Authorization: Bearer 你的user token"
应该返回 403。
这里顺便记住:
- 401:你没登录,或 Token 无效
- 403:你登录了,但没权限
这两个状态码混淆是排查权限问题时最常见的障碍之一。
常见坑与排查
这部分我想讲得更接地气一点,因为真正让人卡住的通常不是“不会写”,而是“为什么明明写了却不生效”。
坑 1:角色名没加 ROLE_ 前缀
你写了:
new SimpleGrantedAuthority("ADMIN")
但注解里写的是:
@PreAuthorize("hasRole('ADMIN')")
实际上 hasRole('ADMIN') 底层会找 ROLE_ADMIN。
所以要么你权限里存 ROLE_ADMIN,要么改成 hasAuthority('ADMIN')。
这是我见过最多的坑,没有之一。
坑 2:每次加载用户都重新加密密码,导致登录失败
示例里为了简化写成:
.password(passwordEncoder.encode("123456"))
但如果你真实项目里从数据库查出的是已经加密过的密码,再 encode 一次就完了。
正确做法是:
- 数据库存密文
loadUserByUsername直接返回数据库里的密文- 登录时由
PasswordEncoder.matches()比较
坑 3:Token 里有角色,但服务端加载的权限变了
有些人喜欢把完整权限列表塞到 JWT 里,感觉这样就不用查数据库了。
短期看很爽,长期很容易埋坑:
- 用户角色变更后,旧 Token 里还是旧权限
- 管理员刚被降权,旧 Token 仍然能访问高权限接口
我的建议是:
- Token 中放 用户 ID、用户名、角色摘要
- 关键权限仍从服务端动态获取
- 或者配合短过期时间 + 刷新机制
坑 4:过滤器执行了,但接口还是 401
通常排查这几步:
- 请求头是不是
Authorization - 值是不是以
Bearer开头 - Token 是否被前端多带了引号
- 过滤器有没有成功把 Authentication 放进
SecurityContextHolder requestMatchers("/auth/login").permitAll()是否配置正确- 有没有多个 SecurityFilterChain 互相影响
如果你想快速确认过滤器是否生效,可以先打日志:
System.out.println("JWT Filter executed");
虽然土,但调试阶段很直接。
坑 5:明明是权限不足,却返回 401
这种情况往往是因为:
- 没有正确设置认证对象
- 或异常处理器把所有安全异常都统一返回 401
记住标准语义:
- 未认证:401
- 已认证但无权限:403
生产项目最好自定义 AuthenticationEntryPoint 和 AccessDeniedHandler,把这两个场景清晰区分开。
坑 6:JWT 密钥长度不够
如果你用 HS256,密钥太短会报错。
像本文示例里写了至少 32 字节的字符串,就是为了满足要求。
如果线上你随便写个:
jwt.secret=abc123
大概率会直接启动或运行时报异常。
安全最佳实践
JWT 很方便,但不要神化它。它只是认证载体,不是安全万能药。
1. 不要在 JWT 里放敏感信息
不要把这些内容放进 Payload:
- 明文密码
- 手机号完整信息
- 银行卡信息
- 身份证号
- 过多内部权限细节
因为 JWT 可以被解码看见。
2. Token 过期时间不要太长
如果你的访问 Token 一发就是 7 天甚至 30 天,一旦泄露,风险很大。
更稳妥的做法:
- Access Token:15 分钟 ~ 2 小时
- Refresh Token:7 天 ~ 30 天
- Access Token 短期有效
- Refresh Token 用于换新 Token
如果只是中后台管理系统,通常 1~2 小时已经够用了。
3. 使用 HTTPS
这个很多人知道,但上线时还是会“先图省事”。
如果没有 HTTPS,Token 在传输过程中就可能被窃听。
JWT 再“无状态”、签名再“安全”,也扛不住你明文传输。
4. 做登出与 Token 失效控制
JWT 最大的一个现实问题是:签发后天然难以主动失效。
可选方案有:
- 使用短过期时间
- 维护 Token 黑名单
- 在 Redis 中保存用户 Token 版本号
- 用户修改密码/管理员禁用账号时,提升版本号,让旧 Token 失效
如果你的业务对“即时下线”要求很高,纯 JWT 无状态方案通常要配合 Redis 一起做。
5. 区分认证失败与鉴权失败
建议统一返回结构,但语义要清楚:
- Token 缺失、过期、非法:401
- 用户身份合法但权限不足:403
这会极大降低前后端联调成本。
性能最佳实践
JWT 常被说成“无状态、性能好”,但性能好不等于你可以随便用。
1. Token 不要塞太多字段
JWT 不是缓存对象。
放太多内容会导致:
- 请求头变大
- 网络开销增加
- 解析成本上升
通常保留这些就够了:
- 用户 ID
- 用户名
- 角色标识
- 过期时间
- 签发时间
2. 热点权限可做缓存,但别失控
真实项目里,如果每次请求都查数据库拿权限,压力会明显上升。
可选策略:
- 用户权限放 Redis
- 设置合理 TTL
- 用户权限变更时主动删除缓存
但缓存越多,权限一致性就越复杂。
所以别一上来就“全量缓存”,先压测、再决定。
3. 过滤器里避免重逻辑
JWT 过滤器是每个请求都会走的地方。
我一般会避免在里面做这些事:
- 大量数据库查询
- 复杂对象拼装
- 额外远程调用
过滤器只做必要认证,业务逻辑下沉到服务层。
真实项目中的推荐演进路径
如果你现在是从 0 到 1 搭建,可以按这个顺序演进:
阶段 1:先跑通基础认证
- 登录接口签发 JWT
- 过滤器统一验签
- 接口基于角色做访问控制
阶段 2:接入数据库用户体系
- 用户表
- 角色表
- 权限表
- 用户角色关联
- 角色权限关联
阶段 3:做细粒度权限控制
比如:
system:user:listsystem:user:addsystem:user:delete
这时可以用:
@PreAuthorize("hasAuthority('system:user:list')")
阶段 4:补齐工程化能力
- 统一异常处理
- 登录失败审计
- Token 刷新
- 黑名单机制
- 权限缓存
- 操作日志
- 安全监控
什么时候不适合只用 JWT
边界条件也得说清楚,不然文章就容易变成“JWT 万能论”。
以下场景,纯 JWT 方案往往不够:
- 必须支持强制实时下线
- 权限变更要求立即全局生效
- 高安全场景要求设备级会话管理
- 需要非常细粒度的动态授权判断
这时更常见的做法是:
- JWT + Redis
- 或 Session/Token 混合方案
- 或统一认证中心(如 OAuth2 / OIDC)
所以你要把 JWT 看成一种工程方案,而不是宗教信仰。
总结
这篇文章我们从实战角度,把 Spring Boot + JWT 的登录鉴权和权限控制完整走了一遍,核心链路是:
- 用户登录
- 服务端签发 JWT
- 请求进入过滤器验签
- 将用户信息放入 SecurityContext
- 通过
@PreAuthorize做角色/权限控制
如果你现在要落地,我建议直接按下面这个最小可执行方案开始:
- 先用本文示例跑通登录和接口鉴权
- 把内存用户替换成数据库用户
- 把角色控制扩展成权限点控制
- 加上统一 401/403 返回
- 根据业务决定是否接入 Redis 做 Token 失效管理
最后给一个很实用的判断标准:
- 中小型前后端分离项目:Spring Boot + JWT 足够好用
- 需要即时失效、复杂会话治理的项目:JWT 需要配合 Redis 或认证中心
- 高安全后台系统:不要只想着“能跑通”,要优先考虑失效机制、审计和权限一致性
如果你把本文代码跑通,再往数据库权限模型扩一层,基本就已经具备在实际项目里独立搭建登录鉴权模块的能力了。