委托鉴权 · 链式编排 · 六位插槽 —— 插件自定义鉴权的架构跃迁

前言、Spring插件化框架开源

深度解析,正式收官,聚焦 v2.0.0 的架构跃迁。文章从"鉴权不授权"的设计边界切入,深度拆解委托链式认证编排引擎的内核------Provider 按优先级依次认领、可插拔策略控制何时终止与最终裁决,以及两层 API(简单层/高级层)如何覆盖全量排他、OR 鉴权、混合匿名与多策略共存全场景。随后揭示六位插槽 Filter 扩展点的"安装≠启用"三层控制模型,MVC 与 WebFlux 双栈对等的适配器架构,认证事件与分级日志构成的完整可观测性闭环,以及 gj-pf4j-security 模块如何以声明式扩展接口实现零侵入集成。

开源不易,如果觉得本项目对您的工作或者学习还是有帮助的话, 请帮忙在GitHub 点个⭐️Star, 更多实现细节及完整配置参数,详见 GitHub 仓库README和Wiki。

gj-pf4j 插件化安全架构全景:左侧 Provider 链式认证引擎负责"你是谁",右侧六位 Filter 插槽负责"在什么阶段做什么",中间生命周期引擎编排两者的注册与回收,三者协同贯穿插件的安全生命周期。

基于 PF4J 内核,深度整合 Spring 生态。不依赖 Spring Boot,MVC/WebFlux 双栈自适应,轻量无侵入,对中小项目极友好;原生适配达梦、人大金仓、GaussDB 等国产数据库,插件代码完全标准 Spring 注解 -可插拔扩展点允许任意第三方组件以零框架改动的方式接入插件生命周期。开放插件自定义鉴权让插件通过 SPI 自由定义认证逻辑,Provider 链式委托配合可插拔策略分发请求,六位 Filter 插槽供宿主编排任意安全过滤器------受"安装 ≠ 启用"模型统一管控。

温馨提示:全文约6100 字左右,阅读时长约15分钟。如果你正在为插件设计鉴权方案,或者好奇一个插件框架如何把"鉴权"的事比较优雅地下放给插件------这篇值得你慢慢看


一、从一个真实的支付插件说起

假设你正在开发一个微信支付插件。它有四个接口:

复制代码
POST   /wxpay/order/create       创建订单    → 内部员工操作,走宿主鉴权
GET    /wxpay/order/{id}         查询订单    → 同上,宿主 session 即可
POST   /wxpay/order/refund       申请退款    → 内部员工操作,但需要额外验 JWT Token
POST   /wxpay/order/callback     支付回调    → 微信服务器调用,验 API Key 签名

四个接口,三种认证方式:宿主 Session、JWT Token、API Key 签名。回调接口尤为特殊------它是微信服务器调过来的,没有宿主 Session,不走登录流程。它只认一个东西:你拿着微信发给你的 API Key,对请求体做了正确的签名。

在之前,插件完全做不到这一点。插件的 Controller 注册到宿主 DispatcherServlet,请求全部经过同一安全链------宿主用什么鉴权方式,插件就只能被动继承。回调接口既不走宿主鉴权,又不是完全公开(不能直接标 @AllowAnonymous),卡在中间无法处理。

更进一步------即使"创建订单"走宿主 Session 就够了,"申请退款"需要额外验 JWT。同一个插件内,不同接口需要不同的认证策略。这在之前也是做不到的。

答案只有一个:让插件能定义自己的认证方式,但授权仍由宿主统一裁定


二、快速开始

宿主引入 gj-pf4j-security 模块即可启用全部鉴权能力,插件侧依赖相同,scope 改为 provided(运行时由宿主提供)。

xml 复制代码
<dependency>
    <groupId>io.github.wangpengxpy</groupId>
    <artifactId>gj-pf4j-security</artifactId>
    <version>{version}</version>
</dependency>

三、设计边界:只做鉴权,不做授权

