面试官让我讲权限设计,我把 mall-Pro 的 JWT + Spring Security 双认证讲了一遍,他追问我是不是专门研究过安全框架
一套同时支持管理员和普通用户的认证授权架构,含 SSRF 绕过、Token 自动刷新、ThreadLocal 内存泄漏等真实踩坑记录
一、背景:电商系统的权限到底有多复杂?
先交代一下项目背景。我做的是一个叫 mall-Pro 的电商项目,基于 SpringCloud 微服务架构。
听上去权限好像不复杂对吧?无非就是登录 → 校验 → 放行。但实际做起来远没这么简单——电商系统的用户有两类:管理员和普通消费者。
管理员要管订单、管商品、管营销、管用户;普通消费者只查商品、下单、看物流。
这两套权限体系在一套系统里共存,管理员的接口和用户的接口还要走不同的校验逻辑——管理员看后台页面,用户看手机 APP。如果设计不好,就会互相干扰。
而且还有几个很头疼的问题:
- Token 怎么刷新? 用户用着用着 Token 过期了,总不能让人家重新登录吧
- 权限改了怎么办? 管理员给用户改了角色,但用户 Token 还没过期,Token 里的旧权限还在用
- 多实例部署怎么办? Session 存服务器的话,负载均衡到另一台机器就丢了
这些问题面试官基本都会问到。这篇就把我们项目的完整方案拆开来讲。
二、第一版踩坑:直接用 Spring Security 的 User,后面炸了
先说说一开始踩的坑。
第一版做登录认证的时候,我图省事,直接返回了 Spring Security 自带的 User 对象:
// ❌ 第一版踩坑代码
@Override
public UserDetails loadUserByUsername(String username) {
UmsAdmin admin = umsAdminMapper.selectByUsername(username);
List<SimpleGrantedAuthority> authorities = ...;
// 直接返回 Spring 的 User
return new User(admin.getUsername(), admin.getPassword(), authorities);
}
看着没毛病?问题大了。
后面做其他功能的时候,需要从当前登录用户拿 userId 去查数据。Spring 的 User 类只有 username、password、authorities 三个字段,没有 id 字段!
那怎么办?有人可能会说:从 username 反查数据库不就行了?
每条请求都查一次数据库?那还叫什么无状态认证。而且 username 可能重复修改,但 id 永远不会变。
教训:Spring Security 的 User 类只提供了它自己需要的字段,业务上需要的字段必须在外面包一层。
三、最终方案:自定义 LoginUser + 双认证器 + JWT 过滤器
踩坑之后重新设计了整个权限体系,最终结构是这样的:
客户端请求
↓
Gateway 网关(路由转发)
↓
JwtUserTokenFilter(继承 OncePerRequestFilter)
├── 白名单路径 → 直接放行(登录、注册、商品搜索等)
└── 非白名单 → 校验 Token
├── Token 无效 → 交给 Spring Security 处理(返回 401)
└── Token 有效 → 解析用户 → 设 SecurityContext → 放行
↓
SecurityConfig 根据路径路由到不同的 AuthenticationProvider
├── /admin/** → adminProvider(管理员认证器)
└── /user/** → userProvider(用户认证器)
第一步:自定义 LoginUser,甩开 Spring 自带的 User
/**
* 自定义登录用户,扩展了 Spring Security 的 User 类。
* 为什么要自己写?因为 Spring 的 User 没有 id 字段,
* 后续从 Token 里取不出 userId,就很尴尬。
*/
public class LoginUser extends User {
private UmsAdmin admin; // 持有完整的管理员对象
public LoginUser(UmsAdmin admin, Collection<? extends GrantedAuthority> authorities) {
super(admin.getUsername(), admin.getPassword(), authorities);
this.admin = admin;
}
public UmsAdmin getAdmin() {
return admin;
}
public Long getId() {
return admin.getId();
}
}
设计思路: 继承 + 扩展。Spring Security 不认识 UmsAdmin,但它认识 UserDetails。所以我把 UmsAdmin 包在 LoginUser 里,这个类既满足了 Spring Security 的要求(继承了 User),又保留了我们业务需要的字段。
第二步:AdminUserDetailsService —— 从数据库查用户和权限
@Service
public class AdminUserDetailsService implements UserDetailsService {
@Resource
private UmsAdminMapper umsAdminMapper;
@Override
public UserDetails loadUserByUsername(String username) {
// 1. 根据用户名查数据库
UmsAdmin admin = umsAdminMapper.selectByUsername(username);
if (admin == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 2. 查该用户的权限列表(RBAC 模型)
List<String> listPerms = umsAdminMapper.selectPermsByUserId(admin.getId());
// 比如返回:["user:manage", "order:view", "product:edit"]
// 3. 把权限字符串转成 SpringSecurity 能识别的对象
List<SimpleGrantedAuthority> authorities = listPerms.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 4. 返回自定义的 LoginUser
return new LoginUser(admin, authorities);
}
}
这里有个细节:权限存在数据库里,格式就是字符串,比如 "order:view"、"user:manage"。Spring Security 不认识这几个字,但它认识 SimpleGrantedAuthority。所以用 stream().map().collect() 把字符串列表转成框架需要的权限对象。
第三步:JWT 工具类 —— 用 HS512 签名,密钥自动生成
@Component
public class JwtTokenUtil {
@Value("${jwt.expiration}")
private Long expiration; // Token 过期时间(毫秒)
/**
* 密钥生成,这里踩过坑。
* 旧写法:private static final String SECRET = "mySecret";
* 新版本 JJWT 要求 HS512 密钥至少 512 位(64 字节),随便写个字符串长度不够会抛异常。
* Keys.secretKeyFor() 自动生成符合长度要求的密钥。
*/
private static final SecretKey SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS512);
/**
* 创建 Token:登录成功后调用
*/
private String createToken(Map<String, Object> claims, String subject) {
Date now = new Date();
Date expireDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.setClaims(claims) // 自定义数据(可塞 userId)
.setSubject(subject) // 主题:用户名
.setIssuedAt(now) // 签发时间
.setExpiration(expireDate) // 过期时间
.signWith(SECRET_KEY) // 用密钥签名(防止篡改)
.compact(); // 压缩成字符串
}
/**
* 从 Token 解析用户名
*/
public String getUsernameFromToken(String token) {
return Jwts.parser()
.setSigningKey(SECRET_KEY)
.parseClaimsJws(token)
.getBody()
.getSubject();
}
/**
* 验证 Token 是否有效(不抛异常就是有效的)
*/
public boolean validateToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false; // 过期了或被篡改了
}
}
/**
* 刷新 Token:只要旧 Token 有效就生成一个新 Token
*/
public String refreshToken(String token) {
if (!validateToken(token)) return null;
String username = getUsernameFromToken(token);
if (username == null) return null;
Map<String, Object> claims = new HashMap<>();
return createToken(claims, username);
}
}
这里有个大坑: 旧版用 String secret = "abc123" 写在代码里当密钥,新版 JJWT 升级之后这样写直接报错——Keys.secretKeyFor() 要求的密钥长度是固定的,太短会抛异常。
而且随机生成密钥有个问题:每次重启项目,之前签发的 Token 全部失效。 生产环境应该把密钥配在配置文件里固定下来,这个是后续要优化的。
第四步:JwtUserTokenFilter —— 核心过滤器
@Component
public class JwtUserTokenFilter extends OncePerRequestFilter {
@Resource
private JwtTokenUtil jwtTokenUtil;
@Resource
private AdminUserDetailsService adminUserDetailsService;
@Resource
private UserDetailService userDetailService;
/** 白名单路径:这些接口不需要 Token */
private static final List<String> WHITE_LIST = Arrays.asList(
"/auth/**", "/user/auth/**", "/api/user/auth/**",
"/actuator/health", "/swagger-ui/**", "/v3/api-docs/**",
"/user/cart/**", "/user/category/**", "/user/product/**",
"/order/**", "/delivery/**", "/skill/**", "/ai/**"
);
/** 白名单路径直接跳过过滤器 */
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String uri = request.getRequestURI();
return WHITE_LIST.stream().anyMatch(p -> pathMatcher.match(p, uri));
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 第一步:从请求头取 Token
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return; // 没有 Token 就放行,让 Spring Security 处理未认证请求
}
// 第二步:验证 Token
String token = authHeader.substring(7);
if (!jwtTokenUtil.validateToken(token)) {
filterChain.doFilter(request, response);
return;
}
// 第三步:解析用户名,查数据库取最新权限
String username = jwtTokenUtil.getUsernameFromToken(token);
UserDetails userDetails;
try {
String uri = request.getRequestURI();
// ☆ 关键:根据 URL 路径区分是管理员还是普通用户
if (uri.startsWith("/user") || uri.startsWith("/api/user")) {
userDetails = userDetailService.loadUserByUsername(username);
} else {
userDetails = adminUserDetailsService.loadUserByUsername(username);
}
} catch (Exception e) {
filterChain.doFilter(request, response);
return;
}
// 第四步:组装认证信息,告诉 Spring Security "这个用户已认证"
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
// 第五步:Token 自动刷新(用户在持续操作时不需要重新登录)
String newToken = jwtTokenUtil.refreshToken(token);
if (newToken != null) {
response.setHeader("Authorization", "Bearer " + newToken);
}
filterChain.doFilter(request, response);
}
}
这个过滤器有几个关键设计:
1. 为什么继承 OncePerRequestFilter 而不是 Filter?
一次 HTTP 请求可能经过 Filter 链多次(比如 forward)。普通 Filter 会重复执行,Token 被重复解析、重复验证。OncePerRequestFilter 保证一次请求只执行一次。
2. 为什么白名单路径要放行而不是直接拒绝?
因为有些接口是公开的——登录、注册、商品搜索。用户都还没登录呢,你拦着人家不让访问登录页,这不搞笑吗。
所以过滤器的逻辑是:白名单直接放行,非白名单校验 Token。没 Token 或 Token 无效也不直接拒绝,而是放行给 Spring Security 的 SecurityConfig 去处理。 SecurityConfig 配了 anyRequest().authenticated(),没认证的请求会被 RestAuthenticationEntryPoint 拦截返回 401。
这种设计的好处是:认证逻辑统一在 SecurityConfig 管理,过滤器只负责"解析 Token 并设到 SecurityContext",各司其职。
3. Token 自动刷新(续期)
很多项目 Token 过期了就踢用户下线,体验很不好。我们加了一个机制:只要 Token 还是有效的,就在每次请求的响应头里返回一个新 Token。 用户只要持续操作,Token 就会一直被续期,不会突然掉线。
第五步:SecurityConfig —— 双认证器配置
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {
@Resource
private AdminUserDetailsService adminUserDetailsService;
@Resource
private UserDetailService userDetailService;
@Resource
private JwtUserTokenFilter jwtUserTokenFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. 关闭 CSRF(JWT 不用 Session,天然防 CSRF)
.csrf(csrf -> csrf.disable())
// 2. 无状态 Session
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// 3. 放行白名单,其他全部要认证
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**", "/user/auth/**",
"/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated())
// 4. JWT 过滤器加在用户名密码过滤器之前
.addFilterBefore(jwtUserTokenFilter,
UsernamePasswordAuthenticationFilter.class)
// 5. 异常处理器
.exceptionHandling(ex -> ex
.authenticationEntryPoint(restAuthenticationEntryPoint) // 401
.accessDeniedHandler(restfulAccessDeniedHandler)) // 403
.cors(cors -> {});
return http.build();
}
/**
* ☆ 关键:双认证器!
* 管理员和用户的密码加密方式一样,但查的表不一样。
* 两个 Provider 各自绑定不同的 UserDetailsService。
*/
@Bean
public DaoAuthenticationProvider adminProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(adminUserDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public DaoAuthenticationProvider userProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager() {
return new ProviderManager(Arrays.asList(adminProvider(), userProvider()));
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
为什么 JWT 过滤器要加在 UsernamePasswordAuthenticationFilter 之前?
Spring Security 默认的登录流程是在 UsernamePasswordAuthenticationFilter 里校验账号密码的。但 JWT 的机制是:不用每次输入账号密码,只要 Token 有效就行。
所以执行顺序是:
JwtUserTokenFilter(先执行:检查 Token 是否有效)
↓
UsernamePasswordAuthenticationFilter(后执行:如果 JWT 没设置认证信息,才走账号密码登录)
为什么用双认证器?
因为管理员存在 ums_admin 表,用户存在 ums_user 表,两个表不同。但密码加密方式一样(BCrypt)。所以每个 DaoAuthenticationProvider 绑定不同的 UserDetailsService,但共享同一个 PasswordEncoder。
第六步:异常处理器
// 401 未登录
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType("application/json;charset=utf-8");
response.getWriter().println(JSON.toJSONString(CommonResult.unauthorized("未登录,请先登录")));
}
}
// 403 无权限
@Component
public class RestfulAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException e) throws IOException {
response.setContentType("application/json;charset=utf-8");
response.getWriter().println(JSON.toJSONString(CommonResult.forbidden("权限不足")));
}
}
这两个处理器返回统一格式的 JSON,前端直接弹提示。如果不用自定义处理器,Spring Security 默认返回的是 HTML 页面,前后端分离的项目根本接不住。
四、还有一个藏得比较深的坑:ThreadLocal 内存泄漏
上面 JWT 过滤器里,我们用 SecurityContextHolder.getContext().setAuthentication() 存了用户信息。但在业务代码里,还需要一个更方便的方式拿当前用户 ID。
于是我们写了个工具类:
public class UserUtil {
private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
public static void setUserId(Long userId) {
USER_ID.set(userId);
}
public static Long getUserId() {
return USER_ID.get();
}
public static void clear() {
USER_ID.remove(); // 必须清理!否则内存泄漏
}
}
用 ThreadLocal 的原因是:在同一个请求的处理链路中(Controller → Service → Mapper),任何地方都能拿到当前用户 ID,不用在每个方法参数里传 userId。
但这里有个极其隐蔽的坑——
Web 服务器用线程池处理请求,一个线程处理完不会销毁,会回到线程池等下一个请求。如果不清理 ThreadLocal,线程被复用时会读到上一个用户的 ID。
后果:用户 A 登录了在后台管理商品,过一会儿用户 B 登录了,A 可能看到 B 的订单信息——严重的越权 Bug!
所以必须在 HandlerInterceptor 或 Filter 的 afterCompletion() 里调用 UserUtil.clear()。
五、还有一个细节:Gateway 跨域配置
因为是前后端分离,Vue 前端在 localhost:8080,后端在 localhost:8081,浏览器会发跨域请求。
但 Gateway 基于 WebFlux(响应式编程),不是 Servlet,不能用 HttpServletResponse.setHeader(),得用 ServerHttpResponse.getHeaders().add():
@Component
public class CorsOptionsFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// 处理 OPTIONS 预检请求
if (request.getMethod() == HttpMethod.OPTIONS) {
ServerHttpResponse response = exchange.getResponse();
HttpHeaders headers = response.getHeaders();
headers.add("Access-Control-Allow-Origin", "*");
headers.add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
headers.add("Access-Control-Allow-Headers", "*");
headers.add("Access-Control-Max-Age", "3600");
response.setStatusCode(HttpStatus.OK);
return Mono.empty();
}
return chain.filter(exchange);
}
}
预检请求是什么? 浏览器发跨域请求前,会先发一个 OPTIONS 请求"试探"服务器是否允许。如果服务器没正确地返回 CORS 头,浏览器会拦截真正的请求。这个问题前后端联调时一定会遇到。
六、面试回答技巧
把这些内容消化完之后,面试官问你"你们的权限系统怎么设计的",你大概可以这样组织答案:
我们用了 Spring Security + JWT 的无状态认证方案。为什么选无状态?因为微服务多实例部署,Session 存服务器的话负载均衡到另一台就要重新登录,很麻烦。
具体流程是:
- 登录时
UserDetailsService查数据库,把用户信息 + 权限列表封装成自定义的LoginUserJwtTokenUtil生成 Token,用 HS512 签名,放在响应头返回给前端- 后续请求前端带
Authorization: Bearer xxx,JwtUserTokenFilter解析 Token,查最新权限,设到SecurityContextHolder- 没登录返回 401,没权限返回 403,都是统一 JSON 格式
踩过几个坑——
第一个是 LoginUser 扩展问题:Spring Security 自带的 User 没有 id 字段,必须自己扩展,不然从 Token 里取不出 userId。
第二个是 密钥长度问题:新版 JJWT 要求 HS512 密钥至少 64 字节,旧版的字符串密钥直接报错。
第三个是 ThreadLocal 清理问题:请求处理完不清理 ThreadLocal,线程复用时会读到上一个用户的数据,导致越权。
我们加了 Token 自动刷新机制,用户持续操作期间 Token 会续期,不会被踢下线。权限控制基于 RBAC 模型,数据库存权限字符串,
@PreAuthorize注解在 Controller 上做方法级拦截。
这样回答既有体系(先讲整体流程,再展开细节),又有实战(三个真实踩坑),面试官听了知道你确实做过这个项目,不是背八股。
七、总结 + 避坑清单
| 场景 | 坑 | 解决方案 |
|---|---|---|
| 自定义 LoginUser | Spring 的 User 没有 id 字段 | 继承 User,扩展业务字段 |
| JWT 密钥 | 旧版字符串密钥不满足新版本长度要求 | Keys.secretKeyFor(SignatureAlgorithm.HS512) |
| JWT 重启失效 | 随机密钥重启后所有 Token 失效 | 生产环境配置固定密钥 |
| ThreadLocal | 线程复用导致读到别人的用户数据 | Interceptor 的 afterCompletion 必须 clear |
| 跨域 | Gateway 基于 WebFlux,不能用 Servlet API | 用 ServerHttpResponse.getHeaders().add() |
| 双认证器 | 管理员和用户存在不同表 | 两个 DaoAuthenticationProvider 绑定不同 UserDetailsService |
| 异常响应 | Spring Security 默认返回 HTML | 自定义 AuthenticationEntryPoint + AccessDeniedHandler |
最后送一句面试话术: 面试官问你"权限系统"的时候,不要上来背 RBAC 概念,而是说"我们为什么选 JWT、怎么处理多用户类型、Token 怎么刷新、踩过什么坑"。面试官想听的是你做过的,不是你背过的。
如果觉得对你有帮助,点赞 + 收藏,下次面试前翻出来看看。
更多推荐




所有评论(0)