localAuth.ts 31 KB

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