localAuth.ts 27 KB

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