这是我们做的第一个关键决策。我们采取集中式策略引擎------从插件各自鉴权迁移到集中化鉴权,采用集中化授权框架做统一判定。授权集中化,不下放给插件------插件只负责"你是谁"的认证环节,授权决策完全由宿主接管。

复制代码
插件负责 → "你是谁"(认证 Authentication)
宿主负责 → "你能干什么"(授权 Authorization)

回到支付插件的例子:插件只管验 API Key 签名、验 JWT Token,返回 Authentication 对象。至于这个通过签名验证的"微信支付回调"有没有权限调接口------由宿主的 AuthorizationFilter 统一裁决。插件不参与授权决策。


四、认证模型:链式委托与可插拔策略

认证链是"委托鉴权"的执行内核。下图完整展示了 Provider 链从 supports() 认领、authenticate() 执行,到策略裁决、四分支结果的完整流转。

4.1 多个认证源

支付插件的四个接口需要三种认证方式。同一个请求过来,它可能携带 API Key 签名(来自微信回调),也可能携带 JWT Token(来自内部员工的退款申请),也可能只有宿主 Session(来自内部员工的订单查询)。我们的方案是认证提供者链:多个 Provider 按优先级排成链,依次认领并认证同一个请求。以支付插件为例,注册两个 Provider:

复制代码
POST /wxpay/order/callback  请求到达(微信服务器)
  │
  ▼
  Provider 1 --- WxPayCallbackSignatureProvider (order=100)
    supports() → 请求路径是否以 /callback 结尾?
      ├─ 否 → 跳过,继续下一个
      └─ 是 → authenticate()
              ├─ 从 Header 取 Wechatpay-Signature
              ├─ 用 API Key 对请求体做 HMAC-SHA256 签名验证
              ├─ 签名匹配 → 注入 SecurityContext → 放行
              └─ 签名不匹配 → PluginBadCredentialsException → 401

  Provider 2 --- WxPayJwtProvider (order=500)
    supports() → 默认 true(兜底,认领剩余所有请求)
      authenticate()
        ├─ 从 Header 取 Authorization: Bearer <token>
        ├─ 无 token → 返回 null → 回退宿主鉴权
        ├─ 有 token → 校验 JWT → 注入 SecurityContext
        └─ token 过期 → PluginBadCredentialsException → WARN 日志,继续

注意 WxPayCallbackSignatureProvider 的 supports() 只认 /callback 结尾的请求。创建订单、查询订单、退款都不匹配,直接跳过,交给下一个 Provider。这就是 supports() 的精细认领------只在需要时接管。而 WxPayJwtProvider 作为兜底,supports() 默认 true,认领所有剩余请求。对于普通订单查询(无 JWT),authenticate() 返回 null,框架回退到宿主鉴权。对于退款操作(有 JWT),返回 Authentication 注入上下文。

4.2 链的编排策略

Provider 链的"何时继续、何时终止、最终如何判定"不是硬编码的------我们借鉴 Apache Shiro 的 ModularRealmAuthenticator 设计,将其抽象为可插拔的 AuthenticationStrategy

java 复制代码
public interface AuthenticationStrategy {
    Decision onAttempt(ProviderAttempt attempt);
    Authentication onCompletion(List<ProviderAttempt> attempts);

    record ProviderAttempt(
            IPluginAuthenticationProvider provider,
            Authentication result,
            PluginAuthenticationException exception,
            long durationMs
    ) {}
}

框架内置两种策略:

策略 行为 支付插件中的表现
AtLeastOneSuccessfulStrategy(默认) 所有支持的 Provider 都试,任一成功即通过 回调请求:WxPayCallbackSignatureProvider 成功 → 放行。退款请求:WxPayJwtProvider 成功 → 放行。查询请求:两个都无结果 → 回退宿主鉴权
FirstSuccessfulStrategy 第一个成功就停 同上,但第一个 Provider 成功后立即终止

4.3 认证事件与日志:可观测性不侵入 Provider

每轮 Provider 调用都有完整日志。以支付插件一次回调为例:

