- 包管理器
- 开发工具
- CLI
【免费下载链接】pnpm
Fast, disk space efficient package manager
导读
本文聚焦 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请求头,值为web或legacy;若本次请求带有 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 响应体,判定规则如下:
- Web 认证挑战:响应体是 JSON 且同时包含
authUrl与doneUrl两个字符串字段,即判定为 EOTP 挑战,挑战体携带这两个 URL; - 经典 OTP 挑战:响应体(转小写后)包含 npm 经典措辞
one-time pass,判定为经典 OTP 挑战,挑战体为空; - 其余情况(如
{"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)的authUrl与doneUrl时,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.并等待输入;用户取消输入则按“挑战未满足”处理,不重试。
两条路径都要求终端可交互:stdin与stdout必须都是 TTY。若注册表要求额外认证而 pnpm 运行在非交互终端中,会报ERR_PNPM_OTP_NON_INTERACTIVE(OtpNonInteractiveError,with_otp_handling.rs),诊断信息会给出两条修复建议:在交互式终端重跑命令,或改用--otp提供经典一次性密码。
一次性密码的会话级复用:OtpSession
本次 changeset 的关键设计是“密码复用”——对应源码中的OtpSession(with_otp_handling.rs):
- 会话创建时不含 OTP;第一次请求运行时把已配置的
--otp(若有)随请求发送; - 若请求返回挑战,会话通过上述两条路径之一取得新密码并缓存到
self.otp; - 同一会话后续的每一个操作都带上缓存密码——
OtpSession::run在每次调用操作前都会把当前持有的 OTP 传入(operation(self.otp.clone())),并在操作返回挑战时更新缓存后重试一次; - 重试时若注册表再次索要 OTP,则判定为
ERR_PNPM_OTP_SECOND_CHALLENGE(OtpSecondChallengeError),提示这是注册表的异常行为。
OtpSession文档注释点明了设计意图:一批操作只花费“一次在场证明”(one proof of presence)而不是每个操作各一次;当缓存的密码不再被接受(经典 OTP 通常在一分钟内过期)时,随后的挑战会获取新密码,并仅对触发该挑战的操作做一次重试。
unpublish 正是这种“一次认证、多请求复用”的典型场景:部分版本取消发布会先执行一次PUT更新 packument,再对每个被移除版本的 tarball 执行DELETE(每次删除还会先重新拉取 packument 以拿到最新的_rev)——见 unpublish.rs。如果每个请求都重新认证,一次取消发布会触发多次 2FA;而MutationContext把OtpSession与所有变更请求共享,让整次运行只认证一次(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_timeout、fetch_retries、fetch_retry_factor、fetch_retry_maxtimeout、fetch_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)把Clock、Sleep、WebAuthFetch、StdinIsTty、StdoutIsTty、EnterKeyListener、OpenUrl、PromptOtp等能力组合成一个 trait,使测试可以在不真实打开浏览器的情况下编排整个挑战-应答流程。
使用建议与限制
- 交互式终端下,默认无需任何额外参数:遇到 Web 挑战时跟随提示在浏览器完成认证即可,遇到经典 OTP 时按提示输入验证码;
- 自动化/CI 等非交互环境,若注册表使用经典一次性密码,请显式传入
--otp <code>,否则命令会以ERR_PNPM_OTP_NON_INTERACTIVE退出; - 密码只在本次运行的会话内复用,不会写入任何配置文件;
- 需要整体移除整个包时务必带上
--force,否则会得到ERR_PNPM_UNPUBLISH_CONFIRM(pnpm11/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
相关推荐
Wasp 邮箱认证完整实战指南:从注册登录到邮件验证与密码重置
Wasp 邮箱认证完整实战指南:从注册登录到邮件验证与密码重置 本指南以 Wasp 全栈框架(当前仓库 waspc https://link.gitcode.c
Web框架后端前端CLI开发工具NocoBase 密码认证:用户注册、注册表单设置与忘记密码流程全解
NocoBase 密码认证:用户注册、注册表单设置与忘记密码流程全解 本篇基于 NocoBase 官方文档《密码认证》并结合开源仓库中 @nocobase/pl
低代码后端前端人工智能AI 应用工作流自动化基于 Passport.js 与 bcrypt 的 Express 用户认证实战:从注册、登录到密码加密
基于 Passport.js 与 bcrypt 的 Express 用户认证实战:从注册、登录到密码加密 本文是一份面向 Node.js 后端开发者的完整认证实
文档教程教育
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考