设计一个安全认证的 CLI 工具

你有没有好奇过,gh auth login 是怎么做到在终端里完成 GitHub 登录的?或者 aws configure 把你的密钥存在了哪里?
CLI 工具的认证设计是个有趣的话题——它既要足够安全(毕竟是凭证),又要足够友好(用户不想每次都输入密码)。这篇文章会拆解主流 CLI 工具的认证方案,帮你理解背后的权衡。
核心概念
在设计认证系统之前,先搞清楚四个容易混淆的概念:
|
|
|
|
|
|
|---|---|---|---|---|
| 认证 |
|
|
|
|
| 授权 |
|
|
|
|
| 鉴权 |
|
|
|
|
| 权限控制 |
|
|
|
|
用一个场景串起来:
1. 你提供工号和姓名 → 物业核实(认证)2. 物业给你一张门禁卡(授权)3. 每次进公司都要刷卡验证(鉴权)4. 进 CEO 办公室需要额外权限(权限控制)
在 CLI 工具中:
-
认证: gh auth login打开浏览器让你登录 GitHub -
授权:GitHub 返回一个 access_token给 CLI -
鉴权:CLI 每次调用 API 时带上这个 token -
权限控制:某些敏感操作需要 reposcope 才能执行
核心挑战
CLI 认证和 Web 认证不太一样:
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
好的 CLI 认证方案,其实就是在「安全性」和「易用性」之间找平衡。
三种主流方案
方案一:OAuth Device Flow(最推荐)
这是 gh、databricks 等工具采用的方案。RFC 8628 定义的标准流程。
怎么工作的:
你的终端 认证服务器 浏览器 │ │ │ │ 1. 请求设备码 │ │ │ ─────────────────────────────> │ │ │ │ │ │ 2. 返回 device_code + user_code│ │ │ <───────────────────────────── │ │ │ │ │ │ 3. 显示: 访问 xxx.com 输入 ABC-123 │ │ │ │ │ 4. 轮询: 用户授权了吗? │ │ │ ─────────────────────────────> │ 5. 用户在浏览器登录授权 │ │ │ <─────────────────────── │ │ 6. 返回 access_token │ │ │ <───────────────────────────── │ │
代码示例(TypeScript):
import { setTimeout as sleep } from'timers/promises';asyncfunctiondeviceFlowLogin() {// 1. 获取设备码const deviceRes = await fetch('https://github.com/login/device/code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: process.env.GITHUB_CLIENT_ID, scope: 'repo user' }) });const { device_code, user_code, verification_uri, interval, expires_in } =await deviceRes.json();// 2. 引导用户授权console.log(`\n请访问: ${verification_uri}`);console.log(`输入验证码: ${user_code}\n`);console.log(`(${Math.floor(expires_in / 60)} 分钟内有效)\n`); // 3. 轮询等待授权 const deadline = Date.now() + expires_in * 1000; while (Date.now() < deadline) { await sleep(interval * 1000); const tokenRes = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ client_id: process.env.GITHUB_CLIENT_ID, device_code, grant_type: 'urn:ietf:params:oauth:grant-type:device_code' }) }); const data = await tokenRes.json(); if (data.access_token) { return data.access_token; } if (data.error === 'authorization_pending') { continue; // 用户还没授权,继续等 } if (data.error === 'slow_down') { await sleep(5000); // 被要求降速 continue; } throw new Error(`认证失败: ${data.error}`); } throw new Error('认证超时');}
为什么推荐:
-
用户体验好——在浏览器里登录,有完整的 2FA、SSO 支持 -
安全性高——CLI 拿不到用户的密码,只能拿到短期 token -
被动式等待——用户授权后 CLI 自动感知,不需要回填什么
注意事项:
2024 年出现了针对 Device Flow 的钓鱼攻击(Storm-2372)。攻击者诱导用户访问 login.microsoft.com/devicelogin 并输入他们提供的 user_code。给用户的建议:永远只在 CLI 主动显示验证码时才去输入,不要相信别人给你的验证码。
方案二:本地回调服务器(适合桌面端)
打开浏览器,登录后回调到 localhost:6688/callback?code=xxx,本地 HTTP 服务器接收 code 换 token。
import http from'http';import open from'open';functioncallbackLogin() {returnnewPromise((resolve, reject) => {const server = http.createServer(async (req, res) => {const url = new URL(req.url, 'http://localhost:6688');const code = url.searchParams.get('code');if (!code) { res.end('无效的回调');return; }// 用 code 换 tokenconst token = await exchangeCodeForToken(code); res.end('登录成功!你可以关闭这个页面了。'); server.close(); resolve(token); }); server.listen(6688);// 打开浏览器const authUrl = new URL('https://github.com/login/oauth/authorize'); authUrl.searchParams.set('client_id', process.env.GITHUB_CLIENT_ID); authUrl.searchParams.set('redirect_uri', 'http://localhost:6688/callback'); authUrl.searchParams.set('scope', 'repo user'); open(authUrl.toString()); });}
优点: 用户体验流畅,授权后自动跳回
缺点: 需要占用端口,可能和别的服务冲突;云服务器/容器环境用不了
方案三:API Key / Token 手动输入(最简单但最不安全)
直接让用户把 token 粘贴进来。
import inquirer from'inquirer';asyncfunctiontokenLogin() {const { token } = await inquirer.prompt([ {type: 'password', name: 'token', message: '粘贴你的 API Token:', mask: '*' } ]);return token;}
适用场景:
-
内部工具,用户已经从 Web 控制台拿到了 token -
CI/CD 环境,token 以环境变量形式注入 -
快速原型开发
不推荐用于面向公众的工具。
凭证存哪里
拿到 token 后,存哪里是个关键问题。
选项 1:系统钥匙串(最安全)
使用操作系统提供的加密存储:
|
|
|
|
|---|---|---|
|
|
|
keytar |
|
|
|
keytar |
|
|
|
keytar |
import keytar from'keytar';const SERVICE_NAME = 'my-awesome-cli';asyncfunctionsaveToken(token: string) {await keytar.setPassword(SERVICE_NAME, 'access_token', token);}asyncfunctionloadToken(): Promise<string | null> {returnawait keytar.getPassword(SERVICE_NAME, 'access_token');}asyncfunctionclearToken() {await keytar.deletePassword(SERVICE_NAME, 'access_token');}
优点:
-
硬件级加密(macOS 使用 Secure Enclave) -
系统级访问控制——其他应用读不到 -
自动同步到 iCloud(macOS)
缺点:
-
CI/CD 环境可能没有钥匙串 -
无头服务器用不了
选项 2:配置文件 + 权限控制
大多数 CLI 工具采用的方式:~/.mycli/credentials
# ~/.mycli/credentials[default]access_token = ghp_xxxxxxxxxxxxrefresh_token =ghr_xxxxxxxxxxxxexpires_at = 1710000000[work]access_token = ghp_yyyyyyyyyyyy
关键:文件权限必须是 600
chmod 600 ~/.mycli/credentials
import fs from'fs';import path from'path';import os from'os';functiongetConfigPath() {return path.join(os.homedir(), '.mycli', 'credentials');}functionensureSecureConfig() {const configPath = getConfigPath();const configDir = path.dirname(configPath);if (!fs.existsSync(configDir)) { fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); }if (fs.existsSync(configPath)) {// 确保权限正确 fs.chmodSync(configPath, 0o600); }}
AWS CLI 的做法:
~/.aws/├── config # 非敏感配置(region, output format)├── credentials # 敏感凭证(access key, secret key)- chmod 600
选项 3:环境变量
CI/CD 环境的首选:
export MYCLI_TOKEN="ghp_xxxxxxxxxxxx"export MYCLI_REFRESH_TOKEN="ghr_xxxxxxxxxxxx"
functiongetToken(): string | undefined{return process.env.MYCLI_TOKEN;}
优点: 不落盘,CI/CD 友好
缺点: 进程结束就没了;环境变量可能被日志/子进程泄露
推荐策略:多层回退
asyncfunctiongetStoredToken(): Promise<string | null> {// 1. 优先用环境变量(CI/CD 场景)if (process.env.MYCLI_TOKEN) {return process.env.MYCLI_TOKEN; }// 2. 尝试系统钥匙串const keychainToken = await keytar.getPassword('my-cli', 'access_token');if (keychainToken) {return keychainToken; }// 3. 回退到配置文件const configToken = readFromConfigFile();if (configToken) {return configToken; }returnnull;}
Token 刷新策略
短期 token + 长期 refresh token 是标配:
interface TokenInfo { access_token: string; refresh_token: string; expires_at: number; // Unix timestamp}asyncfunctiongetValidToken(tokens: TokenInfo): Promise<string> {// 还没过期,直接用if (Date.now() < tokens.expires_at - 60000) { // 提前 1 分钟刷新return tokens.access_token; }// 刷新 tokenconst response = await fetch('https://auth.example.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token }) });const newTokens = await response.json();// 更新存储await saveTokens(newTokens);return newTokens.access_token;}
安全清单
设计 CLI 认证时,检查这些点:
传输层:
-
[ ] 所有请求走 HTTPS -
[ ] 验证服务器证书
存储层:
-
[ ] 敏感文件权限是 600 -
[ ] 优先使用系统钥匙串 -
[ ] 日志里不打印 token
Token 管理:
-
[ ] 使用短期 token(< 1小时) -
[ ] 实现 refresh token 轮换 -
[ ] 提供 logout命令清除凭证
用户体验:
-
[ ] Token 过期时自动刷新,不打断用户 -
[ ] 刷新失败时提示重新登录 -
[ ] 支持多账号/多 profile
业界案例
GitHub CLI (gh)
gh auth login
-
使用 OAuth Device Flow -
Token 存在 ~/.config/gh/hosts.yml -
支持多 GitHub 实例(github.com、GitHub Enterprise)
AWS CLI
aws configureaws sso login --profile my-profile
-
两种模式:长期 AK/SK 或 SSO 临时凭证 -
凭证存 ~/.aws/credentials -
SSO 模式会打开浏览器完成登录
Databricks CLI
databricks auth login --host https://xxx.cloud.databricks.com
-
支持 OAuth U2M(用户登录)和 M2M(服务账号) -
自动管理 token 刷新
总结
设计 CLI 认证,记住这几个原则:
-
能用 Device Flow 就用 Device Flow——用户体验和安全性兼顾 -
优先存系统钥匙串——让操作系统帮你保护秘密 -
短期 token + 自动刷新——即使泄露,影响也有限 -
CI/CD 走环境变量——不落盘,方便注入
好的认证设计,是让用户感觉不到认证的存在。登录一次,之后一切自动发生。
夜雨聆风