xml 复制代码
[Plugin: payment] Auth SUCCESS via WxPayCallbackSignatureProvider in 3ms: principal=mch_123456
[Plugin: payment] Auth chain: 1 success, 0 failed, 3ms total → principal=mch_123456

退款操作------JWT 已过期:

xml 复制代码
[Plugin: payment] Bad credentials via WxPayJwtProvider in 5ms: token expired at 2026-07-05T12:00:00Z
[Plugin: payment] Auth chain exhausted: 2 provider(s) all failed in 5ms → 401

6 种场景各配 INFO/WARN/ERROR/DEBUG 级别,统一 Plugin: {id} 前缀,每条日志带耗时。更进一步,框架发布认证事件------PluginAuthenticationSuccessEventPluginAuthenticationFailureEvent。宿主监听即可实现告警:

java 复制代码
@Component
public class PaymentAuthAudit {
    @EventListener
    public void onCallbackAuth(PluginAuthenticationSuccessEvent e) {
        if ("WxPayCallbackSignatureProvider".equals(e.getProviderName())) {
            auditLog.info("微信支付回调认证成功: mch={}, {}ms",
                    e.getAuthentication().getName(), e.getDurationMs());
        }
    }

    @EventListener
    public void onAuthFailure(PluginAuthenticationFailureEvent e) {
        if (e.getDurationMs() > 2000) {
            alerting.send("插件 [{}] 认证耗时异常: {}ms", e.getPluginId(), e.getDurationMs());
        }
    }
}

Provider 代码里一行日志都不需要写。事件体系完全在 Provider 外部运作。


五、两层 API:简单与高级,各司其职

框架提供两层认证 API,适配两种截然不同的开发场景。简单层面向大多数接口------写一个 authenticate() 方法,框架自动处理路由分发。高级层面向特殊接口------supports() 精细控制认领范围,完全自定义路由逻辑。两层可共存,框架自动检测冲突。

5.1 简单层:只写认证逻辑,路由框架自动处理

对于大部分插件接口(创建订单、查询订单、退款),用 JWT 统一认证。开发者只写 authenticate() 方法:

java 复制代码
@Component
public class WxPayJwtProvider extends AbstractPluginAuthenticationProvider {
    @Override
    public Authentication authenticate(HttpServletRequest request)
            throws PluginAuthenticationException {
        String token = request.getHeader("Authorization");
        if (token == null || !token.startsWith("Bearer ")) {
            return null;  // 无 JWT → 不强制,回退宿主鉴权
        }
        Claims claims = JwtUtils.verify(token.substring(7));
        return new UsernamePasswordAuthenticationToken(
                claims.getSubject(), null, extractAuthorities(claims));
    }
}

框架在插件启动时自动采集该插件的所有 URL pattern------/wxpay/order/create、/wxpay/order/refund------注入到 supports() 中。路由分发完全自动,开发者不需要写一行 if (uri.contains(...))。

5.2 高级层:精细控制,回调接口自主认领

回调接口比较特殊------它需要按路径匹配,且认证方式完全不同(验 API Key 签名而非 JWT)。这适合走高级层:

java 复制代码
@Order(100)  // 比简单层的 WxPayJwtProvider (默认 500) 先执行
@Component
public class WxPayCallbackSignatureProvider implements IPluginAuthenticationProvider {

    @Override
    public boolean supports(HttpServletRequest request) {
        return request.getRequestURI().endsWith("/callback");
    }

    @Override
    public Authentication authenticate(HttpServletRequest request)
            throws PluginAuthenticationException {
        String signature = request.getHeader("Wechatpay-Signature");
        String timestamp = request.getHeader("Wechatpay-Timestamp");
        String nonce = request.getHeader("Wechatpay-Nonce");

        if (signature == null || timestamp == null || nonce == null) {
            throw new PluginBadCredentialsException("缺少微信支付签名参数");
        }

        String body = new String(request.getInputStream().readAllBytes());
        String expectedSign = HmacUtils.hmacSha256(apiKey, timestamp + "\n" + nonce + "\n" + body);
        if (!expectedSign.equals(signature)) {
            throw new PluginBadCredentialsException("微信支付签名验证失败");
        }

        return new UsernamePasswordAuthenticationToken("mch_" + mchId, null, List.of());
    }
}

