乐于分享
好东西不私藏

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

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

你有没有好奇过,gh auth login 是怎么做到在终端里完成 GitHub 登录的?或者 aws configure 把你的密钥存在了哪里?

CLI 工具的认证设计是个有趣的话题——它既要足够安全(毕竟是凭证),又要足够友好(用户不想每次都输入密码)。这篇文章会拆解主流 CLI 工具的认证方案,帮你理解背后的权衡。

核心概念

在设计认证系统之前,先搞清楚四个容易混淆的概念:

概念
英文
方向
物料
类比
认证
Identification
Client → Server
身份证明(用户名密码等)
物业校验你的个人信息
授权
Authorization
Server → Client
Token
物业下发一张门禁卡
鉴权
Authentication
Client → Server
Token
进公司时刷门禁卡验证
权限控制
Access Control
Client → Server
Token
判断这张卡能否进入 CEO 办公室

用一个场景串起来:

1. 你提供工号和姓名 → 物业核实(认证)2. 物业给你一张门禁卡(授权)3. 每次进公司都要刷卡验证(鉴权)4. 进 CEO 办公室需要额外权限(权限控制)

在 CLI 工具中:

  • 认证gh auth login 打开浏览器让你登录 GitHub
  • 授权:GitHub 返回一个 access_token 给 CLI
  • 鉴权:CLI 每次调用 API 时带上这个 token
  • 权限控制:某些敏感操作需要 repo scope 才能执行

核心挑战

CLI 认证和 Web 认证不太一样:

挑战
为什么难
没有浏览器
终端里跑不了完整的 OAuth 跳转流程
长期使用
用户不想每天登录,token 需要持久化
安全存储
凭证写在硬盘上,得防着被偷
多环境
开发者的电脑、CI/CD 服务器、云主机……

好的 CLI 认证方案,其实就是在「安全性」和「易用性」之间找平衡。

三种主流方案

方案一:OAuth Device Flow(最推荐)

这是 ghdatabricks 等工具采用的方案。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:系统钥匙串(最安全)

使用操作系统提供的加密存储:

系统
存储位置
Node.js 库
macOS
Keychain
keytar
Windows
Credential Manager
keytar
Linux
Secret Service / gnome-keyring
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 认证,记住这几个原则:

  1. 能用 Device Flow 就用 Device Flow——用户体验和安全性兼顾
  2. 优先存系统钥匙串——让操作系统帮你保护秘密
  3. 短期 token + 自动刷新——即使泄露,影响也有限
  4. CI/CD 走环境变量——不落盘,方便注入

好的认证设计,是让用户感觉不到认证的存在。登录一次,之后一切自动发生。

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 设计一个安全认证的 CLI 工具

猜你喜欢

  • 暂无文章