news 2026/9/20 7:39:56

pnpm unpublish 完整支持注册表 2FA/OTP 挑战:从 ERR_PNPM_UNAUTHORIZED 到 Web 认证与一次性密码复用

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
pnpm unpublish 完整支持注册表 2FA/OTP 挑战:从 ERR_PNPM_UNAUTHORIZED 到 Web 认证与一次性密码复用
  • 包管理器
  • 开发工具
  • CLI

【免费下载链接】pnpm

Fast, disk space efficient package manager

项目地址:https://gitcode.com/gh_mirrors/pn/pnpm
点击查看免费下载

导读

本文聚焦 pnpm 在unpublish(取消发布)命令上的认证能力升级:当注册表对已登录用户返回 401 且该 401 是一次性密码(OTP)挑战时,pnpm 不再直接报ERR_PNPM_UNAUTHORIZED失败,而是自动完成双重认证——要么启动基于 Web 的认证流程(EOTP),要么提示输入经典一次性密码,并且把获取到的密码复用到本次运行内的每一个注册表请求。读者读完本文将掌握pnpm unpublish的完整参数语义、2FA 挑战的两条处理路径、密码复用机制(OtpSession)、非交互终端下的错误处理(ERR_PNPM_OTP_NON_INTERACTIVE)以及对应的源码与测试依据。

该变更记录在仓库的 changeset 文件 .changeset/unpublish-otp-challenge.md 中,涉及@pnpm/network.web-auth(minor)、@pnpm/registry-access.client(patch)、@pnpm/registry-access.commands(patch)、pacquet(patch)与pnpm(patch)等包。

unpublish 命令的认证背景与本次变更动因

pnpm unpublish用于从注册表移除某个包或某段版本范围。此前,当注册表要求双重认证(2FA)而用户已经登录时,命令会直接以ERR_PNPM_UNAUTHORIZED失败——也就是说“已登录”与“通过 2FA 挑战”被混为一谈,用户明明持有有效凭据却无法完成取消发布操作。