关键设计:@Order(100) 让它在 WxPayJwtProvider(默认 500)之前执行。回调请求先被 supports() 匹配到,认证成功后就结束了,WxPayJwtProvider 不会参与。非回调请求不匹配 supports(),跳过,由后面的 Provider 处理。

5.3 两层共存:框架自动冲突检测

支付插件恰好有两层共存------简单层的 WxPayJwtProvider + 高级层的 WxPayCallbackSignatureProvider。框架在启动时自动检测:

复制代码
1. 发现 AbstractPluginAuthenticationProvider 子类: WxPayJwtProvider
2. 发现其他 IPluginAuthenticationProvider 实现: WxPayCallbackSignatureProvider
3. 检查 @PluginAuthenticated 标注情况 → 已准确标注
4. 简单层注入 authenticatedPaths,高级层原样注册 → OK

如果开发者没有标注 @PluginAuthenticated,简单层会默认接管全部接口,意外覆盖高级层的回调处理。框架检测到这个冲突后,ERROR 日志并自动跳过简单层:

复制代码
[Plugin: payment] Simple-layer provider (WxPayJwtProvider) found alongside
1 advanced provider(s) without @PluginAuthenticated annotation.
Skipping simple-layer to prevent conflict.

这是典型的"错误时大声报错,而非静默失败"。


六、一个注解:会话优先与独占鉴权

一个注解,两种模式------@PluginAuthenticated 声明式切换会话优先与独占鉴权。下图展示了它在 MVC 注解式路由和 WebFlux 函数式路由中的双通道等价实现,我们回到支付插件的 Controller:

java 复制代码
@RestController
@RequestMapping("/wxpay/order")
public class WxPayOrderController {

    @PluginAuthenticated              // ← OR 鉴权
    @PostMapping("/refund")
    public Result refund(@RequestBody RefundRequest req) {
        // 内部员工操作:Session 用户直接放行,外部系统走 JWT
    }

    @GetMapping("/{id}")             // ← 未标注,走宿主 session
    public Result getOrder(@PathVariable String id) {
        // 普通查询,宿主鉴权即可
    }

    @PluginAuthenticated              // ← OR 鉴权
    @PostMapping("/create")
    public Result createOrder(@RequestBody OrderRequest req) {
        // 同退款:Session 放行 / JWT
    }

    @AllowAnonymous(reason = "微信支付回调由微信服务器发起,通过 API Key 签名验证")  // ← 匿名
    @PostMapping("/callback")
    public Result callback(@RequestBody String body) {
        // 不是真的"匿名"------WxPayCallbackSignatureProvider 在 Filter 层做了签名验证
    }
}

四种接口,三种行为:

接口 注解 认证流程
GET /{id} 宿主 Session
POST /create @PluginAuthenticated Session 优先,无 Session → JWT
POST /refund @PluginAuthenticated Session 优先,无 Session → JWT
POST /callback @AllowAnonymous 跳过认证链 → WxPayCallbackSignatureProvider 在 Filter 层验签

@PluginAuthenticated 区分了两种模式:

模式 触发条件 Session 用户的处理
全量排他 Controller 无 @PluginAuthenticated 忽略宿主 Session,必过 authenticate()
OR 鉴权 Controller 有 @PluginAuthenticated Session 优先放行,无 Session 才走 authenticate()

@PluginAuthenticated 支持标注在方法上,方法级优先于类级。同时标注 @AllowAnonymous@AllowAnonymous 生效。WebFlux 函数式路由通过 .pluginAuthenticated() 链式调用声明,与注解语义完全等价:

java 复制代码
return GJRouterFunctions.route()
    .POST("/wxpay/order/refund", handler::refund)
    .pluginAuthenticated()
    .POST("/wxpay/order/callback", handler::callback, "微信支付回调验签")
    .build();

