Explorar el Código

新增手机扫码登录服务器

LSamien hace 2 meses
padre
commit
c8d2a41a20

+ 3 - 0
README.md

@@ -11,6 +11,9 @@
 
 iOS 默认连接 `http://主机:8800` 并使用 8800 用户登录;已有 9119 账号继续兼容。
 9119 和 8800 都可管理由 8800 签发配对码的直接子节点,节点不会递归暴露孙节点。
+已登录 Web 用户还可在“手机登录与节点”中生成两分钟有效的一次性登录二维码;iOS
+登录页扫描后会把 8800 保存为普通服务器账号。`yaoyao://login` 登录码与
+`yaoyao://pair` 子节点码相互独立,不会把手机误登记为子节点。
 
 8800 使用单独托管的 9119 服务账号。受管本机首次安装会生成随机服务密码并加密
 保存;外部或 Docker 9119 可在“系统管理”中配置,或设置

+ 19 - 0
src/client/api/accountPairing.ts

@@ -0,0 +1,19 @@
+import { apiRequest } from './client'
+
+export interface AccountPairingSession {
+  protocolVersion: number
+  serviceType: string
+  pairingId: string
+  expiresAt: number
+  qrPayload: string
+}
+
+export async function createAccountPairing(): Promise<AccountPairingSession> {
+  return apiRequest('/api/app/account-pairings', { method: 'POST', body: {} })
+}
+
+export async function accountPairingStatus(
+  pairingId: string,
+): Promise<{ state: 'pending' | 'claimed' | 'expired' }> {
+  return apiRequest(`/api/app/account-pairings/${encodeURIComponent(pairingId)}`)
+}

+ 71 - 8
src/client/components/app/NodePairingDialog.vue

@@ -2,6 +2,7 @@
 import { computed, onBeforeUnmount, ref, watch } from 'vue'
 import QRCode from 'qrcode'
 import AppIcon from '@/components/common/AppIcon.vue'
