|
@@ -0,0 +1,152 @@
|
|
|
|
|
+// 后台登录 Claude:以服务账号启动官方的 `claude auth login`,把登录网址交给后台,
|
|
|
|
|
+// 再把管理员粘贴的一次性授权码写入 CLI 的标准输入。登录在 Anthropic 官方页面完成,
|
|
|
|
|
+// 凭证由官方 CLI 自己交换和保存;授权码只在内存中转交,不写入日志、数据库或审计详情。
|
|
|
|
|
+import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
|
|
|
+import { AppError, badRequest, conflict, notFound } from '../lib/errors'
|
|
|
|
|
+import { newId } from '../lib/ids'
|
|
|
|
|
+
|
|
|
|
|
+const URL_TIMEOUT_MS = 30_000
|
|
|
|
|
+const SESSION_TTL_MS = 10 * 60_000
|
|
|
|
|
+const CODE_RESULT_TIMEOUT_MS = 60_000
|
|
|
|
|
+const AUTHORIZE_URL = /https:\/\/\S+\/oauth\/authorize\?\S+/
|
|
|
|
|
+
|
|
|
|
|
+export interface LoginStart { id: string; url: string; expiresAt: string }
|
|
|
|
|
+export type LoginResult = { ok: true; message: string } | { ok: false; message: string; retry: boolean }
|
|
|
|
|
+
|
|
|
|
|
+interface Session {
|
|
|
|
|
+ id: string
|
|
|
|
|
+ child: ChildProcessWithoutNullStreams
|
|
|
|
|
+ url: string
|
|
|
|
|
+ expiresAt: Date
|
|
|
|
|
+ /** 最近一次提交授权码后的输出 */
|
|
|
|
|
+ output: string
|
|
|
|
|
+ exited: { code: number | null } | null
|
|
|
|
|
+ timer: NodeJS.Timeout
|
|
|
|
|
+ listeners: Set<() => void>
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export class ClaudeLoginManager {
|
|
|
|
|
+ private session: Session | null = null
|
|
|
|
|
+
|
|
|
|
|
+ constructor(
|
|
|
|
|
+ private bin: () => string,
|
|
|
|
|
+ private audit: (summary: string) => void,
|
|
|
|
|
+ ) {}
|
|
|
|
|
+
|
|
|
|
|
+ current(): { id: string; url: string; expiresAt: string } | null {
|
|
|
|
|
+ const s = this.session
|
|
|
|
|
+ return s && !s.exited ? { id: s.id, url: s.url, expiresAt: s.expiresAt.toISOString() } : null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async start(opts: { sso?: boolean; email?: string }): Promise<LoginStart> {
|
|
|
|
|
+ if (this.current()) throw conflict('login_in_progress', '已有一个登录流程在进行,请先完成或取消')
|
|
|
|
|
+ const args = ['auth', 'login']
|
|
|
|
|
+ if (opts.sso) args.push('--sso')
|
|
|
|
|
+ if (opts.email) {
|
|
|
|
|
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(opts.email)) throw badRequest('邮箱格式不正确')
|
|
|
|
|
+ args.push('--email', opts.email)
|
|
|
|
|
+ }
|
|
|
|
|
+ const env: NodeJS.ProcessEnv = { ...process.env, BROWSER: 'false' }
|
|
|
|
|
+ delete env.ANTHROPIC_API_KEY
|
|
|
|
|
+ delete env.ANTHROPIC_AUTH_TOKEN
|
|
|
|
|
+ const child = spawn(this.bin(), args, { env, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
|
|
|
+
|
|
|
|
|
+ const session: Session = {
|
|
|
|
|
+ id: newId('adm'), child, url: '', expiresAt: new Date(Date.now() + SESSION_TTL_MS), output: '', exited: null,
|
|
|
|
|
+ timer: setTimeout(() => this.cancel(session.id, '登录超时,已取消'), SESSION_TTL_MS), listeners: new Set(),
|
|
|
|
|
+ }
|
|
|
|
|
+ const onData = (d: Buffer) => {
|
|
|
|
|
+ session.output += d.toString()
|
|
|
|
|
+ for (const l of session.listeners) l()
|
|
|
|
|
+ }
|
|
|
|
|
+ child.stdout.on('data', onData)
|
|
|
|
|
+ child.stderr.on('data', onData)
|
|
|
|
|
+ child.on('exit', code => {
|
|
|
|
|
+ session.exited = { code }
|
|
|
|
|
+ clearTimeout(session.timer)
|
|
|
|
|
+ for (const l of session.listeners) l()
|
|
|
|
|
+ })
|
|
|
|
|
+ child.on('error', err => {
|
|
|
|
|
+ session.output += `\n${err.message}`
|
|
|
|
|
+ session.exited = { code: -1 }
|
|
|
|
|
+ for (const l of session.listeners) l()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ // 等待 CLI 输出登录网址
|
|
|
|
|
+ const url = await waitFor(session, URL_TIMEOUT_MS, () => AUTHORIZE_URL.exec(session.output)?.[0] ?? (session.exited ? '' : null))
|
|
|
|
|
+ if (!url) {
|
|
|
|
|
+ child.kill('SIGKILL')
|
|
|
|
|
+ clearTimeout(session.timer)
|
|
|
|
|
+ throw new AppError(502, 'login_start_failed', `无法启动登录流程:${tail(session.output) || '没有输出登录网址'}`)
|
|
|
|
|
+ }
|
|
|
|
|
+ session.url = url
|
|
|
|
|
+ session.output = ''
|
|
|
|
|
+ this.session = session
|
|
|
|
|
+ this.audit('后台发起 Claude 登录')
|
|
|
|
|
+ return { id: session.id, url, expiresAt: session.expiresAt.toISOString() }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async submitCode(id: string, code: string): Promise<LoginResult> {
|
|
|
|
|
+ const s = this.require(id)
|
|
|
|
|
+ const clean = code.trim()
|
|
|
|
|
+ if (!clean || clean.length > 2000 || /\s/.test(clean)) throw badRequest('授权码格式不正确')
|
|
|
|
|
+ s.output = ''
|
|
|
|
|
+ s.child.stdin.write(`${clean}\n`)
|
|
|
|
|
+
|
|
|
|
|
+ // 结果:进程退出(成功或失败),或者输出"无效授权码"后继续等待重新输入
|
|
|
|
|
+ const outcome = await waitFor(s, CODE_RESULT_TIMEOUT_MS, () => {
|
|
|
|
|
+ if (s.exited) return 'exit'
|
|
|
|
|
+ return /invalid|expired|error|failed/i.test(s.output) ? 'invalid' : null
|
|
|
|
|
+ })
|
|
|
|
|
+ if (outcome === 'exit' && s.exited?.code === 0) {
|
|
|
|
|
+ this.session = null
|
|
|
|
|
+ this.audit('Claude 登录成功')
|
|
|
|
|
+ return { ok: true, message: tail(s.output) || '登录成功' }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (outcome === 'invalid' && !s.exited) {
|
|
|
|
|
+ return { ok: false, message: tail(s.output) || '授权码无效,请重新复制完整的授权码', retry: true }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!outcome) return { ok: false, message: '等待登录结果超时,请稍后刷新状态确认', retry: true }
|
|
|
|
|
+ this.session = null
|
|
|
|
|
+ this.audit('Claude 登录失败')
|
|
|
|
|
+ return { ok: false, message: tail(s.output) || `登录失败(退出码 ${s.exited?.code ?? '?'})`, retry: false }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cancel(id: string, reason = '已取消登录'): void {
|
|
|
|
|
+ const s = this.session
|
|
|
|
|
+ if (!s || s.id !== id) return
|
|
|
|
|
+ this.session = null
|
|
|
|
|
+ clearTimeout(s.timer)
|
|
|
|
|
+ if (!s.exited) s.child.kill('SIGTERM')
|
|
|
|
|
+ this.audit(reason)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private require(id: string): Session {
|
|
|
|
|
+ const s = this.session
|
|
|
|
|
+ if (!s || s.id !== id || s.exited) throw notFound('登录流程(可能已超时或已结束)')
|
|
|
|
|
+ return s
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function waitFor<T>(s: Session, timeoutMs: number, check: () => T | null): Promise<T | null> {
|
|
|
|
|
+ return new Promise(resolve => {
|
|
|
|
|
+ const done = (v: T | null) => {
|
|
|
|
|
+ clearTimeout(timer)
|
|
|
|
|
+ s.listeners.delete(listener)
|
|
|
|
|
+ resolve(v)
|
|
|
|
|
+ }
|
|
|
|
|
+ const listener = () => {
|
|
|
|
|
+ const v = check()
|
|
|
|
|
+ if (v !== null) done(v)
|
|
|
|
|
+ }
|
|
|
|
|
+ const timer = setTimeout(() => done(null), timeoutMs)
|
|
|
|
|
+ s.listeners.add(listener)
|
|
|
|
|
+ listener()
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 输出的最后几行(去掉终端控制字符),用于向管理员展示 */
|
|
|
|
|
+function tail(out: string): string {
|
|
|
|
|
+ // eslint-disable-next-line no-control-regex
|
|
|
|
|
+ return out.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim().split('\n').slice(-3).join('\n').slice(0, 500)
|
|
|
|
|
+}
|