七、不止认证:Filter 扩展点的六位插槽模型

认证之外,插件还可以在 Spring Security 标准链上的六个位置插入自定义 Filter------从请求预处理到响应后加工,覆盖整条安全链。插件只需实现对应接口,宿主通过三层配置控制是否启用。认证链与 Filter 扩展点互补:认证负责"你是谁",Filter 负责"在什么阶段做什么"。

7.1 真实需求:回调请求需要做防重放

微信支付回调还有一个额外需求:防重放攻击。微信的签名参数中包含timestamp和nonce,我们需要在认证之前就检查:

  1. Timestamp 与服务器时间差不超过 5 分钟
  2. Nonce 在 5 分钟内没有被用过(Redis 缓存检查)

这两个检查不是"认证"------它们不产生 Authentication 对象------但它们需要在认证之前执行。这就是 Filter 扩展点的用武之地:

java 复制代码
@Component
public class ReplayAttackFilter implements FirstFilterExtension {
    @Override
    public Filter getFilter() {
        return (req, res, chain) -> {
            String path = PluginHttpUtils.getPathWithinApplication((HttpServletRequest) req);
            if (!path.endsWith("/wxpay/order/callback")) {
                chain.doFilter(req, res);
                return;
            }
            String timestamp = ((HttpServletRequest) req).getHeader("Wechatpay-Timestamp");
            String nonce = ((HttpServletRequest) req).getHeader("Wechatpay-Nonce");
            if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) > 300) {
                ((HttpServletResponse) res).sendError(400, "timestamp expired");
                return;
            }
            if (redis.setIfAbsent("nonce:" + nonce, "1", Duration.ofMinutes(5))) {
                chain.doFilter(req, res);
            } else {
                ((HttpServletResponse) res).sendError(400, "nonce reused");
            }
        };
    }
    @Override
    public int getOrder() { return 0; }
}

它实现了 FirstFilterExtension 接口,返回一个 Filter。框架在插件启动时扫描到它,检查宿主是否开放了 FIRST 位置,如果开放则动态注入安全链。

7.2 六位插槽全景

安全链上共 7 个位置,其中 AUTHENTICATION(位置 4)由框架的 IPluginAuthenticationProvider 认证链占用,插件不实现此位置的 Filter 扩展接口。其余 6 个位置------FIRSTSESSION_RESTOREFORM_LOGINANONYMOUSPRE_AUTHORIZELAST------开放给插件实现。

支付插件只用到 FIRST 和 LAST 两个位置。其余位置空置------不影响安全链性能(代理 Filter 检测到空列表后直接透传)。再举一个 LAST 位置的例子------支付插件在每个响应中注入版本号:

java 复制代码
@Component
public class PaymentVersionHeaderFilter implements LastFilterExtension {
    @Override
    public Filter getFilter() {
        return (req, res, chain) -> {
            chain.doFilter(req, res);
            ((HttpServletResponse) res).setHeader("X-Payment-Version", "1.0.0");
        };
    }
}

7.3 三层开关

Filter 在安全链上执行,不能无条件开放------防重放 Filter 如果写坏了,可能把所有请求都拦截掉。因此我们设计了逐层收紧的控制模型:

复制代码
第一层:全局开关       gj.security.filter.enabled = true
                        → 防君子:默认关闭,管理员明确开启
第二层:位置开关       gj.security.filter.allowed-positions = FIRST, LAST
                        → 防泛滥:只开放你真正需要的位置
第三层:插件开关       gj.security.plugins.payment.filter.allowed-positions = FIRST, LAST
                        → 防冒用:只有受信插件的 Filter 才生效

支付插件的配置示例:

yaml 复制代码
gj:
  security:
    filter:
      enabled: true
      allowed-positions: FIRST, LAST       # 只开放两个位置
    plugins:
      gj.payment.module:                    # 仅对此插件开放
        filter:
          allowed-positions: FIRST, LAST

