Java Web 开发中基于 Spring Boot + JWT 的权限认证实战:从登录鉴权到接口安全落地
在 Java Web 项目里,认证和授权几乎是绕不过去的一道坎。很多人一开始会觉得:
“登录成功后给个 token,不就完了吗?”
但真到项目里,问题会接二连三冒出来:
- token 放哪里更合适?
- 接口怎么区分“未登录”和“没权限”?
- JWT 里到底该放哪些信息?
- token 过期怎么处理?
- Spring Boot 里拦截器、过滤器、Spring Security 到底怎么选?
这篇文章我尽量不空谈概念,而是带你从一个可运行的 Spring Boot + JWT 示例出发,完成一套常见的权限认证流程:
- 用户登录,服务端签发 JWT
- 客户端请求接口时携带 JWT
- 服务端校验 token,解析用户身份
- 基于角色控制接口访问权限
- 处理过期、伪造、权限不足等常见场景
这套方案很适合中小型后台系统、管理平台、前后端分离项目。
如果你已经会写 Spring Boot 接口,但想把“接口安全”这件事真正落地,这篇会比较适合你。
一、背景与问题
传统基于 Session 的登录机制有几个现实问题:
- 前后端分离后,跨域和状态维护更麻烦
- 服务横向扩容时,Session 共享需要额外方案
- 移动端、Web、多端接入时不够灵活
JWT(JSON Web Token)的优势在于:
- 无状态:服务端不需要保存登录会话
- 天然适合前后端分离
- 易于携带用户身份信息
- 可扩展:能放角色、用户 ID、签发时间等声明
但 JWT 也不是“用了就安全”。
我见过不少项目里把 JWT 用成了“只是换了个字符串的 Session”,甚至把敏感信息直接塞进去,风险非常大。
所以这篇重点不只是“怎么生成 token”,而是:
- 怎么在 Spring Boot 里把 JWT 流程串起来
- 怎么做基础权限控制
- 怎么避开常见坑
- 怎么让方案更接近生产可用
二、前置知识与环境准备
1. 技术栈
本文示例使用:
- JDK 8+
- Spring Boot 2.7.x
- Spring Security
- jjwt 0.11.5
- Maven
2. 适用场景
适合:
- 管理后台
- 内部业务系统
- RESTful API
- 前后端分离项目
不太适合直接照搬的场景:
- 超高安全级别系统
- 需要单点登录(SSO)
- 复杂 OAuth2 授权体系
- 强一致的登录态撤销需求
三、核心原理
先把全链路看清楚,后面代码会顺很多。
1. 登录鉴权流程
flowchart TD
A[用户提交用户名密码] --> B[Spring Boot 登录接口]
B --> C{用户名密码是否正确}
C -- 否 --> D[返回 401/登录失败]
C -- 是 --> E[生成 JWT]
E --> F[客户端保存 Token]
F --> G[请求受保护接口时携带 Authorization: Bearer Token]
G --> H[JWT 过滤器校验 Token]
H --> I{Token 是否有效}
I -- 否 --> J[返回 401/Token 无效或过期]
I -- 是 --> K[写入 SecurityContext]
K --> L[进入 Controller]
L --> M{是否具备所需角色}
M -- 否 --> N[返回 403/权限不足]
M -- 是 --> O[返回业务数据]
2. JWT 的组成
JWT 由三部分组成:
- Header:声明类型和签名算法
- Payload:承载业务声明
- Signature:签名,防篡改
格式如下:
header.payload.signature
常见 payload 字段:
sub:主题,通常用用户名iat:签发时间exp:过期时间- 自定义字段:如
userId、roles
注意:JWT 不是加密,只是 Base64Url 编码后再签名。
所以不要把密码、身份证号、银行卡号这类敏感信息放进去。
3. 认证和授权的区别
这个概念很多人写代码时容易混:
- 认证(Authentication):你是谁
比如校验用户名密码、校验 JWT 是否有效 - 授权(Authorization):你能访问什么
比如只有ADMIN才能访问删除接口
4. Spring Security 在这里扮演什么角色
我们这里用 Spring Security 做两件事:
- 接管登录后的用户身份上下文
- 对接口做基于角色的访问控制
JWT 本身只负责“令牌表示身份”,
真正把“当前请求对应哪个用户、具有什么权限”接起来,还是靠 Spring Security。
四、项目结构设计
先给一个简化后的结构,后面代码按这个来:
src/main/java/com/example/jwtdemo
├── JwtDemoApplication.java
├── config
│ └── SecurityConfig.java
├── controller
│ ├── AuthController.java
│ └── UserController.java
├── dto
│ ├── LoginRequest.java
│ └── LoginResponse.java
├── filter
│ └── JwtAuthenticationFilter.java
├── service
│ └── CustomUserDetailsService.java
└── util
└── JwtTokenUtil.java
五、实战代码(可运行)
下面这套代码是一个最小可运行示例。
为了聚焦 JWT 认证流程,用户数据先用内存方式模拟,后续你可以很容易替换成数据库。
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
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>2.7.18</version>
</parent>
<properties>
<java.version>8</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>
</dependencies>
</project>
2. 启动类
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);
}
}
3. DTO
LoginRequest.java
package com.example.jwtdemo.dto;
public class LoginRequest {
private String username;
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;
}
}
4. JWT 工具类
JwtTokenUtil.java
package com.example.jwtdemo.util;
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.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;
import java.util.stream.Collectors;
@Component
public class JwtTokenUtil {
// 至少 256 bit,下面是 Base64 编码后的示例密钥
private static final String SECRET_KEY = "bXktc3VwZXItc2VjcmV0LWtleS0xMjM0NTY3ODkwMTIzNDU2Nzg5MA==";
private static final long EXPIRATION = 1000 * 60 * 60; // 1小时
private SecretKey getSigningKey() {
byte[] keyBytes = Decoders.BASE64.decode(SECRET_KEY);
return Keys.hmacShaKeyFor(keyBytes);
}
public String generateToken(UserDetails userDetails) {
List<String> roles = userDetails.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
return Jwts.builder()
.setSubject(userDetails.getUsername())
.claim("roles", roles)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}
public String extractUsername(String token) {
return extractClaims(token).getSubject();
}
public boolean isTokenExpired(String token) {
return extractClaims(token).getExpiration().before(new Date());
}
public boolean validateToken(String token, UserDetails userDetails) {
String username = extractUsername(token);
return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
}
public Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
}
5. 用户服务
这里用内存用户做演示。
真实项目里通常是查数据库,然后把角色权限映射成 GrantedAuthority。
CustomUserDetailsService.java
package com.example.jwtdemo.service;
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("ROLE_USER")
)
);
}
if ("user".equals(username)) {
return new User(
"user",
passwordEncoder.encode("123456"),
Arrays.asList(
new SimpleGrantedAuthority("ROLE_USER")
)
);
}
throw new UsernameNotFoundException("用户不存在");
}
}
这里有个小提醒:
示例里每次加载用户时都重新encode("123456"),在演示场景下没问题。
如果你接数据库,应该存已经加密好的密码,而不是运行时现算。
6. JWT 认证过滤器
这个过滤器负责:
- 从请求头取出
Authorization - 解析 Bearer Token
- 校验 token
- 把认证信息写入
SecurityContext
JwtAuthenticationFilter.java
package com.example.jwtdemo.filter;
import com.example.jwtdemo.service.CustomUserDetailsService;
import com.example.jwtdemo.util.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.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
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 (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
try {
username = jwtTokenUtil.extractUsername(token);
} catch (Exception e) {
// 这里先放行,最终由 Spring Security 统一处理未认证请求
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
var userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
7. Spring Security 配置
SecurityConfig.java
package com.example.jwtdemo.config;
import com.example.jwtdemo.filter.JwtAuthenticationFilter;
import com.example.jwtdemo.service.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.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomUserDetailsService userDetailsService;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
CustomUserDetailsService userDetailsService) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.userDetailsService = userDetailsService;
}
@Bean
public SecurityFilterChain filterChain(org.springframework.security.config.annotation.web.builders.HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.userDetailsService(userDetailsService)
.authorizeRequests()
.antMatchers("/auth/login").permitAll()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated()
.and()
.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();
}
}
8. 登录接口
这里使用 AuthenticationManager 校验用户名密码。
校验成功后签发 JWT。
AuthController.java
package com.example.jwtdemo.controller;
import com.example.jwtdemo.dto.LoginRequest;
import com.example.jwtdemo.dto.LoginResponse;
import com.example.jwtdemo.util.JwtTokenUtil;
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 JwtTokenUtil jwtTokenUtil;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenUtil jwtTokenUtil) {
this.authenticationManager = authenticationManager;
this.jwtTokenUtil = jwtTokenUtil;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody 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);
}
}
9. 受保护接口
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("/user/profile")
@PreAuthorize("hasRole('USER') or hasRole('ADMIN')")
public Map<String, Object> userProfile(Authentication authentication) {
Map<String, Object> result = new HashMap<>();
result.put("message", "用户信息访问成功");
result.put("username", authentication.getName());
result.put("authorities", authentication.getAuthorities());
return result;
}
@GetMapping("/admin/dashboard")
@PreAuthorize("hasRole('ADMIN')")
public Map<String, Object> adminDashboard(Authentication authentication) {
Map<String, Object> result = new HashMap<>();
result.put("message", "管理员面板访问成功");
result.put("username", authentication.getName());
result.put("authorities", authentication.getAuthorities());
return result;
}
}
六、调用链路时序图
看到这里,建议你再看一眼请求时序,很多细节会更清晰。
sequenceDiagram
participant C as Client
participant A as AuthController
participant S as Spring Security
participant J as JwtTokenUtil
participant F as JwtAuthenticationFilter
participant U as UserController
C->>A: POST /auth/login 用户名密码
A->>S: AuthenticationManager.authenticate()
S-->>A: 认证成功
A->>J: generateToken()
J-->>A: JWT
A-->>C: 返回 token
C->>F: GET /user/profile + Authorization: Bearer xxx
F->>J: 解析并校验 token
J-->>F: token 有效
F->>S: 写入 SecurityContext
S-->>U: 放行请求
U-->>C: 返回受保护资源
七、逐步验证清单
下面我们实际测一遍。
1. 启动应用
运行 Spring Boot 项目,默认端口 8080。
2. 登录获取 token
使用 admin 登录
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"123456\"}"
返回类似:
{
"token": "eyJhbGciOiJIUzI1NiJ9.xxx.yyy",
"tokenType": "Bearer"
}
使用 user 登录
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"user\",\"password\":\"123456\"}"
3. 访问普通用户接口
curl http://localhost:8080/user/profile \
-H "Authorization: Bearer 你的token"
期望:
admin可以访问user也可以访问
4. 访问管理员接口
curl http://localhost:8080/admin/dashboard \
-H "Authorization: Bearer 你的token"
期望:
admin可以访问user返回 403
5. 不带 token 测试
curl http://localhost:8080/user/profile
期望:
- 返回 401 或未认证响应
八、权限模型怎么落地更合理
很多项目里,权限控制不是简单的“管理员/普通用户”二选一。
更常见的是下面这个模型:
- 用户(User)
- 角色(Role)
- 权限(Permission)
关系一般是:
- 用户绑定多个角色
- 角色绑定多个权限
- 接口根据角色或权限进行校验
可以抽象成下面这样:
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 --> Role : n..n
Role --> Permission : n..n
在实际项目里,我更建议:
- JWT 中只放必要身份信息:
userId、username、roles - 细粒度权限可以从数据库或缓存读取
- 不要把完整权限树都塞进 token,token 会迅速膨胀
什么时候用角色控制,什么时候用权限控制?
- 角色控制:简单后台、页面级访问控制,足够直接
- 权限控制:按钮级、接口级、资源级控制,更灵活
例如:
ROLE_ADMINsys:user:listsys:user:delete
对于复杂系统,建议角色 + 权限结合使用。
九、常见坑与排查
这部分很重要,我自己做项目时,这些坑基本都踩过。
1. 明明带了 token,接口还是 401
常见原因
- 请求头不是
Authorization - 前缀不是
Bearer - token 已过期
- JWT 签名密钥不一致
- 过滤器没有加入 Spring Security 链
- token 解析异常被吞掉后没有日志
排查建议
先打印这几个点:
System.out.println("Authorization=" + request.getHeader("Authorization"));
System.out.println("username=" + username);
System.out.println("authentication=" + SecurityContextHolder.getContext().getAuthentication());
重点确认:
- token 是否真的被取到
- 用户名是否从 token 里成功解析
SecurityContext是否已写入认证信息
2. 登录总是失败,提示密码错误
常见原因
PasswordEncoder 没配对。
比如:
- 数据库存的是 BCrypt
- 你却用明文比对
- 或者每次写死了一个新密码串
正确做法
数据库里保存加密后的密码,例如:
String encoded = passwordEncoder.encode("123456");
System.out.println(encoded);
存进去后,登录时由 Spring Security 自动调用:
passwordEncoder.matches(rawPassword, encodedPassword)
真实项目里不要把明文密码写在代码里,这里只是示意。
3. 返回 403,不是 401
很多人看到 403 会以为“没登录”,其实不一定。
401 Unauthorized:通常表示未认证403 Forbidden:通常表示已认证,但没权限
比如 user 带着合法 token 去访问 /admin/dashboard,这就该是 403。
4. token 里角色有,接口还是没权限
这是 Spring Security 里一个非常常见的细节:
hasRole("ADMIN")实际匹配的是ROLE_ADMIN- 所以 authority 一般要写成
ROLE_ADMIN
也就是说:
new SimpleGrantedAuthority("ROLE_ADMIN")
而不是:
new SimpleGrantedAuthority("ADMIN")
除非你统一改成 hasAuthority("ADMIN") 这套写法。
5. token 过期后体验很差
用户请求一个接口,突然 401 了,前端如果没处理,会非常割裂。
常见改造思路
- access token 短时有效,比如 30 分钟
- refresh token 长时有效,比如 7 天
- access token 过期后,用 refresh token 换新 token
这篇先不展开完整 refresh token 实现,但如果你的系统面向正式用户,建议尽快补上。
6. 想做“退出登录”,JWT 却是无状态的,怎么办?
这是 JWT 的典型争议点之一。
无状态意味着服务端不存会话,
那你就没法像 Session 一样“删掉服务器里的登录状态”。
常见方案
- 前端删除本地 token
- 服务端维护 token 黑名单(Redis)
- 缩短 access token 有效期
- 配合 refresh token 做可控续签
如果你的系统对“强制下线”“立刻失效”要求很高,
那就别迷信纯 JWT 无状态方案,适当引入 Redis 是很常见的做法。
十、安全/性能最佳实践
这一节尽量说能直接落地的建议。
1. 不要在 JWT 中放敏感信息
不要放:
- 明文密码
- 手机号、身份证号
- 银行卡等隐私信息
- 过多业务数据
建议只放:
- 用户唯一标识
- 用户名
- 角色标识
- 签发时间、过期时间
2. 一定使用 HTTPS
JWT 如果在 HTTP 明文传输中被截获,对方就能直接拿去冒用。
所以:
- 生产环境必须 HTTPS
- 不要把 token 暴露在 URL 参数里
- 优先放在请求头中
3. 设置合理过期时间
不要贪图省事把 token 设成 30 天甚至永久有效。
推荐思路:
- access token:15 分钟 ~ 2 小时
- refresh token:7 天 ~ 30 天
边界条件要看业务:
- 内部系统可稍长
- 高敏感系统应更短
4. 密钥管理不要硬编码
示例里为了演示写在代码中。
生产环境建议:
- 放到配置中心或环境变量
- 定期轮换
- 区分不同环境的密钥
- 不同服务不要共用同一套签名密钥
例如:
application.yml
jwt:
secret: ${JWT_SECRET}
expiration: 3600000
5. 统一异常返回
默认情况下,Spring Security 的未认证、无权限响应可能不够友好。
实际项目建议自定义:
AuthenticationEntryPoint:处理 401AccessDeniedHandler:处理 403
这样前端更容易统一接入。
一个简化示例:
package com.example.jwtdemo.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
@Component
public class SecurityExceptionHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
org.springframework.security.core.AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> result = new HashMap<>();
result.put("code", 401);
result.put("message", "未认证或Token无效");
response.getWriter().write(objectMapper.writeValueAsString(result));
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
Map<String, Object> result = new HashMap<>();
result.put("code", 403);
result.put("message", "权限不足");
response.getWriter().write(objectMapper.writeValueAsString(result));
}
}
然后在 SecurityConfig 中接入。
6. 认证信息尽量走缓存或精简查询
每次请求都查数据库,会给认证链路带来不必要压力。
常见优化方式:
- token 中放基础角色信息,减少重复查库
- 用户权限变更不频繁时,配合 Redis 缓存权限
- 对高频接口减少重复解析与复杂装配
不过也别走极端:
如果你把太多信息放进 token,虽然少查库了,但 token 会变大、更新也更麻烦。
平衡点通常是:
- token 存基础身份
- 复杂权限走缓存
7. 对关键接口增加二次校验
像下面这些高风险操作:
- 删除数据
- 导出敏感数据
- 修改核心配置
- 给用户赋权
建议除了 JWT 登录态,还做额外保护:
- 操作审计日志
- 二次密码确认
- 短信/邮箱验证码
- 防重放校验
- 限流
JWT 解决的是“你是谁”,不是“你做这件事一定安全”。
十一、一个更贴近生产的改造方向
如果你准备把示例往生产上靠,我建议按这个顺序演进:
第一步:把内存用户替换成数据库
- 用户表
- 角色表
- 用户角色关联表
第二步:加入统一异常处理
- 401 / 403 JSON 统一返回
- token 异常日志更清晰
第三步:加入 refresh token
- access token 短期有效
- refresh token 负责续签
第四步:支持登出与黑名单
- Redis 存已失效 token
- 关键用户支持强制下线
第五步:做细粒度权限控制
@PreAuthorize("hasAuthority('sys:user:list')")- 后台菜单、按钮、接口一体化控制
十二、边界条件:什么时候不建议只用 JWT
虽然 JWT 很流行,但也不是所有系统都该默认用它。
以下情况建议你慎重:
1. 你需要强实时撤销登录态
比如:
- 管理员强制踢人下线
- 修改密码后所有设备立刻失效
这时单靠纯无状态 JWT 不够,通常要配合 Redis 黑名单或会话中心。
2. 你要做复杂的第三方授权
比如:
- 微信登录
- GitHub 登录
- 企业统一身份平台
- 多系统单点登录
这类需求往往更适合 OAuth2 / OIDC 体系。
3. 你的系统权限模型极复杂
如果权限变化频繁、细粒度到资源实例级别,
JWT 里固化权限未必是最佳方案,可能更适合“短 token + 服务端动态鉴权”。
十三、总结
我们这篇做了这样一套完整链路:
- 用 Spring Boot 提供登录接口
- 用 Spring Security 负责认证上下文与权限控制
- 用 JWT 表示用户登录身份
- 通过过滤器解析 token 并写入
SecurityContext - 对用户接口、管理员接口做角色保护
- 说明了常见坑、排查方法和生产实践建议
如果你现在正准备在项目里落地,我给你的可执行建议是:
- 先把最小链路跑通:登录、带 token 访问、角色控制
- 再补统一异常处理:明确 401 和 403
- 生产上尽快补 refresh token 和密钥管理
- 高安全场景别迷信纯 JWT 无状态,该上 Redis 黑名单就上
- JWT 只放必要信息,别把它当“小型数据库”
最后说句经验话:
认证鉴权这件事,代码量未必最大,但一旦设计随意,后面补救成本非常高。与其上线后被 401、403、权限串用折腾,不如一开始就把认证链路理清楚。
如果你是第一次完整接 Spring Boot + JWT,我建议你把文中的代码亲手敲一遍,再自己加两个角色、加一个权限接口,你会理解得更扎实。