背景与问题
前后端分离之后,登录鉴权这件事看起来简单,真正落地时却很容易“半通不通”:
- 前端拿什么保存登录态?
- 后端还要不要用 Session?
- JWT 放哪儿最合适?
- Spring Security 默认那套表单登录,怎么改造成前后端接口风格?
- 角色、权限、接口访问控制到底怎么设计才不至于后期失控?
我见过不少项目,一开始只是想“做个登录”,最后却演变成:
- 登录接口能通,但所有接口都 401
- Token 明明没过期,后端却解析失败
- 角色权限写死在代码里,需求一变就得全项目搜索替换
- 刷新 Token、退出登录、黑名单一个都没想清楚
这篇文章就从架构视角 + 可运行代码来讲清楚:如何在 Spring Boot 中,基于 JWT + Spring Security 构建一套适合前后端分离的登录鉴权方案,并且把权限设计做得更可维护。
为什么很多团队会选 JWT + Spring Security
在前后端分离场景里,服务端常见有两种登录态思路:
- Session/Cookie
- JWT Token
先说结论:
如果你的系统是典型的前后端分离、接口服务化、可能还有网关或多端接入,JWT + Spring Security 往往更灵活。
方案对比与取舍
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Session + Cookie | 简单、成熟、服务端可控 | 分布式共享 Session 成本高;跨域麻烦 | 传统单体后台 |
| JWT | 无状态、适合分布式、跨服务传递方便 | 失效控制更复杂;泄露风险更高 | 前后端分离、多端接入 |
| OAuth2/OIDC | 标准化、适合开放平台 | 学习和接入成本高 | 大型统一认证平台 |
这篇文章聚焦的是第二种:JWT 负责携带身份信息,Spring Security 负责接管认证与授权流程。
核心原理
先别急着写代码,先把职责边界理清。很多坑,都是因为没搞清“谁负责什么”。
1. JWT 负责“携带身份声明”
JWT(JSON Web Token)本质上是一个字符串,通常分为三段:
- Header:签名算法等信息
- Payload:用户 ID、用户名、角色、过期时间等声明
- Signature:签名,防止内容被篡改
它的特点是:
- 服务端签发
- 客户端保存
- 每次请求都带上
- 服务端校验签名与有效期后,恢复出用户身份
2. Spring Security 负责“认证与授权”
Spring Security 不是只会“表单登录”。它更像一套安全框架:
- 认证 Authentication:你是谁?
- 授权 Authorization:你能访问什么?
在 JWT 方案中,我们通常这样改造:
/api/auth/login:用户名密码登录,校验成功后签发 JWT- 其他接口:前端在请求头带上
Authorization: Bearer xxx - 自定义过滤器解析 JWT
- 解析成功后把用户信息放进
SecurityContext - Spring Security 再根据权限规则决定是否放行
3. 一条请求的完整流转
flowchart LR
A[前端登录请求<br>/api/auth/login] --> B[认证接口校验用户名密码]
B --> C[生成 JWT 返回前端]
C --> D[前端保存 Token]
D --> E[请求受保护接口<br>Authorization: Bearer token]
E --> F[JWT 过滤器解析 Token]
F --> G[写入 SecurityContext]
G --> H[Spring Security 鉴权]
H --> I[业务接口执行]
4. 认证与授权的边界
这一点特别重要,很多文章会混着讲。
- 认证:登录时用户名密码是否正确;JWT 是否合法、是否过期
- 授权:当前用户是否拥有
ADMIN角色或user:read权限
我的建议是:
- JWT 里尽量放必要且稳定的信息,比如
userId、username - 角色权限是否放进 JWT,要看系统复杂度
- 小系统:可以放角色,减少查库
- 中大型系统:建议只放用户标识,权限走缓存/数据库加载,便于动态变更
权限设计:别一上来就只用角色
很多项目刚开始就两个角色:ADMIN、USER。
三个月后需求变成:
- 某些管理员只能看报表,不能删用户
- 某些运营可以审核,但不能导出
- 同一个角色在不同租户下权限不同
这时候,纯角色模型就不够了。
推荐的权限模型:RBAC 的简化落地
我更推荐的做法是:
- 用户(User)
- 角色(Role)
- 权限(Permission)
关系如下:
- 用户绑定多个角色
- 角色绑定多个权限
- 接口控制尽量落到“权限”层,而不是直接写死角色
例如:
- 角色:
ROLE_ADMIN、ROLE_AUDITOR - 权限:
user:add、user:delete、report:view
这样后续扩展时,只需要改角色和权限关系,不需要到处改代码。
classDiagram
class User {
+Long id
+String username
+String password
}
class Role {
+Long id
+String code
+String name
}
class Permission {
+Long id
+String code
+String name
}
User --> Role : many-to-many
Role --> Permission : many-to-many
设计建议
角色适合做“职责归类”
比如:
- 系统管理员
- 审核员
- 普通用户
权限适合做“接口或操作控制”
比如:
sys:user:listsys:user:createsys:user:delete
代码层面建议
- 接口注解优先使用权限,如
hasAuthority('sys:user:list') - 少用过于粗粒度的
hasRole('ADMIN') - 保留少量超级管理员兜底逻辑,但别把它当常态
实战代码(可运行)
下面给出一个可运行的最小示例。为了把重点放在 JWT 与 Security 流程上,我这里先用内存用户演示。如果你接数据库,只需要把 UserDetailsService 替换掉即可。
项目依赖
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-security-demo</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</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.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>
配置文件
application.yml
server:
port: 8080
jwt:
secret: 12345678901234567890123456789012
expire: 3600000
这里的
secret至少要足够长,别图省事写个abc。我当时就踩过这个坑,JJWT 直接给我报密钥强度不够。
启动类
JwtSecurityDemoApplication.java
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class JwtSecurityDemoApplication {
public static void main(String[] args) {
SpringApplication.run(JwtSecurityDemoApplication.class, args);
}
}
JWT 工具类
JwtTokenUtil.java
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.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
@Component
public class JwtTokenUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expire}")
private long expire;
private SecretKey getSecretKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String username, Map<String, Object> claims) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expire);
return Jwts.builder()
.setSubject(username)
.addClaims(claims)
.setIssuedAt(now)
.setExpiration(expireDate)
.signWith(getSecretKey(), SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSecretKey())
.build()
.parseClaimsJws(token)
.getBody();
}
public String getUsernameFromToken(String token) {
return parseToken(token).getSubject();
}
public boolean isTokenExpired(String token) {
Date expiration = parseToken(token).getExpiration();
return expiration.before(new Date());
}
public boolean validateToken(String token, String username) {
String tokenUsername = getUsernameFromToken(token);
return tokenUsername.equals(username) && !isTokenExpired(token);
}
}
自定义用户服务
CustomUserDetailsService.java
package com.example.demo.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 new User(
"admin",
"$2a$10$7EqJtq98hPqEX7fNZaFWoOHi6M9G5J0xYwcmBHQYaWV7k/akdUSH2", // 123456
List.of(
new SimpleGrantedAuthority("ROLE_ADMIN"),
new SimpleGrantedAuthority("sys:user:list"),
new SimpleGrantedAuthority("sys:user:add")
)
);
}
if ("user".equals(username)) {
return new User(
"user",
"$2a$10$7EqJtq98hPqEX7fNZaFWoOHi6M9G5J0xYwcmBHQYaWV7k/akdUSH2", // 123456
List.of(
new SimpleGrantedAuthority("ROLE_USER"),
new SimpleGrantedAuthority("sys:user:list")
)
);
}
throw new UsernameNotFoundException("用户不存在");
}
}
JWT 过滤器
JwtAuthenticationFilter.java
package com.example.demo.security;
import io.jsonwebtoken.Claims;
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 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 {
Claims claims = jwtTokenUtil.parseToken(token);
username = claims.getSubject();
} catch (Exception e) {
// 这里不直接抛出,让后续认证入口统一返回 401
}
}
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (jwtTokenUtil.validateToken(token, userDetails.getUsername())) {
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
认证接口 DTO
LoginRequest.java
package com.example.demo.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;
}
}
Security 配置
SecurityConfig.java
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.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.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())
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"code\":401,\"message\":\"未认证或Token无效\"}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("{\"code\":403,\"message\":\"无权限访问\"}");
})
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
上面用到了
HttpServletResponse,别忘了导包:
import jakarta.servlet.http.HttpServletResponse;
登录接口
AuthController.java
package com.example.demo.controller;
import com.example.demo.dto.LoginRequest;
import com.example.demo.security.JwtTokenUtil;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/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 Map<String, Object> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
);
List<String> authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
Map<String, Object> claims = new HashMap<>();
claims.put("authorities", authorities);
String token = jwtTokenUtil.generateToken(request.getUsername(), claims);
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("tokenType", "Bearer");
result.put("username", request.getUsername());
result.put("authorities", authorities);
return result;
}
}
业务接口
UserController.java
package com.example.demo.controller;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/me")
public Map<String, Object> me() {
return Map.of("message", "你已通过认证");
}
@GetMapping
@PreAuthorize("hasAuthority('sys:user:list')")
public Map<String, Object> list() {
return Map.of("message", "用户列表查询成功");
}
@PostMapping
@PreAuthorize("hasAuthority('sys:user:add')")
public Map<String, Object> add() {
return Map.of("message", "新增用户成功");
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
public Map<String, Object> delete(@PathVariable Long id) {
return Map.of("message", "删除用户成功", "id", id);
}
}
请求验证流程演示
sequenceDiagram
participant FE as 前端
participant API as Spring Boot API
participant Filter as JWT Filter
participant Sec as Spring Security
participant Biz as Controller
FE->>API: POST /api/auth/login 用户名密码
API->>Sec: AuthenticationManager.authenticate
Sec-->>API: 认证成功
API-->>FE: 返回 JWT
FE->>API: GET /api/users Authorization: Bearer token
API->>Filter: 进入 JWT 过滤器
Filter->>Filter: 解析并校验 Token
Filter->>Sec: 写入 Authentication 到 SecurityContext
Sec->>Biz: 检查权限 sys:user:list
Biz-->>FE: 返回业务结果
如何运行与测试
启动项目后,可以直接用 curl 测试。
1. 登录
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"123456"}'
返回示例:
{
"token": "eyJhbGciOiJIUzI1NiJ9....",
"tokenType": "Bearer",
"username": "admin",
"authorities": [
"ROLE_ADMIN",
"sys:user:list",
"sys:user:add"
]
}
2. 访问受保护接口
curl http://localhost:8080/api/users/me \
-H "Authorization: Bearer 你的token"
3. 访问需要列表权限的接口
curl http://localhost:8080/api/users \
-H "Authorization: Bearer 你的token"
4. 访问新增接口
curl -X POST http://localhost:8080/api/users \
-H "Authorization: Bearer 你的token"
如果你登录的是 user / 123456,查询接口能过,但新增接口会返回 403。
常见坑与排查
这一节我尽量讲得“接地气”一点,因为这些问题在实战里真的出现频率很高。
1. 明明带了 Token,还是 401
常见原因
- 请求头没带
Bearer前缀 - 前端把 token 放错位置,比如放成了自定义头
- Token 已过期
- JWT 密钥不一致
- 过滤器没有加入 Spring Security 过滤链
排查顺序
- 浏览器开发者工具看请求头
- 后端日志打印
Authorization - 单独验证
jwtTokenUtil.parseToken(token)是否正常 - 确认
addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)是否生效
2. 登录成功,但接口全是 403
这通常不是认证问题,而是授权问题。
重点检查
@PreAuthorize("hasRole('ADMIN')")时,权限是否真的有ROLE_ADMINhasAuthority('sys:user:add')时,字符串是否完全一致- Spring Security 里角色默认前缀就是
ROLE_
很多人会把:
new SimpleGrantedAuthority("ADMIN")
和:
@PreAuthorize("hasRole('ADMIN')")
配在一起,然后纳闷为什么不行。
因为 hasRole('ADMIN') 底层会匹配 ROLE_ADMIN。
3. 密码明明对,登录却失败
常见原因
- 数据库存的是 BCrypt 密码,但你用了明文比较
PasswordEncoder没注册- 复制的密文不完整
建议统一使用:
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
并且数据库里只存加密后的密码。
4. 跨域导致前端请求失败
如果前后端分离部署,跨域经常会被误认为鉴权问题。
症状
- 浏览器控制台报 CORS 错误
- Postman 正常,浏览器不正常
解决思路
明确配置 CORS,而不是只在某个 Controller 上临时加注解。
例如:
package com.example.demo.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.setAllowedOriginPatterns(List.of("*"));
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(false);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
5. Token 中带了权限,但权限改了以后不生效
这是 JWT 常见的“无状态副作用”。
如果你把角色权限直接塞进 JWT,那么:
- 用户登录后拿到旧权限
- 管理员后台改了权限
- 旧 Token 在过期前仍可能继续生效
解决思路
- 短 Token 生命周期
- 配合刷新 Token
- 关键权限实时查库或走缓存
- 极端情况下引入黑名单机制
安全/性能最佳实践
这部分是架构落地时真正拉开差距的地方。代码能跑不难,能稳、能控、能维护才难。
1. 不要把敏感信息放进 JWT
JWT 的 Payload 只是 Base64 编码,不是加密。
所以不要放:
- 明文密码
- 手机号、身份证号等高敏感信息
- 过多业务字段
建议只放:
userIdusernamejti- 少量权限标识(视场景决定)
2. Token 过期时间别贪长
很多系统为了“用户省心”,把 Token 有效期设成 7 天、30 天。
安全上这非常危险,一旦泄露,攻击窗口太大。
推荐经验值:
- 访问 Token:30 分钟 ~ 2 小时
- 刷新 Token:7 天 ~ 14 天
如果系统安全等级更高,就再缩短。
3. 引入 Refresh Token,而不是无限续命
比较合理的双 Token 模型如下:
stateDiagram-v2
[*] --> 未登录
未登录 --> 已登录: 用户名密码校验成功
已登录 --> 访问中: Access Token 有效
访问中 --> Access过期: 到达过期时间
Access过期 --> 访问中: 使用 Refresh Token 刷新
Access过期 --> 未登录: Refresh Token 失效或撤销
建议
- Access Token 短效
- Refresh Token 存数据库或 Redis
- 支持主动注销和失效控制
- 刷新时轮换 Refresh Token,减少重放风险
4. 退出登录不能只让前端删 Token
前端本地删掉 Token,只是“看起来退出了”。
如果 Token 还有效,被截获后仍可能使用。
更稳妥的做法:
- 给 JWT 加
jti - 退出登录时把
jti加入 Redis 黑名单 - 黑名单有效期与 Token 剩余寿命一致
5. 权限加载要考虑性能
如果每次请求都查数据库加载权限,高并发下开销不小。
常见优化策略
- 用户权限缓存到 Redis
- 用户登录时把权限快照缓存
- 权限变更时主动清理缓存
取舍建议
- 小系统:JWT 带权限即可
- 中型系统:JWT 带用户标识,权限走缓存
- 大型系统:认证中心统一签发,资源服务独立校验
6. 接口鉴权粒度要稳定
不要今天这个接口写 hasRole('ADMIN'),明天那个接口写 hasAuthority('xxx'),后天又在代码里手动 if 判断。这样项目越做越乱。
建议统一规范:
- 页面菜单控制:前端根据权限码渲染
- 接口控制:后端以
@PreAuthorize("hasAuthority('xxx')")为主 - 数据权限:在 Service/SQL 层补充,不要误以为接口权限能替代数据权限
7. HTTPS 是底线
JWT 再“无状态”,只要走明文传输,就等于裸奔。
线上环境必须启用 HTTPS,否则中间人截获 Authorization 请求头后,就可以直接重放请求。
一个更贴近生产的落地建议
如果你现在要做一个中型后台系统,我会建议你这样分层:
认证层
- 用户名密码登录
- Access Token + Refresh Token
- 退出登录黑名单机制
鉴权层
- Spring Security 统一接管
- JWT Filter 恢复用户身份
@PreAuthorize做接口权限控制
权限数据层
- 用户、角色、权限三表模型
- 权限码统一命名规则,如
module:resource:action - 权限缓存 + 变更后失效
审计层
- 记录登录成功/失败
- 记录关键操作日志
- 对异常 IP、频繁失败登录做限流或告警
这样设计的好处是:
不是只解决“能不能登录”,而是给后面的权限扩展、审计追踪、性能优化留了空间。
边界条件与适用范围
这套方案不是银弹,也有它的边界。
更适合的场景
- 前后端分离后台系统
- App / H5 / 多端接入
- 微服务或分布式接口服务
不一定最优的场景
- 纯传统 SSR Web 项目
- 非常简单的内部工具系统
- 已经有统一认证中心,应该接 OAuth2/OIDC 而不是重复造轮子
如果你的系统后续会接第三方登录、单点登录、开放授权,那么从一开始就要评估是否直接上 Spring Authorization Server / OAuth2,别在 JWT 自建方案上越堆越重。
总结
在 Spring Boot 中做前后端分离登录鉴权,JWT + Spring Security 是一套非常常见、也足够实用的组合:
- JWT:解决无状态身份传递
- Spring Security:解决认证流程接管和权限控制
- RBAC 权限模型:解决角色与权限的可扩展设计
如果你准备在项目里真正落地,我建议按这个顺序推进:
- 先打通登录、签发 Token、过滤器解析 Token
- 再接入 Spring Security 的接口鉴权
- 然后把权限模型从“纯角色”升级为“角色 + 权限”
- 最后补上刷新 Token、黑名单、缓存、审计日志这些生产级能力
一句话概括:
先把认证链路跑通,再把授权模型做稳,最后补安全与性能细节。
这样做,系统不会一开始就过度设计,但也不会在需求增长后迅速失控。