localAuth.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734
  1. import {
  2. createCipheriv,
  3. createDecipheriv,
  4. createHash,
  5. randomBytes,
  6. randomUUID,
  7. scryptSync,
  8. timingSafeEqual,
  9. } from 'node:crypto'
  10. import {
  11. chmodSync,
  12. mkdirSync,
  13. readFileSync,
  14. renameSync,
  15. writeFileSync,
  16. } from 'node:fs'
  17. import { dirname, join } from 'node:path'
  18. import type Koa from 'koa'
  19. import { parse, serialize } from 'cookie'
  20. import { HttpError } from './errors.js'
  21. import { validAvatarImage } from '../shared/agentIdentity.js'
  22. import { appendSetCookies } from './security.js'
  23. import { CookieJar, type UpstreamRequestOptions, type UpstreamResponse, UpstreamClient } from './upstream.js'
  24. import { allowsLocalAuthorization, isLocalAuthorizationTarget, localSessionToken } from './loopbackAuthorization.js'
  25. const SESSION_COOKIE = 'hermes_yaoyao_session'
  26. const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30
  27. const USERNAME_PATTERN = /^[^\u0000-\u001f\u007f\s/@\\]{1,100}$/u
  28. export type LocalRole = 'admin' | 'user'
  29. interface StoredUser {
  30. id: string
  31. username: string
  32. avatar?: string
  33. normalizedUsername: string
  34. role: LocalRole
  35. assignedProfiles?: string[]
  36. enabled: boolean
  37. mustChangePassword: boolean
  38. salt: string
  39. passwordHash: string
  40. authVersion: number
  41. createdAt: number
  42. updatedAt: number
  43. }
  44. interface StoredUsers { version: 1; users: StoredUser[] }
  45. interface SessionRecord {
  46. userID: string
  47. authVersion: number
  48. expiresAt: number
  49. }
  50. interface StoredSessions { version: 1; sessions: Array<SessionRecord & { tokenHash: string }> }
  51. export interface LocalUser {
  52. id: string
  53. username: string
  54. avatar?: string
  55. role: LocalRole
  56. assignedProfiles?: string[]
  57. enabled: boolean
  58. mustChangePassword: boolean
  59. createdAt: number
  60. updatedAt: number
  61. }
  62. export interface UpstreamCredentials { username: string; password: string }
  63. function passwordDigest(password: string, salt: Buffer): Buffer {
  64. return scryptSync(password, salt, 32, {
  65. N: 2 ** 14, r: 8, p: 1, maxmem: 64 * 1_024 * 1_024,
  66. })
  67. }
  68. function canonicalUsername(value: string): string {
  69. const username = value.trim()
  70. if (!USERNAME_PATTERN.test(username)) {
  71. throw new HttpError(400, '用户名必须为 1 到 100 个不含空格的字符', 'invalid_username')
  72. }
  73. return username
  74. }
  75. function validatePassword(value: string, allowDefault = false): string {
  76. if (value.length > 4_096 || (!allowDefault && value.length < 8) || (allowDefault && !value)) {
  77. throw new HttpError(400, '密码必须至少 8 个字符', 'invalid_password')
  78. }
  79. return value
  80. }
  81. function publicUser(user: StoredUser): LocalUser {
  82. return {
  83. id: user.id,
  84. username: user.username,
  85. ...(validAvatarImage(user.avatar) ? { avatar: user.avatar } : {}),
  86. role: user.role,
  87. assignedProfiles: [...(user.assignedProfiles ?? [])],
  88. enabled: user.enabled,
  89. mustChangePassword: user.mustChangePassword,
  90. createdAt: user.createdAt,
  91. updatedAt: user.updatedAt,
  92. }
  93. }
  94. export class LocalAuthStore {
  95. readonly #path: string
  96. readonly #credentialPath: string
  97. readonly #keyPath: string
  98. readonly #sessionsPath: string
  99. readonly #secureCookie: boolean
  100. readonly #sessions = new Map<string, SessionRecord>()
  101. #users: StoredUser[]
  102. constructor(home: string, secureCookie = false) {
  103. this.#path = join(home, 'users.json')
  104. this.#credentialPath = join(home, 'upstream-credentials.enc')
  105. this.#keyPath = join(home, 'local-auth.key')
  106. this.#sessionsPath = join(home, 'sessions.json')
  107. this.#secureCookie = secureCookie
  108. this.#users = this.#loadUsers()
  109. this.#loadSessions()
  110. }
  111. get setupRequired(): boolean {
  112. return this.#users.length === 0
  113. }
  114. /** Exact persisted user identity used only to validate a legacy registry migration. */
  115. soleUserIDForLegacyMigration(): string | undefined {
  116. return this.#users.length === 1 ? this.#users[0]!.id : undefined
  117. }
  118. setupAdmin(
  119. ctx: Koa.Context,
  120. usernameValue: string,
  121. passwordValue: string,
  122. ): LocalUser {
  123. if (!this.setupRequired) {
  124. throw new HttpError(409, '管理员已经初始化', 'setup_already_completed')
  125. }
  126. const username = canonicalUsername(usernameValue)
  127. const password = validatePassword(passwordValue)
  128. const now = Date.now()
  129. const admin = this.#newUser(username, password, 'admin', false, now)
  130. this.#users = [admin]
  131. this.#saveUsers()
  132. return this.#issueSession(ctx, admin)
  133. }
  134. login(ctx: Koa.Context, usernameValue: string, password: string): LocalUser {
  135. const normalized = usernameValue.trim().toLocaleLowerCase('en-US')
  136. const user = this.#users.find(candidate => candidate.normalizedUsername === normalized)
  137. if (!user || !user.enabled || !this.#passwordMatches(user, password)) {
  138. throw new HttpError(401, '用户名或密码错误', 'login_failed')
  139. }
  140. return this.#issueSession(ctx, user)
  141. }
  142. issueSession(ctx: Koa.Context, userID: string): LocalUser {
  143. const user = this.#users.find(candidate => candidate.id === userID)
  144. if (!user || !user.enabled) {
  145. throw new HttpError(401, '用户已失效', 'account_pairing_user_unavailable')
  146. }
  147. return this.#issueSession(ctx, user)
  148. }
  149. #issueSession(ctx: Koa.Context, user: StoredUser): LocalUser {
  150. const token = randomBytes(32).toString('base64url')
  151. this.#sessions.set(this.#sessionKey(token), {
  152. userID: user.id,
  153. authVersion: user.authVersion,
  154. expiresAt: Date.now() + SESSION_TTL_SECONDS * 1_000,
  155. })
  156. this.#saveSessions()
  157. appendSetCookies(ctx, [serialize(SESSION_COOKIE, token, {
  158. httpOnly: true,
  159. secure: this.#secureCookie,
  160. sameSite: 'strict',
  161. path: '/',
  162. maxAge: SESSION_TTL_SECONDS,
  163. })])
  164. const published = publicUser(user)
  165. ctx.state.localUser = published
  166. return published
  167. }
  168. logout(ctx: Koa.Context): void {
  169. const token = this.#token(ctx)
  170. if (token) this.#sessions.delete(this.#sessionKey(token))
  171. this.#saveSessions()
  172. appendSetCookies(ctx, [serialize(SESSION_COOKIE, '', {
  173. httpOnly: true, secure: this.#secureCookie, sameSite: 'strict', path: '/', maxAge: 0,
  174. })])
  175. }
  176. current(ctx: Koa.Context): LocalUser | undefined {
  177. const injected = ctx.state.localUser as LocalUser | undefined
  178. if (injected) return injected
  179. return this.currentFromCookieHeader(ctx.get('cookie'))
  180. }
  181. /** Resolve the same authenticated user for non-Koa transports such as WS Upgrade. */
  182. currentFromCookieHeader(cookieHeader: string | undefined): LocalUser | undefined {
  183. const token = parse(cookieHeader ?? '')[SESSION_COOKIE]
  184. if (!token) return undefined
  185. const key = this.#sessionKey(token)
  186. const session = this.#sessions.get(key)
  187. if (!session || session.expiresAt <= Date.now()) {
  188. this.#sessions.delete(key)
  189. this.#saveSessions()
  190. return undefined
  191. }
  192. const user = this.#users.find(candidate => candidate.id === session.userID)
  193. if (!user || !user.enabled || user.authVersion !== session.authVersion) {
  194. this.#sessions.delete(key)
  195. this.#saveSessions()
  196. return undefined
  197. }
  198. return publicUser(user)
  199. }
  200. require(ctx: Koa.Context, allowPasswordChange = false): LocalUser {
  201. const user = this.current(ctx)
  202. if (!user) throw new HttpError(401, '请先登录夭夭', 'authentication_required')
  203. if (user.mustChangePassword && !allowPasswordChange) {
  204. throw new HttpError(428, '必须先修改初始密码', 'password_change_required')
  205. }
  206. return user
  207. }
  208. requireAdmin(ctx: Koa.Context): LocalUser {
  209. const user = this.require(ctx)
  210. if (user.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
  211. return user
  212. }
  213. validateAssignedProfiles(value: unknown): string[] {
  214. if (!Array.isArray(value) || value.length > 256 || value.some(p => typeof p !== 'string' || !p.trim() || p.length > 256 || /[\/\\\u0000-\u001f]/.test(p))) {
  215. throw new HttpError(400, '分配的基础 Agent 无效', 'invalid_assigned_profiles')
  216. }
  217. return [...new Set(value.map(p => p.trim()))]
  218. }
  219. canUseSource(userID: string, nodeId: string, profile: string): boolean {
  220. const user = this.#users.find(candidate => candidate.id === userID && candidate.enabled)
  221. return Boolean(user && (user.role === 'admin' || (nodeId === 'local' && user.assignedProfiles?.includes(profile))))
  222. }
  223. isAdminActive(userID:string):boolean {return this.#users.some(user=>user.id===userID&&user.enabled&&user.role==='admin')}
  224. isUserActive(userID: string): boolean {
  225. return this.#users.some(user => user.id === userID && user.enabled)
  226. }
  227. pushAuthorizationVersion(userID: string): number | undefined {
  228. const user = this.#users.find(candidate => candidate.id === userID && candidate.enabled)
  229. return user?.authVersion
  230. }
  231. list(admin: LocalUser): LocalUser[] {
  232. if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
  233. return this.#users.map(publicUser).sort((a, b) => a.createdAt - b.createdAt)
  234. }
  235. create(admin: LocalUser, usernameValue: string, passwordValue: string, assignedProfiles: unknown = []): LocalUser {
  236. if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
  237. const username = canonicalUsername(usernameValue)
  238. const normalizedUsername = username.toLocaleLowerCase('en-US')
  239. if (this.#users.some(user => user.normalizedUsername === normalizedUsername)) {
  240. throw new HttpError(409, '用户名已存在', 'username_exists')
  241. }
  242. const password = validatePassword(passwordValue)
  243. const now = Date.now()
  244. const user = this.#newUser(username, password, 'user', true, now)
  245. user.assignedProfiles = this.validateAssignedProfiles(assignedProfiles)
  246. this.#users.push(user)
  247. this.#saveUsers()
  248. return publicUser(user)
  249. }
  250. updateUser(admin: LocalUser, userID: string, input: { enabled?: boolean; password?: string; assignedProfiles?: unknown }): LocalUser {
  251. if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
  252. const user = this.#users.find(candidate => candidate.id === userID)
  253. if (!user) throw new HttpError(404, '用户不存在', 'user_not_found')
  254. if (user.role === 'admin') throw new HttpError(409, '管理员账号请在账号设置中修改', 'admin_account_protected')
  255. const assigned = input.assignedProfiles === undefined ? undefined : this.validateAssignedProfiles(input.assignedProfiles)
  256. if (input.password !== undefined) validatePassword(input.password)
  257. if (assigned !== undefined) user.assignedProfiles = assigned
  258. if (typeof input.enabled === 'boolean') user.enabled = input.enabled
  259. if (input.password !== undefined) {
  260. this.#setPassword(user, validatePassword(input.password), true)
  261. } else {
  262. user.authVersion += 1
  263. }
  264. user.updatedAt = Date.now()
  265. this.#saveUsers()
  266. return publicUser(user)
  267. }
  268. deleteUser(admin: LocalUser, userID: string): void {
  269. if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
  270. const user = this.#users.find(candidate => candidate.id === userID)
  271. if (!user) throw new HttpError(404, '用户不存在', 'user_not_found')
  272. if (user.role === 'admin') throw new HttpError(409, '不能删除管理员账号', 'admin_account_protected')
  273. this.#users = this.#users.filter(candidate => candidate.id !== userID)
  274. this.#saveUsers()
  275. this.#revokeUser(userID)
  276. }
  277. changeCredentials(
  278. ctx: Koa.Context,
  279. currentPassword: string,
  280. newPassword: string,
  281. newUsername?: string,
  282. ): LocalUser {
  283. const current = this.require(ctx, true)
  284. const user = this.#users.find(candidate => candidate.id === current.id)!
  285. if (!this.#passwordMatches(user, currentPassword)) {
  286. throw new HttpError(401, '当前密码错误', 'invalid_current_password')
  287. }
  288. const username = newUsername === undefined ? user.username : canonicalUsername(newUsername)
  289. const normalized = username.toLocaleLowerCase('en-US')
  290. if (this.#users.some(candidate => candidate.id !== user.id && candidate.normalizedUsername === normalized)) {
  291. throw new HttpError(409, '用户名已存在', 'username_exists')
  292. }
  293. user.username = username
  294. user.normalizedUsername = normalized
  295. this.#setPassword(user, validatePassword(newPassword), false)
  296. user.mustChangePassword = false
  297. user.updatedAt = Date.now()
  298. this.#saveUsers()
  299. this.#revokeUser(user.id)
  300. return this.login(ctx, username, newPassword)
  301. }
  302. setAvatar(ctx: Koa.Context, value: unknown): LocalUser {
  303. const current = this.require(ctx)
  304. const user = this.#users.find(candidate => candidate.id === current.id)!
  305. if (value !== null && value !== '' && !validAvatarImage(value)) {
  306. throw new HttpError(400, '头像必须是有效的 PNG、JPEG 或 WebP 图片', 'invalid_account_avatar')
  307. }
  308. if (typeof value === 'string' && value) user.avatar = value
  309. else delete user.avatar
  310. user.updatedAt = Date.now()
  311. this.#saveUsers()
  312. const published = publicUser(user)
  313. ctx.state.localUser = published
  314. return published
  315. }
  316. upstreamCredentials(fallback?: UpstreamCredentials): UpstreamCredentials | undefined {
  317. try {
  318. const encoded = Buffer.from(readFileSync(this.#credentialPath, 'utf8').trim(), 'base64url')
  319. if (encoded.byteLength < 29) return fallback
  320. const key = this.#key()
  321. const decipher = createDecipheriv('aes-256-gcm', key, encoded.subarray(0, 12))
  322. decipher.setAuthTag(encoded.subarray(12, 28))
  323. const raw = Buffer.concat([decipher.update(encoded.subarray(28)), decipher.final()]).toString('utf8')
  324. const value = JSON.parse(raw) as UpstreamCredentials
  325. return value.username && value.password ? value : fallback
  326. } catch {
  327. return fallback
  328. }
  329. }
  330. setUpstreamCredentials(admin: LocalUser, credentials: UpstreamCredentials): void {
  331. if (admin.role !== 'admin') throw new HttpError(403, '需要管理员权限', 'admin_required')
  332. this.#writeUpstreamCredentials(credentials)
  333. }
  334. #writeUpstreamCredentials(credentials: UpstreamCredentials): void {
  335. const username = canonicalUsername(credentials.username)
  336. const password = validatePassword(credentials.password, true)
  337. const iv = randomBytes(12)
  338. const cipher = createCipheriv('aes-256-gcm', this.#key(), iv)
  339. const encrypted = Buffer.concat([
  340. iv,
  341. cipher.update(JSON.stringify({ username, password }), 'utf8'),
  342. cipher.final(),
  343. cipher.getAuthTag(),
  344. ])
  345. // Store as IV + auth tag + ciphertext for a stable, inspectable envelope.
  346. const normalized = Buffer.concat([encrypted.subarray(0, 12), encrypted.subarray(-16), encrypted.subarray(12, -16)])
  347. this.#atomicWrite(this.#credentialPath, `${normalized.toString('base64url')}\n`)
  348. }
  349. #token(ctx: Koa.Context): string | undefined {
  350. return parse(ctx.get('cookie') || '')[SESSION_COOKIE]
  351. }
  352. #revokeUser(userID: string): void {
  353. for (const [token, session] of this.#sessions) {
  354. if (session.userID === userID) this.#sessions.delete(token)
  355. }
  356. this.#saveSessions()
  357. }
  358. #passwordMatches(user: StoredUser, password: string): boolean {
  359. try {
  360. const expected = Buffer.from(user.passwordHash, 'base64')
  361. const actual = passwordDigest(password, Buffer.from(user.salt, 'base64'))
  362. return expected.byteLength === actual.byteLength && timingSafeEqual(expected, actual)
  363. } catch {
  364. return false
  365. }
  366. }
  367. #setPassword(user: StoredUser, password: string, mustChange: boolean): void {
  368. const salt = randomBytes(16)
  369. user.salt = salt.toString('base64')
  370. user.passwordHash = passwordDigest(password, salt).toString('base64')
  371. user.mustChangePassword = mustChange
  372. user.authVersion += 1
  373. }
  374. #newUser(username: string, password: string, role: LocalRole, mustChange: boolean, now: number): StoredUser {
  375. const salt = randomBytes(16)
  376. return {
  377. id: randomUUID().toLowerCase(),
  378. username,
  379. normalizedUsername: username.toLocaleLowerCase('en-US'),
  380. role,
  381. enabled: true,
  382. mustChangePassword: mustChange,
  383. salt: salt.toString('base64'),
  384. passwordHash: passwordDigest(password, salt).toString('base64'),
  385. authVersion: 1,
  386. createdAt: now,
  387. updatedAt: now,
  388. }
  389. }
  390. #loadUsers(): StoredUser[] {
  391. try {
  392. const value = JSON.parse(readFileSync(this.#path, 'utf8')) as StoredUsers
  393. if (value.version !== 1 || !Array.isArray(value.users) || value.users.length === 0) throw new Error()
  394. return value.users
  395. } catch (error) {
  396. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
  397. throw new Error('15300 用户库损坏,已停止启动以避免覆盖数据')
  398. }
  399. return []
  400. }
  401. }
  402. #saveUsers(): void {
  403. this.#atomicWrite(this.#path, `${JSON.stringify({ version: 1, users: this.#users }, null, 2)}\n`)
  404. }
  405. #sessionKey(token: string): string {
  406. return createHash('sha256').update(token, 'utf8').digest('base64url')
  407. }
  408. #loadSessions(): void {
  409. try {
  410. const value = JSON.parse(readFileSync(this.#sessionsPath, 'utf8')) as StoredSessions
  411. if (value.version !== 1 || !Array.isArray(value.sessions)) throw new Error('invalid sessions')
  412. const now = Date.now()
  413. for (const session of value.sessions) {
  414. if (session.tokenHash && session.expiresAt > now) {
  415. this.#sessions.set(session.tokenHash, {
  416. userID: session.userID, authVersion: session.authVersion, expiresAt: session.expiresAt,
  417. })
  418. }
  419. }
  420. } catch (error) {
  421. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
  422. // Corrupt sessions are safely discarded; user credentials remain authoritative.
  423. }
  424. }
  425. }
  426. #saveSessions(): void {
  427. const sessions = [...this.#sessions.entries()].map(([tokenHash, value]) => ({ tokenHash, ...value }))
  428. this.#atomicWrite(this.#sessionsPath, `${JSON.stringify({ version: 1, sessions })}\n`)
  429. }
  430. #key(): Buffer {
  431. try {
  432. const key = readFileSync(this.#keyPath)
  433. if (key.byteLength !== 32) throw new Error('invalid key')
  434. return key
  435. } catch (error) {
  436. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  437. const key = randomBytes(32)
  438. this.#atomicWrite(this.#keyPath, key)
  439. return key
  440. }
  441. }
  442. #atomicWrite(path: string, value: string | Buffer): void {
  443. mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
  444. const temporary = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
  445. writeFileSync(temporary, value, { mode: 0o600 })
  446. chmodSync(temporary, 0o600)
  447. renameSync(temporary, path)
  448. }
  449. }
  450. export class UpstreamServiceSession {
  451. readonly #jar = new CookieJar('')
  452. #loginTask: Promise<void> | undefined
  453. #ensureTask: Promise<void> | undefined
  454. #validatedUntil = 0
  455. #credentialFingerprint: string | undefined
  456. #generation = 0
  457. #authMode: 'unknown' | 'loopback-token' | 'loopback-direct' | 'password' = 'unknown'
  458. #lastVerifiedAt: number | undefined
  459. #localAuthorization = false
  460. readonly #now: () => number
  461. readonly #validationTTL: number
  462. constructor(
  463. readonly client: UpstreamClient,
  464. readonly credentials: () => UpstreamCredentials | undefined,
  465. options: { now?: () => number; validationTTL?: number } = {},
  466. ) {
  467. this.#now = options.now ?? Date.now
  468. this.#validationTTL = options.validationTTL ?? 30_000
  469. client.setReauthenticationHandler(this.#jar, async () => {
  470. this.#syncCredentials()
  471. this.#validatedUntil = 0
  472. await this.#renewAuthorization(this.#generation)
  473. })
  474. }
  475. get jar(): CookieJar { return this.#jar }
  476. get authorizationMode(): 'local' | 'account' { return this.#localAuthorization ? 'local' : 'account' }
  477. async webSocketCredential(): Promise<{ name: 'token' | 'ticket'; value: string }> {
  478. await this.ensure()
  479. if (this.#localAuthorization) {
  480. if (!this.#jar.sessionToken) await this.#renewAuthorization(this.#generation)
  481. else {
  482. // A restarted Hermes rotates its process token even within our REST
  483. // validation lease. Validate before a new socket; a 401 renews once.
  484. const check = await this.client.request('/api/profiles', this.#jar, { cache: 'reload' })
  485. if (check.status !== 200) throw new HttpError(502, 'Hermes 本机会话令牌验证失败', 'local_authorization_rejected')
  486. }
  487. if (this.#localAuthorization && this.#jar.sessionToken) return { name: 'token', value: this.#jar.sessionToken }
  488. }
  489. let response = await this.request('/api/auth/ws-ticket', { method: 'POST' })
  490. // Auth endpoints are excluded from generic request retries. Repair an
  491. // expired account cookie or a restarted upstream's changed auth mode here.
  492. if (response.status === 401) {
  493. await this.#renewAuthorization(this.#generation)
  494. if (this.#localAuthorization && this.#jar.sessionToken) return { name: 'token', value: this.#jar.sessionToken }
  495. response = await this.request('/api/auth/ws-ticket', { method: 'POST' })
  496. }
  497. if (this.#localAuthorization && this.#jar.sessionToken) return { name: 'token', value: this.#jar.sessionToken }
  498. if (response.status < 200 || response.status >= 300) throw new HttpError(502, 'Hermes 未签发实时连接凭据', 'upstream_auth_failed')
  499. let value: unknown
  500. try { value = JSON.parse(response.body.toString()).ticket } catch { /* fail closed */ }
  501. if (typeof value !== 'string' || !value.trim()) throw new HttpError(502, 'Hermes 返回了空实时连接凭据', 'invalid_ticket')
  502. return { name: 'ticket', value: value.trim() }
  503. }
  504. connectionInfo(): {
  505. endpoint: string
  506. authMode: 'unknown' | 'loopback-token' | 'loopback-direct' | 'password'
  507. networkScope: 'local' | 'network'
  508. lastVerifiedAt?: number
  509. } {
  510. return {
  511. endpoint: this.client.baseURL.origin,
  512. authMode: this.#authMode,
  513. networkScope: this.#isLoopbackUpstream() ? 'local' : 'network',
  514. lastVerifiedAt: this.#lastVerifiedAt,
  515. }
  516. }
  517. async ensure(): Promise<void> {
  518. this.#syncCredentials()
  519. if (this.#validatedUntil > this.#now()) return
  520. if (this.#ensureTask) return this.#ensureTask
  521. const generation = this.#generation
  522. const task = this.#validate(generation).then(() => {
  523. this.#checkGeneration(generation)
  524. this.#lastVerifiedAt = this.#now()
  525. this.#validatedUntil = this.#lastVerifiedAt + this.#validationTTL
  526. }).finally(() => { if (this.#ensureTask === task) this.#ensureTask = undefined })
  527. this.#ensureTask = task
  528. return task
  529. }
  530. invalidateAuthentication(): void { this.#validatedUntil = 0; this.client.invalidateReads() }
  531. #syncCredentials(): void {
  532. const fingerprint = createHash('sha256').update(JSON.stringify(this.credentials() ?? null)).digest('hex')
  533. if (this.#credentialFingerprint !== undefined && fingerprint !== this.#credentialFingerprint) {
  534. this.#generation++
  535. this.#validatedUntil = 0; this.#ensureTask = undefined; this.#loginTask = undefined
  536. this.#jar.replace(new CookieJar())
  537. this.#authMode = 'unknown'
  538. this.#localAuthorization = false
  539. this.#lastVerifiedAt = undefined
  540. this.client.invalidateReads()
  541. }
  542. this.#credentialFingerprint = fingerprint
  543. }
  544. #checkGeneration(generation: number): void {
  545. this.#syncCredentials()
  546. if (generation !== this.#generation) throw new HttpError(409, '上游凭据已变化,请重试读取', 'upstream_credentials_changed')
  547. }
  548. async #validate(generation: number): Promise<void> {
  549. // Probe into a temporary jar. A delayed old probe must not overwrite freshly
  550. // rotated service credentials/cookies in the live jar.
  551. const probe = new CookieJar(this.#jar.header)
  552. probe.setSessionToken(this.#jar.sessionToken)
  553. const status = await this.client.request('/api/status', probe)
  554. if (status.status < 200 || status.status >= 300) throw new HttpError(502, '9119 状态不可用', 'upstream_unavailable')
  555. const parsed = JSON.parse(status.body.toString('utf8')) as Record<string, unknown>
  556. this.#checkGeneration(generation)
  557. if (allowsLocalAuthorization(parsed)) {
  558. this.#requireLocalTarget()
  559. const authMode = await this.#loadLoopbackToken(probe)
  560. ? 'loopback-token'
  561. : 'loopback-direct'
  562. this.#checkGeneration(generation)
  563. this.#authMode = authMode
  564. this.#localAuthorization = true
  565. this.#jar.replace(probe)
  566. return
  567. }
  568. this.#authMode = 'password'
  569. this.#localAuthorization = false
  570. probe.setSessionToken(undefined)
  571. const me = await this.client.request('/api/auth/me', probe)
  572. this.#checkGeneration(generation)
  573. if (me.status >= 200 && me.status < 300) {
  574. this.#jar.replace(probe)
  575. return
  576. }
  577. if (me.status !== 401 && me.status !== 403) throw new HttpError(502, '无法验证 9119 服务会话', 'upstream_auth_unavailable')
  578. await this.#login(generation)
  579. }
  580. async request(path: string, options: UpstreamRequestOptions = {}): Promise<UpstreamResponse> {
  581. await this.ensure()
  582. return this.client.request(path, this.#jar, options)
  583. }
  584. async verify(credentials: UpstreamCredentials): Promise<void> {
  585. await this.#authenticate(credentials, new CookieJar(''))
  586. }
  587. async #login(generation: number): Promise<void> {
  588. if (this.#loginTask) return this.#loginTask
  589. const credentials = this.credentials()
  590. if (!credentials) throw new HttpError(503, '9119 当前启用了账号鉴权,请配置服务账号;本机授权需由回环 9119 明确启用', 'upstream_credentials_required')
  591. const jar = new CookieJar()
  592. const task = this.#authenticate({ ...credentials }, jar).then(() => {
  593. this.#checkGeneration(generation)
  594. this.#jar.replace(jar)
  595. this.#authMode = 'password'
  596. this.#localAuthorization = false
  597. this.client.invalidateReads()
  598. this.#lastVerifiedAt = this.#now()
  599. this.#validatedUntil = this.#lastVerifiedAt + this.#validationTTL
  600. }).finally(() => { if (this.#loginTask === task) this.#loginTask = undefined })
  601. this.#loginTask = task
  602. return task
  603. }
  604. #requireLocalTarget(): void {
  605. if (!isLocalAuthorizationTarget(this.client.baseURL)) throw new HttpError(403, '免账号本机授权仅支持直连 127.0.0.1 或 ::1;远程上游必须启用鉴权', 'loopback_auth_required')
  606. }
  607. async #renewAuthorization(generation: number): Promise<void> {
  608. if (this.#loginTask) return this.#loginTask
  609. const probe = new CookieJar()
  610. const status = await this.client.request('/api/status', probe)
  611. if (status.status !== 200) throw new HttpError(502, '无法检查 9119 授权模式', 'upstream_auth_unavailable')
  612. const mode = JSON.parse(status.body.toString()) as Record<string, unknown>
  613. this.#checkGeneration(generation)
  614. if (this.#loginTask) return this.#loginTask
  615. if (!allowsLocalAuthorization(mode)) return this.#login(generation)
  616. this.#requireLocalTarget()
  617. const task = (async () => {
  618. const page = await this.client.request('/', probe, { maxResponseBytes: 2 * 1024 * 1024, cache: 'reload' })
  619. if (page.status !== 200) throw new HttpError(502, 'Hermes 本机授权入口不可用', 'local_authorization_unavailable')
  620. const token = localSessionToken(page.body.toString('utf8'))
  621. if (!token) throw new HttpError(502, 'Hermes 未提供本机会话令牌;不会降级或自动修改账号配置', 'local_authorization_unavailable')
  622. probe.setSessionToken(token)
  623. const check = await this.client.request('/api/profiles', probe, { cache: 'reload' })
  624. if (check.status !== 200) throw new HttpError(502, 'Hermes 本机会话令牌验证失败', 'local_authorization_rejected')
  625. this.#checkGeneration(generation)
  626. this.#jar.replace(probe); this.#localAuthorization = true
  627. this.#authMode = 'loopback-token'
  628. this.client.invalidateReads()
  629. this.#lastVerifiedAt = this.#now()
  630. this.#validatedUntil = this.#lastVerifiedAt + this.#validationTTL
  631. })().finally(() => { if (this.#loginTask === task) this.#loginTask = undefined })
  632. this.#loginTask = task
  633. return task
  634. }
  635. async #authenticate(credentials: UpstreamCredentials, jar: CookieJar): Promise<void> {
  636. const providers = await this.client.request('/api/auth/providers', jar)
  637. if (providers.status < 200 || providers.status >= 300) throw new HttpError(502, '无法读取 9119 登录方式', 'upstream_auth_unavailable')
  638. const response = await this.client.request('/auth/password-login', jar, {
  639. method: 'POST',
  640. body: { provider: 'basic', username: credentials.username, password: credentials.password, next: '' },
  641. })
  642. if (response.status < 200 || response.status >= 300) {
  643. throw new HttpError(503, '9119 服务账号验证失败', 'upstream_credentials_invalid')
  644. }
  645. }
  646. #isLoopbackUpstream(): boolean {
  647. const hostname = this.client.baseURL.hostname.toLowerCase().replace(/^\[|\]$/g, '')
  648. return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
  649. }
  650. async #loadLoopbackToken(jar: CookieJar): Promise<boolean> {
  651. this.#requireLocalTarget()
  652. jar.setSessionToken(undefined)
  653. const response = await this.client.request('/', jar, {
  654. headers: { accept: 'text/html' },
  655. maxResponseBytes: 2 * 1_024 * 1_024,
  656. cache: 'reload',
  657. })
  658. if (response.status < 200 || response.status >= 300) return false
  659. const token = localSessionToken(response.body.toString('utf8'))
  660. if (!token) return false
  661. jar.setSessionToken(token)
  662. const check = await this.client.request('/api/profiles', jar, { cache: 'reload' })
  663. if (check.status !== 200) throw new HttpError(502, 'Hermes 本机会话令牌验证失败', 'local_authorization_rejected')
  664. return true
  665. }
  666. }