+import { accountPairingStatus, createAccountPairing, type AccountPairingSession } from '@/api/accountPairing'
 import {
   createPairing,
   pairChildNode,
@@ -13,9 +14,10 @@ import {
   type PairingSession,
 } from '@/api/pairing'
 
-const props = withDefaults(defineProps<{ open: boolean; insecureTransport?: boolean; userName?: string }>(), {
+const props = withDefaults(defineProps<{ open: boolean; insecureTransport?: boolean; userName?: string; isAdmin?: boolean }>(), {
   insecureTransport: false,
   userName: '',
+  isAdmin: false,
 })
 const emit = defineEmits<{ close: [] }>()
 
@@ -23,7 +25,10 @@ const ALL_SCOPES: NodeScope[] = [
   'agents.read', 'history.read', 'sessions.execute', 'groups.read', 'groups.execute',
 ]
 const pairing = ref<PairingSession>()
+const accountPairing = ref<AccountPairingSession>()
 const qrImage = ref('')
+const accountQrImage = ref('')
+const accountPairingComplete = ref(false)
 const devices = ref<PairedDevice[]>([])
 const nodeID = ref('')
 const busy = ref(false)
@@ -34,6 +39,7 @@ const scanVideo = ref<HTMLVideoElement>()
 const scanning = ref(false)
 const now = ref(Date.now())
 let timer: number | undefined
+let accountTimer: number | undefined
 let scanTimer: number | undefined
 let scanStream: MediaStream | undefined
 
@@ -43,12 +49,47 @@ type BarcodeDetectorConstructor = new (options: { formats: string[] }) => Barcod
 const secondsRemaining = computed(() => pairing.value
   ? Math.max(0, Math.ceil((pairing.value.expiresAt - now.value) / 1_000))
   : 0)
+const accountSecondsRemaining = computed(() => accountPairing.value
+  ? Math.max(0, Math.ceil((accountPairing.value.expiresAt - now.value) / 1_000))
+  : 0)
 
 function stopPolling() {
   if (timer !== undefined) window.clearInterval(timer)
   timer = undefined
 }
 
+function stopAccountPolling() {
+  if (accountTimer !== undefined) window.clearInterval(accountTimer)
+  accountTimer = undefined
+}
+
+async function pollAccountPairing() {
+  now.value = Date.now()
+  const current = accountPairing.value
+  if (!current || accountSecondsRemaining.value <= 0) { stopAccountPolling(); return }
+  try {
+    const status = await accountPairingStatus(current.pairingId)
+    if (status.state === 'claimed') {
+      accountPairingComplete.value = true
+      stopAccountPolling()
+    } else if (status.state === 'expired') stopAccountPolling()
+  } catch { /* Keep a still-valid QR code during transient polling failures. */ }
+}
+
+async function beginAccountPairing() {
+  busy.value = true; error.value = ''; accountPairingComplete.value = false; stopAccountPolling()
+  try {
+    const created = await createAccountPairing()
+    accountPairing.value = created
+    now.value = Date.now()
+    accountQrImage.value = await QRCode.toDataURL(created.qrPayload, {
+      width: 296, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#191918', light: '#ffffff' },
+    })
+    accountTimer = window.setInterval(() => { void pollAccountPairing() }, 1_000)
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '无法创建手机登录二维码' }
+  finally { busy.value = false }
+}
+
 function stopScanning() {
   if (scanTimer !== undefined) window.clearInterval(scanTimer)
   scanTimer = undefined
@@ -113,6 +154,7 @@ async function beginPairing() {
   busy.value = true
   error.value = ''
   stopPolling()
+  stopAccountPolling()
   try {
     const created = await createPairing(ALL_SCOPES)
     pairing.value = created
@@ -163,9 +205,14 @@ watch(() => props.open, open => {
   stopPolling()
   stopScanning()
   pairing.value = undefined
+  accountPairing.value = undefined
   qrImage.value = ''
+  accountQrImage.value = ''
+  accountPairingComplete.value = false
   error.value = ''
   if (open) {
+    void beginAccountPairing()
+    if (!props.isAdmin) return
     busy.value = true
     void refreshDevices()
       .catch(cause => { error.value = cause instanceof Error ? cause.message : '读取配对设备失败' })
@@ -173,7 +220,7 @@ watch(() => props.open, open => {
   }
 })
 
-onBeforeUnmount(() => { stopPolling(); stopScanning() })
+onBeforeUnmount(() => { stopPolling(); stopAccountPolling(); stopScanning() })
 </script>
 
 <template>
@@ -182,26 +229,41 @@ onBeforeUnmount(() => { stopPolling(); stopScanning() })
       <div v-if="open" class="pairing-layer" role="presentation" @mousedown.self="emit('close')">
         <section class="pairing-dialog" role="dialog" aria-modal="true" aria-labelledby="pairing-title">
           <header>
-            <div><small>HERMES NODE</small><h2 id="pairing-title">手机与节点</h2></div>
+            <div><small>YAOYAO MOBILE</small><h2 id="pairing-title">手机登录与节点</h2></div>
             <button class="icon-button" type="button" aria-label="关闭手机与节点" @click="emit('close')"><AppIcon name="close" /></button>
           </header>
 
-          <p class="pairing-intro">扫描一次即可在 iOS 中使用这台 Hermes 的 Bots、聊天历史和团队 Agent。</p>
+          <p class="pairing-intro">手机扫描登录二维码后,会把这台 8800 保存为普通服务器账号;不会添加成子节点。</p>
           <p v-if="insecureTransport" class="pairing-warning"><AppIcon name="alert" :size="15" />当前是局域网 HTTP,二维码免密配对仅适用于可信网络。正式使用请配置 HTTPS/WSS 或 Tailscale。</p>
           <p v-if="error" class="pairing-error">{{ error }}</p>
 
+          <section class="account-login-section">
+            <div class="device-heading"><strong>扫码登录并添加服务器</strong><small>{{ userName }}</small></div>
+            <section v-if="accountPairing" class="qr-card">
+              <img v-if="accountQrImage" :src="accountQrImage" alt="手机扫码登录并添加服务器" />
+              <strong>{{ accountPairingComplete ? '手机已添加此服务器' : '在 iOS 登录页选择扫码' }}</strong>
+              <span v-if="accountPairingComplete">该二维码已经失效</span>
+              <span v-else-if="accountSecondsRemaining">二维码将在 {{ accountSecondsRemaining }} 秒后失效</span>
+              <span v-else>二维码已失效</span>
+              <button v-if="accountPairingComplete || !accountSecondsRemaining" class="quiet-button" type="button" @click="beginAccountPairing">重新生成</button>
+            </section>
+          </section>
+
+          <section v-if="isAdmin" class="device-section">
+            <div class="device-heading"><strong>授权为 8800 子节点</strong><small>管理员</small></div>
           <section v-if="pairing" class="qr-card">
             <img v-if="qrImage" :src="qrImage" alt="添加此 Hermes 节点的二维码" />
-            <strong>用夭夭 iOS 扫描</strong>
+            <strong>仅用于子节点配对</strong>
             <span v-if="secondsRemaining">二维码将在 {{ secondsRemaining }} 秒后失效</span>
             <span v-else>二维码已失效</span>
             <button v-if="!secondsRemaining" class="quiet-button" type="button" @click="beginPairing">重新生成</button>
           </section>
           <button v-else class="solid-button create-pairing" type="button" :disabled="busy" @click="beginPairing">
-            <AppIcon name="users" :size="17" />{{ busy ? '正在准备…' : '生成手机配对二维码' }}
+            <AppIcon name="users" :size="17" />{{ busy ? '正在准备…' : '生成子节点配对二维码' }}
           </button>
+          </section>
 
-          <section class="device-section">
+          <section v-if="isAdmin" class="device-section">
             <div class="device-heading"><strong>已授权设备</strong><small v-if="nodeID">节点 {{ nodeID.slice(0, 8) }}</small></div>
             <p v-if="!busy && !devices.length" class="device-empty">还没有已授权设备。</p>
             <div v-for="device in devices" :key="device.id" class="device-row">
@@ -209,7 +271,7 @@ onBeforeUnmount(() => { stopPolling(); stopScanning() })
               <button type="button" :disabled="busy" @click="revoke(device)">撤销</button>
             </div>
           </section>
-          <section class="device-section child-section">
+          <section v-if="isAdmin" class="device-section child-section">
             <div class="device-heading"><strong>添加 8800 子节点</strong><small>只管理直接子节点</small></div>
             <label><span>节点名称(可选)</span><input v-model="childName" placeholder="例如:办公室 Mac" /></label>
             <label><span>子节点配对码</span><textarea v-model="childCode" rows="3" placeholder="粘贴子节点生成的 yaoyao://pair…" /></label>
@@ -230,6 +292,7 @@ header { display: flex; align-items: flex-start; justify-content: space-between;
 .pairing-intro { margin: 17px 0 14px; color: var(--text-secondary); font-size: 11px; line-height: 1.65; }
 .pairing-warning, .pairing-error { margin: 0 0 13px; padding: 10px; border-radius: 10px; font-size: 10px; line-height: 1.55; }.pairing-warning { display: flex; gap: 7px; background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); }.pairing-error { background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); }
 .create-pairing { width: 100%; min-height: 43px; gap: 8px; }
+.account-login-section { display: grid; gap: 8px; margin-top: 14px; }.account-login-section > .device-heading { margin-bottom: 0; }
 .pairing-credentials { display: grid; gap: 10px; margin-bottom: 13px; }.pairing-credentials label { display: grid; gap: 5px; color: var(--text-secondary); font-size: 10px; }.pairing-credentials input { min-height: 38px; padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: 0; background: var(--surface-soft); color: var(--text-primary); }.pairing-credentials input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.pairing-credentials small { color: var(--text-muted); font-size: 9px; line-height: 1.5; }
 .qr-card { display: grid; justify-items: center; gap: 7px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: #fff; color: #191918; }.qr-card img { width: min(296px, 100%); aspect-ratio: 1; }.qr-card strong { font-size: 12px; }.qr-card span { color: #686865; font-size: 10px; }.qr-card .quiet-button { color: #191918; }
 .device-section { margin-top: 19px; padding-top: 15px; border-top: 1px solid var(--line); }.device-heading { display: flex; justify-content: space-between; margin-bottom: 8px; }.device-heading strong { font-size: 11px; }.device-heading small, .device-empty { color: var(--text-muted); font-size: 9px; }.device-empty { margin: 12px 1px; }

+ 3 - 3
src/client/components/app/WorkspaceShell.vue

@@ -282,7 +282,7 @@ onBeforeUnmount(() => {
           <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
           </button>
-          <button v-if="isAdmin" class="sidebar-account__utility" type="button" title="手机与节点" aria-label="手机与节点" @click="nodePairingOpen = true">
+          <button class="sidebar-account__utility" type="button" title="手机登录与节点" aria-label="手机登录与节点" @click="nodePairingOpen = true">
             <AppIcon name="users" :size="17" />
           </button>
           <Transition name="menu-fade">
@@ -395,7 +395,7 @@ onBeforeUnmount(() => {
           <button class="sidebar-account__utility" type="button" :title="theme === 'dark' ? '切换浅色主题' : '切换深色主题'" @click="emit('toggleTheme')">
             <AppIcon :name="theme === 'dark' ? 'sun' : 'moon'" :size="17" />
           </button>
-          <button v-if="isAdmin" class="sidebar-account__utility" type="button" title="手机与节点" aria-label="手机与节点" @click="nodePairingOpen = true; mobileDrawerOpen = false">
+          <button class="sidebar-account__utility" type="button" title="手机登录与节点" aria-label="手机登录与节点" @click="nodePairingOpen = true; mobileDrawerOpen = false">
             <AppIcon name="users" :size="17" />
           </button>
           <Transition name="menu-fade">
@@ -439,7 +439,7 @@ onBeforeUnmount(() => {
       </aside>
     </Transition>
 
-    <NodePairingDialog :open="nodePairingOpen" :insecure-transport="insecureTransport" :user-name="pairingUserName || userName" @close="nodePairingOpen = false" />
+    <NodePairingDialog :open="nodePairingOpen" :insecure-transport="insecureTransport" :user-name="pairingUserName || userName" :is-admin="isAdmin" @close="nodePairingOpen = false" />
     <SystemUpdateDialog :open="systemUpdateOpen" @close="systemUpdateOpen = false" />
     <SystemManagementDialog :open="systemManagementOpen" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @close="systemManagementOpen = false" />
     <AccountSecurityDialog :open="accountSecurityOpen" @close="accountSecurityOpen = false" />

+ 93 - 0
src/server/accountPairing.ts

@@ -0,0 +1,93 @@
+import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
+import { HttpError } from './errors.js'
+
+const PAIRING_TTL_MS = 2 * 60 * 1_000
+const MAX_PENDING_PAIRINGS = 32
+
+interface PendingAccountPairing {
+  id: string
+  userID: string
+  secretHash: Buffer
+  expiresAt: number
+  claimed: boolean
+}
+
+export interface AccountPairingSession {
+  id: string
+  secret: string
+  expiresAt: number
+}
+
+function canonicalPairingID(value: string): string {
+  const normalized = value.trim().toLowerCase()
+  if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
+    throw new HttpError(400, '登录配对码无效', 'invalid_account_pairing')
+  }
+  return normalized
+}
+
+function digest(id: string, secret: string): Buffer {
+  return createHash('sha256').update(`${id}:${secret}`, 'utf8').digest()
+}
+
+export class AccountLoginPairingStore {
+  readonly #pending = new Map<string, PendingAccountPairing>()
+  readonly #clock: () => number
+
+  constructor(clock: () => number = Date.now) {
+    this.#clock = clock
+  }
+
+  create(userID: string): AccountPairingSession {
+    this.#prune()
+    while (this.#pending.size >= MAX_PENDING_PAIRINGS) {
+      const oldest = this.#pending.keys().next().value as string | undefined
+      if (!oldest) break
+      this.#pending.delete(oldest)
+    }
+    const id = randomUUID().toLowerCase()
+    const secret = randomBytes(32).toString('base64url')
+    const expiresAt = this.#clock() + PAIRING_TTL_MS
+    this.#pending.set(id, {
+      id, userID, secretHash: digest(id, secret), expiresAt, claimed: false,
+    })
+    return { id, secret, expiresAt }
+  }
+
+  status(pairingID: string): { state: 'pending' | 'claimed' | 'expired' } {
+    const id = canonicalPairingID(pairingID)
+    const pairing = this.#pending.get(id)
+    if (!pairing || pairing.expiresAt <= this.#clock()) {
+      this.#pending.delete(id)
+      return { state: 'expired' }
+    }
+    return { state: pairing.claimed ? 'claimed' : 'pending' }
+  }
+
+  claim(pairingID: string, secret: string): string {
+    this.#prune()
+    const id = canonicalPairingID(pairingID)
+    const pairing = this.#pending.get(id)
+    if (!pairing || pairing.expiresAt <= this.#clock()) {
+      this.#pending.delete(id)
+      throw new HttpError(410, '登录配对码已过期', 'account_pairing_expired')
+    }
+    if (pairing.claimed) {
+      throw new HttpError(409, '登录配对码已使用', 'account_pairing_already_used')
+    }
+    const supplied = digest(id, secret)
+    if (supplied.byteLength !== pairing.secretHash.byteLength
+      || !timingSafeEqual(supplied, pairing.secretHash)) {
+      throw new HttpError(401, '登录配对密钥无效', 'invalid_account_pairing_secret')
+    }
+    pairing.claimed = true
+    return pairing.userID
+  }
+
+  #prune(): void {
+    const now = this.#clock()
+    for (const [id, pairing] of this.#pending) {
+      if (pairing.expiresAt <= now) this.#pending.delete(id)
+    }
+  }
+}

+ 7 - 0
src/server/app.ts

@@ -11,6 +11,7 @@ import { errorMessage, HttpError } from './errors.js'
 import { RealtimeLeaseStore } from './leases.js'
 import { NodePairingStore } from './pairing.js'
 import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
+import { AccountLoginPairingStore } from './accountPairing.js'
 import { createApiRouter } from './routes.js'
 import {
   applySecurityHeaders,
@@ -33,6 +34,7 @@ export interface ApplicationOptions {
   updates?: SystemUpdateManager
   auth?: LocalAuthStore
   upstreamSession?: UpstreamServiceSession
+  accountPairings?: AccountLoginPairingStore
 }
 
 export interface ApplicationRuntime {
@@ -46,6 +48,7 @@ export interface ApplicationRuntime {
   updates: SystemUpdateManager
   auth: LocalAuthStore
   upstreamSession: UpstreamServiceSession
+  accountPairings: AccountLoginPairingStore
   close(): void
 }
 
@@ -97,6 +100,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     upstream,
     () => auth.upstreamCredentials(configuredUpstreamCredentials),
   )
+  const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
   try {
     uploads.cleanupUncommitted()
   } catch {
@@ -166,6 +170,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
       || ctx.path === '/api/auth/providers'
       || ctx.path === '/auth/password-login'
       || ctx.path.startsWith('/api/pair/v1/')
+      || ctx.path.startsWith('/api/account-pair/v1/')
       || ctx.path === '/healthz'
       || ctx.path === '/readyz'
     if (ctx.path.startsWith('/api/app/') && !anonymous) {
@@ -207,6 +212,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     updates,
     auth,
     upstreamSession,
+    accountPairings,
   })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
@@ -231,6 +237,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     updates,
     auth,
     upstreamSession,
+    accountPairings,
     close: () => uploads.close(),
   }
 }

+ 12 - 0
src/server/localAuth.ts

@@ -120,6 +120,18 @@ export class LocalAuthStore {
     if (!user || !user.enabled || !this.#passwordMatches(user, password)) {
       throw new HttpError(401, '用户名或密码错误', 'login_failed')
     }
+    return this.#issueSession(ctx, user)
+  }
+
+  issueSession(ctx: Koa.Context, userID: string): LocalUser {
+    const user = this.#users.find(candidate => candidate.id === userID)
+    if (!user || !user.enabled) {
+      throw new HttpError(401, '用户已失效', 'account_pairing_user_unavailable')
+    }
+    return this.#issueSession(ctx, user)
+  }
+
+  #issueSession(ctx: Koa.Context, user: StoredUser): LocalUser {
     const token = randomBytes(32).toString('base64url')
     this.#sessions.set(this.#sessionKey(token), {
       userID: user.id,

+ 43 - 0
src/server/routes.ts

@@ -29,6 +29,7 @@ import {
 import { receiveGroupUploads, uploadMarkdown, UploadStore } from './uploads.js'
 import { SystemUpdateManager, type SystemUpdateStatus } from './updateManager.js'
 import { LocalAuthStore, type LocalUser, UpstreamServiceSession } from './localAuth.js'
+import { AccountLoginPairingStore } from './accountPairing.js'
 
 type JsonObject = Record<string, unknown>
 
@@ -42,6 +43,7 @@ export interface RouteDependencies {
   updates: SystemUpdateManager
   auth: LocalAuthStore
   upstreamSession: UpstreamServiceSession
+  accountPairings: AccountLoginPairingStore
 }
 
 function body(ctx: Koa.Context): JsonObject {
@@ -881,6 +883,47 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     const user = dependencies.auth.changeCredentials(ctx, currentPassword, newPassword, username)
     json(ctx, 200, { user, must_change_password: false, server_kind: 'yaoyao-web' })
   })
+  router.post('/api/app/account-pairings', (ctx) => {
+    const user = dependencies.auth.require(ctx)
+    const pairing = dependencies.accountPairings.create(user.id)
+    const origin = requestOrigin(ctx, dependencies.config)
+    const deepLink = new URL('yaoyao://login')
+    deepLink.searchParams.set('v', '1')
+    deepLink.searchParams.set('url', origin)
+    deepLink.searchParams.set('id', pairing.id)
+    deepLink.searchParams.set('secret', pairing.secret)
+    json(ctx, 201, {
+      protocolVersion: 1,
+      serviceType: 'yaoyao-web',
+      pairingId: pairing.id,
+      expiresAt: pairing.expiresAt,
+      qrPayload: deepLink.toString(),
+    })
+  })
+  router.get('/api/app/account-pairings/:pairingID', (ctx) => {
+    dependencies.auth.require(ctx)
+    json(ctx, 200, dependencies.accountPairings.status(ctx.params.pairingID))
+  })
+  router.get('/api/account-pair/v1/capabilities', (ctx) => {
+    json(ctx, 200, {
+      protocolVersion: 1,
+      serviceType: 'yaoyao-web',
+      feature: 'server-login',
+    })
+  })
+  router.post('/api/account-pair/v1/claim', (ctx) => {
+    const request = body(ctx)
+    const pairingID = typeof request.pairingId === 'string' ? request.pairingId : ''
+    const secret = typeof request.secret === 'string' ? request.secret : ''
+    const userID = dependencies.accountPairings.claim(pairingID, secret)
+    const user = dependencies.auth.issueSession(ctx, userID)
+    json(ctx, 201, {
+      protocolVersion: 1,
+      serviceType: 'yaoyao-web',
+      serverUrl: requestOrigin(ctx, dependencies.config),
+      user,
+    })
+  })
 
   router.post('/api/app/pairings', async (ctx) => {
     dependencies.auth.requireAdmin(ctx)

+ 24 - 0
tests/server/accountPairing.test.ts

@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { AccountLoginPairingStore } from '../../src/server/accountPairing.js'
+
+describe('8800 account login pairing', () => {
+  it('issues a one-time expiring claim for a normal server account', () => {
+    let now = 1_000
+    const store = new AccountLoginPairingStore(() => now)
+    const pairing = store.create('user-1')
+    expect(store.status(pairing.id)).toEqual({ state: 'pending' })
+    expect(store.claim(pairing.id, pairing.secret)).toBe('user-1')
+    expect(store.status(pairing.id)).toEqual({ state: 'claimed' })
+    expect(() => store.claim(pairing.id, pairing.secret)).toThrow(/已使用/)
+
+    const expired = store.create('user-2')
+    now = expired.expiresAt + 1
+    expect(store.status(expired.id)).toEqual({ state: 'expired' })
+  })
+
+  it('rejects an invalid secret', () => {
+    const store = new AccountLoginPairingStore()
+    const pairing = store.create('user-1')
+    expect(() => store.claim(pairing.id, 'wrong-secret')).toThrow(/密钥无效/)
+  })
+})

+ 16 - 0
tests/server/localAuthRoutes.test.ts

@@ -74,6 +74,22 @@ describe('8800 local authentication routes', () => {
     expect(users.body.items.map((user: { username: string }) => user.username)).toEqual(['owner', 'member'])
     expect(cookies(changed)).not.toContain('admin')
 
+    const accountPairing = await agent.post('/api/app/account-pairings')
+      .set('Host', '127.0.0.1:8800').set('Origin', 'http://127.0.0.1:8800')
+      .set('X-CSRF-Token', ready.body.csrfToken).send({}).expect(201)
+    const loginCode = new URL(accountPairing.body.qrPayload)
+    expect(loginCode.hostname).toBe('login')
+    const scanned = request.agent(runtime.app.callback())
+    await scanned.post('/api/account-pair/v1/claim').set('Host', '127.0.0.1:8800')
+      .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
+      .expect(201)
+    const scannedIdentity = await scanned.get('/api/auth/me').set('Host', '127.0.0.1:8800').expect(200)
+    expect(scannedIdentity.body).toMatchObject({ username: 'owner', role: 'admin' })
+    await request(runtime.app.callback()).post('/api/account-pair/v1/claim')
+      .set('Host', '127.0.0.1:8800')
+      .send({ pairingId: loginCode.searchParams.get('id'), secret: loginCode.searchParams.get('secret') })
+      .expect(409)
+
     const native = request.agent(runtime.app.callback())
     await native.get('/api/status').set('Host', '127.0.0.1:8800').expect(200, /yaoyao-web/)
     await native.post('/auth/password-login').set('Host', '127.0.0.1:8800')