插件实现了 Filter 接口 ≠ Filter 一定生效。三层全通过才注入,默认全部关闭。

7.4 为什么 Filter 需要三层开关,而 Provider 不需要?

从威胁模型看,Filter 和 Provider 的控制粒度截然不同------它们的破坏半径决定了是否需要逐层防御:

  • Filter 横向穿透:一条SecurityFilterChain上挂载所有插件的 Filter,宿主无法在运行时针对单个插件的 Filter 做启停控制。一个插件的防重放 Filter 写坏了,可能把所有插件(甚至宿主)的请求都拦截。所以必须预埋三层漏斗------在注入之前由宿主逐层收紧。
  • Provider 纵向隔离:一个 Provider 只处理对应插件的请求,不跨插件。即使 Provider 出 bug,宿主只需disablePlugin(id)即可止损------这是宿主本来就有的插件管理能力,无需通过开关收敛。

安全上这叫"破坏半径驱动控制"------影响的请求范围越大,需要的控制粒度越细。对比认证 SPI:Provider 只是填充 SecurityContext,破坏半径可控,无需配置即可生效。

7.5 动态插入:代理 Filter 模式

Spring Security 的 SecurityFilterChain 在构建后不可变,但插件在运行时动态加载。我们通过预埋代理 Filter 解决------6 个 PluginCompositeFilter Bean 预先注册到宿主安全链,内部持有 CopyOnWriteArrayList。插件启动时 Filter 插入列表,停止时从列表移除。宿主安全链结构始终不变。每个插件 Filter 的调用包裹了 try-catch------沙箱隔离。通过 nextCalled 标记区分两种异常场景:

java 复制代码
boolean[] nextCalled = new boolean[1];
wrapped = (req, res) -> {
    try {
        f.doFilter(req, res, (r, s) -> {
            nextCalled[0] = true;
            next.doFilter(r, s);
        });
    } catch (Exception e) {
        log.error("Plugin filter error --- skipping: {}", f.getClass(), e);
        // 前置异常(next 未调用)→ 跳过故障 Filter,继续后续链
        // 后置异常(next 已调用)→ 吞掉,避免重复执行下游
        if (!nextCalled[0]) {
            next.doFilter(req, res);
        }
    }
};

同时发布 PluginFilterErrorEvent,宿主可监听接入告警。

7.6 异常体系

异常 含义 日志级别
PluginAuthChallenge OAuth2/SAML 等跳转(控制流,非错误) DEBUG
PluginBadCredentialsException 凭证无效(API Key、Token、签名等客户端错误) WARN
PluginAuthServiceException 外部认证不可用 ERROR

八、实践与思考

回到支付插件的四个接口------创建订单、查询订单、退款、回调。之前,全部走宿主鉴权,回调完全没法处理。现在,三种认证方式(宿主 Session、JWT Token、API Key 签名)在同一个插件内和平共存。

对插件开发者:大部分接口继承 AbstractPluginAuthenticationProvider,写一个 authenticate() 方法,框架自动处理路由分发。特殊接口(如回调)走高级层,写 supports() 精细控制认领范围。需要防重放?实现 FirstFilterExtension,宿主配置一开即生效。

对宿主运维:Filter 扩展点默认关闭,按位置、按插件逐层开启。每个 Provider 的每次调用都有日志(带耗时、带场景级别)、有事件(@EventListener 即可接入审计告警)。认证链策略可替换------覆盖一个 Bean 即可切换编排模式。

框架的安全边界不再由预置能力定义。任何人写一个 IPluginAuthenticationProvider 或实现一个 Filter 扩展接口,就能以和内置能力完全相同的方式接入插件的安全生命周期。


九、总结

本篇是最新v2.0.0 版本的收官之作。从插件扩展机制的全面开放,到自定义鉴权能力的完整暴露------插件开发的两个核心命题,我是谁,我能做什么至此融为一体。框架已搭好舞台、立好护栏,剩下的想象力,属于用它的人