一套同时支持管理员和普通用户的认证授权架构,含 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 类只有 usernamepasswordauthorities 三个字段,没有 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 存服务器的话负载均衡到另一台就要重新登录,很麻烦。

具体流程是:

  1. 登录时 UserDetailsService 查数据库,把用户信息 + 权限列表封装成自定义的 LoginUser
  2. JwtTokenUtil 生成 Token,用 HS512 签名,放在响应头返回给前端
  3. 后续请求前端带 Authorization: Bearer xxxJwtUserTokenFilter 解析 Token,查最新权限,设到 SecurityContextHolder
  4. 没登录返回 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 怎么刷新、踩过什么坑"。面试官想听的是你做过的,不是你背过的。


如果觉得对你有帮助,点赞 + 收藏,下次面试前翻出来看看。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