本次 changeset 明确了修复目标(对应上游 issue #14464):

  • 登录状态下的 401 若为OTP 挑战(而非真正的凭据错误),则启动认证完成流程;
  • 挑战携带authUrl/doneUrl时走Web 认证流程
  • 挑战为经典一次性密码时提示输入 OTP
  • 获得的密码被本次运行中的所有请求复用,避免每个请求都重复认证。

ERR_PNPM_UNAUTHORIZED在仓库中依然存在,但它现在只代表“未登录”这类真正的认证失败,而非 OTP 挑战。可以从 Rust CLI 源码 pnpm/crates/cli/src/cli_args/unpublish.rs 以及 TypeScript 测试 pnpm11/registry-access/commands/test/unpublish.ts 中看到该错误码的既有语义。

unpublish 命令参数全解

UnpublishArgs的定义位于 pnpm/crates/cli/src/cli_args/unpublish.rs,四个参数与认证直接相关的是--otp

参数类型说明
--registry <URL>可选npm 注册表的基础 URL,用于覆盖配置中的默认注册表
--otp <code>可选一次性密码,用于要求双重认证的注册表。传了--otp时命令走legacy认证类型,直接接受经典验证码
--force布尔无论当前发布了多少版本都从注册表移除整个包;不传该标志时 pnpm 拒绝整体取消发布
params位置参数要移除的包,可带版本范围(如pkg@1.x);裸包名表示移除全部已发布版本

认证类型在源码中通过AuthType枚举建模(unpublish.rs):

  • AuthType::Web:默认值,对应 npm 的auth-type: web,用于 Web 认证挑战;
  • AuthType::Legacy:用户显式传入--otp时启用,对应auth-type: legacy,接受经典一次性密码。

每个请求都会携带npm-auth-type请求头,值为weblegacy;若本次请求带有 OTP,则额外携带npm-otp请求头(见 pnpm/crates/cli/src/cli_args/unpublish/mutation.rs)。

401 挑战识别:什么才是“OTP 挑战”

并非所有 401 都值得走认证流程。otp_challenge_from_unauthorized_body(位于 pnpm/crates/network-web-auth/src/with_otp_handling.rs)负责解析 401 响应体,判定规则如下:

  1. Web 认证挑战:响应体是 JSON 且同时包含authUrldoneUrl两个字符串字段,即判定为 EOTP 挑战,挑战体携带这两个 URL;
  2. 经典 OTP 挑战:响应体(转小写后)包含 npm 经典措辞one-time pass,判定为经典 OTP 挑战,挑战体为空;
  3. 其余情况(如{"error":"unauthorized"}Bad token、空 body 等)都不是挑战,按普通认证失败处理。

对应的单元测试见 pnpm/crates/network-web-auth/src/with_otp_handling/tests.rs,覆盖了上述三种判定及边界情况。

在 unpublish 的实现里,401 响应会先被读取(带DEPRECATION_ERROR_BODY_LIMIT长度限制),然后交给该函数分类:是挑战则抛给OtpSession处理,不是挑战则用write_error_for_status生成普通注册表错误(mutation.rs)。

Web 认证流程与经典 OTP 提示

挑战被确认后,resolve_otp_challenge(with_otp_handling.rs)负责满足它,两条路径为:

  • Web 流程:当挑战体同时携带合法(http/https)的authUrldoneUrl时,pnpm 打印带认证 URL 的消息(format_auth_url_message),询问是否在浏览器中打开该 URL,随后轮询doneUrl等待用户完成浏览器认证并取得令牌(poll_for_web_auth_token)。URL 会经过canonical_http_url净化:只允许http/https协议(防止恶意注册表注入javascript:等危险 scheme),并剥离 URL 中的 userinfo,避免凭据形状的数据进入日志;
  • 经典 OTP:挑战体为空(或缺少 URL)时,在终端提示This operation requires a one-time password.并等待输入;用户取消输入则按“挑战未满足”处理,不重试。

两条路径都要求终端可交互:stdinstdout必须都是 TTY。若注册表要求额外认证而 pnpm 运行在非交互终端中,会报ERR_PNPM_OTP_NON_INTERACTIVEOtpNonInteractiveError,with_otp_handling.rs),诊断信息会给出两条修复建议:在交互式终端重跑命令,或改用--otp提供经典一次性密码。

一次性密码的会话级复用:OtpSession

本次 changeset 的关键设计是“密码复用”——对应源码中的OtpSession(with_otp_handling.rs):

  1. 会话创建时不含 OTP;第一次请求运行时把已配置的--otp(若有)随请求发送;
  2. 若请求返回挑战,会话通过上述两条路径之一取得新密码并缓存到self.otp
  3. 同一会话后续的每一个操作都带上缓存密码——OtpSession::run在每次调用操作前都会把当前持有的 OTP 传入(operation(self.otp.clone())),并在操作返回挑战时更新缓存后重试一次;
  4. 重试时若注册表再次索要 OTP,则判定为ERR_PNPM_OTP_SECOND_CHALLENGEOtpSecondChallengeError),提示这是注册表的异常行为。

OtpSession文档注释点明了设计意图:一批操作只花费“一次在场证明”(one proof of presence)而不是每个操作各一次;当缓存的密码不再被接受(经典 OTP 通常在一分钟内过期)时,随后的挑战会获取新密码,并仅对触发该挑战的操作做一次重试。

unpublish 正是这种“一次认证、多请求复用”的典型场景:部分版本取消发布会先执行一次PUT更新 packument,再对每个被移除版本的 tarball 执行DELETE(每次删除还会先重新拉取 packument 以拿到最新的_rev)——见 unpublish.rs。如果每个请求都重新认证,一次取消发布会触发多次 2FA;而MutationContextOtpSession与所有变更请求共享,让整次运行只认证一次(mutation.rs)。

请求与重试细节:头部、重试与超时

send_once(mutation.rs)构造每次变更请求:

  • 方法:DELETE <package>/-rev/<rev>(整包删除)或PUT <package>/-rev/<rev>(版本子集更新,body 为更新后的 packument JSON);
  • 头部:始终携带npm-auth-type;有authorization时携带;本次请求有生效 OTP 时携带npm-otp
  • 请求经由send_with_retry发送,重试选项取自配置。

