accountPairing.ts 2.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
  2. import { HttpError } from './errors.js'
  3. const PAIRING_TTL_MS = 2 * 60 * 1_000
  4. const MAX_PENDING_PAIRINGS = 32
  5. interface PendingAccountPairing {
  6. id: string
  7. userID: string
  8. secretHash: Buffer
  9. expiresAt: number
  10. claimed: boolean
  11. }
  12. export interface AccountPairingSession {
  13. id: string
  14. secret: string
  15. expiresAt: number
  16. }
  17. function canonicalPairingID(value: string): string {
  18. const normalized = value.trim().toLowerCase()
  19. if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
  20. throw new HttpError(400, '登录配对码无效', 'invalid_account_pairing')
  21. }
  22. return normalized
  23. }
  24. function digest(id: string, secret: string): Buffer {
  25. return createHash('sha256').update(`${id}:${secret}`, 'utf8').digest()
  26. }
  27. export class AccountLoginPairingStore {
  28. readonly #pending = new Map<string, PendingAccountPairing>()
  29. readonly #clock: () => number
  30. constructor(clock: () => number = Date.now) {
  31. this.#clock = clock
  32. }
  33. create(userID: string): AccountPairingSession {
  34. this.#prune()
  35. while (this.#pending.size >= MAX_PENDING_PAIRINGS) {
  36. const oldest = this.#pending.keys().next().value as string | undefined
  37. if (!oldest) break
  38. this.#pending.delete(oldest)
  39. }
  40. const id = randomUUID().toLowerCase()
  41. const secret = randomBytes(32).toString('base64url')
  42. const expiresAt = this.#clock() + PAIRING_TTL_MS
  43. this.#pending.set(id, {
  44. id, userID, secretHash: digest(id, secret), expiresAt, claimed: false,
  45. })
  46. return { id, secret, expiresAt }
  47. }
  48. status(pairingID: string): { state: 'pending' | 'claimed' | 'expired' } {
  49. const id = canonicalPairingID(pairingID)
  50. const pairing = this.#pending.get(id)
  51. if (!pairing || pairing.expiresAt <= this.#clock()) {
  52. this.#pending.delete(id)
  53. return { state: 'expired' }
  54. }
  55. return { state: pairing.claimed ? 'claimed' : 'pending' }
  56. }
  57. claim(pairingID: string, secret: string): string {
  58. this.#prune()
  59. const id = canonicalPairingID(pairingID)
  60. const pairing = this.#pending.get(id)
  61. if (!pairing || pairing.expiresAt <= this.#clock()) {
  62. this.#pending.delete(id)
  63. throw new HttpError(410, '登录配对码已过期', 'account_pairing_expired')
  64. }
  65. if (pairing.claimed) {
  66. throw new HttpError(409, '登录配对码已使用', 'account_pairing_already_used')
  67. }
  68. const supplied = digest(id, secret)
  69. if (supplied.byteLength !== pairing.secretHash.byteLength
  70. || !timingSafeEqual(supplied, pairing.secretHash)) {
  71. throw new HttpError(401, '登录配对密钥无效', 'invalid_account_pairing_secret')
  72. }
  73. pairing.claimed = true
  74. return pairing.userID
  75. }
  76. #prune(): void {
  77. const now = this.#clock()
  78. for (const [id, pairing] of this.#pending) {
  79. if (pairing.expiresAt <= now) this.#pending.delete(id)
  80. }
  81. }
  82. }