Web 认证流程自身的轮询也有一套独立的网络选项:web_auth_fetch_options(mutation.rs)把fetch_timeoutfetch_retriesfetch_retry_factorfetch_retry_maxtimeoutfetch_retry_mintimeout等配置映射到WebAuthFetchOptions,供轮询doneUrl时使用。

测试与验证:无终端与 --otp 两种路径

Rust 集成测试 pnpm/crates/cli/tests/suite/unpublish.rs 用脚本化的 Web 认证宿主(UnpublishHosttrait,生产环境为真实WebAuthHost,测试中为可编排的 fake)验证了两条关键路径:

  • 非交互终端的 Web 挑战:spawn 出的 CLI 进程没有 TTY,注册表返回 OTP 挑战时,stderr 断言包含ERR_PNPM_OTP_NON_INTERACTIVE,而不是ERR_PNPM_UNAUTHORIZED——证明“已登录但 2FA 未过”不再与“未登录”混淆;
  • --otp的 legacy 路径the_otp_flag_sends_the_code_under_the_legacy_auth_type断言请求携带npm-otp: 123456,且本次请求不带 Web 挑战所需的npm-otp缺失匹配——验证经典一次性密码在legacy认证类型下直接前置发送。

UnpublishHost的抽象(unpublish.rs)把ClockSleepWebAuthFetchStdinIsTtyStdoutIsTtyEnterKeyListenerOpenUrlPromptOtp等能力组合成一个 trait,使测试可以在不真实打开浏览器的情况下编排整个挑战-应答流程。

使用建议与限制

  • 交互式终端下,默认无需任何额外参数:遇到 Web 挑战时跟随提示在浏览器完成认证即可,遇到经典 OTP 时按提示输入验证码;
  • 自动化/CI 等非交互环境,若注册表使用经典一次性密码,请显式传入--otp <code>,否则命令会以ERR_PNPM_OTP_NON_INTERACTIVE退出;
  • 密码只在本次运行的会话内复用,不会写入任何配置文件;
  • 需要整体移除整个包时务必带上--force,否则会得到ERR_PNPM_UNPUBLISH_CONFIRMpnpm11/registry-access/commands/test/unpublish.ts中同样断言了该保护行为);若注册表返回 405,说明该包只允许弃用(deprecate)而不允许整体删除(ERR_PNPM_UNPUBLISH_FORBIDDEN)。

上述行为以当前仓库(pnpm Rust 实现 pnpm/crates/cli/src/cli_args/unpublish.rs、Web 认证核心 pnpm/crates/network-web-auth/src/with_otp_handling.rs 及测试 pnpm/crates/cli/tests/suite/unpublish.rs)为准,注册表端的挑战格式需符合 npm 的 EOTP 约定(authUrl/doneUrl)或经典one-time pass措辞。

  • 包管理器
  • 开发工具
  • CLI

【免费下载链接】pnpm

Fast, disk space efficient package manager

项目地址:https://gitcode.com/gh_mirrors/pn/pnpm
点击查看免费下载

相关推荐

上一篇:Fooocus-MRE:从零开始的AI绘画终极指南,让创意不再被技术束缚
下一篇:主Issue:重构教程章节结构

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 7:39:38

BrewUI:基于SwiftUI的Homebrew语义化交互层设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 7:38:57

车载毫米波雷达干扰仿真评估:从FMCW原理到工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 7:35:39

跨框架智能体沙箱设计:从工具契约到全链路审计的实战拆解

上个月陪一个团队做安全评审&#xff0c;他们的Agent叫“销售助手”&#xff0c;跑在AutoGen上&#xff0c;功能很简单——查客户资料、生成跟进话术、偶尔调一下CRM的接口。团队负责人很自信地跟我们说“沙箱已经上了&#xff0c;Agent跑在独立容器里”。结果测试的时候&#…

作者头像 李华
网站建设 2026/9/20 7:35:11

开源代码审查协议:可审计、可复现、可嵌入CI的LLM协同范式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华