gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit5fdbb6b25fdbb6b2ident mission 009: report — scratch folder notemre5fdbb6b2/lib/login.hl

8.0 KB

  1. // lib/login.hl — THE LOGIN to ident (piece 1, ticket #24): email → a six-digit code by mail → signed in. The
  2. // code tables and every write to them, the limits (per address, per client IP), the check of a code (the first
  3. // right one creates the account), and the PENDING sign-in of the code page (ident#20). The code and IP-bucket
  4. // helpers: lib/login-helpers.hl. Statics only, the server realm.
  5. //
  6. // otpTable pk @id index email a pending one-time login code:
  7. // { email, hash = sha256(email:code), expires, tries } (one per email)
  8. // sendsTable pk @id index email one row per code mailed: { email, at } (rate limit)
  9. // ipSendsTable pk @id index ip one row per code mailed: { ip (the client's BUCKET), at }
  10. // (the per-IP limit, mission 006 (old 010))
  11. //
  12. // Codes are never stored in clear, only their sha256.
  13. import { MPackDB } from 'hl:mpackdb'
  14. import { now } from 'hl:time'
  15. import { dir, envNumber, countOf, first, merged, normEmail, validEmail } from './util.hl'
  16. import { newOtp, otpHash, ipBucket } from './login-helpers.hl'
  17. import { accountByEmail, createAccount, beginSession } from './accounts.hl'
  18. import { identityRows } from './identities.hl'
  19. static otpTable = new MPackDB(file = dir + '/otp.db', primaryKey = '@id', indexes = ['email'])
  20. static sendsTable = new MPackDB(file = dir + '/sends.db', primaryKey = '@id', indexes = ['email'])
  21. static ipSendsTable = new MPackDB(file = dir + '/ipsends.db', primaryKey = '@id', indexes = ['ip'])
  22. // THE CLOCKS AND LIMITS (ms). The env names exist for the gate's short-clock server.
  23. static otpTtl = envNumber('IDENT_OTP_TTL_MS', 600000) // a login code: 10 min
  24. static maxTries = 5 // wrong codes before it dies
  25. static sendWindow = envNumber('IDENT_SEND_WINDOW_MS', 600000) // rate limit window: 10 min
  26. static sendLimit = envNumber('IDENT_SEND_LIMIT', 3) // codes per email per window
  27. // THE PER-IP LIMIT (mission 006 (old 010)): codes mailed per client IP BUCKET (login-helpers.hl ipBucket) —
  28. // 10 per 10 min and 30 per 24 h, so one client cannot use ident to mail many addresses
  29. static ipWindow = envNumber('IDENT_IP_WINDOW_MS', 600000) // short window: 10 min
  30. static ipLimit = envNumber('IDENT_IP_LIMIT', 10) // codes per IP per short window
  31. static ipDayWindow = envNumber('IDENT_IP_DAY_WINDOW_MS', 86400000) // long window: 24 h
  32. static ipDayLimit = envNumber('IDENT_IP_DAY_LIMIT', 30) // codes per IP per long window
  33. // how many codes went to this bucket in the short and in the long window (and the rows
  34. // older than the long window are removed on the way)
  35. static ipCounts = (bucket, t0) => {
  36. let inWindow = 0
  37. let inDay = 0
  38. rows = ipSendsTable.find('ip', bucket)
  39. if (countOf(rows) > 0) {
  40. for (s of rows) {
  41. if (s.at > t0 - ipDayWindow) {
  42. inDay = inDay + 1
  43. if (s.at > t0 - ipWindow) { inWindow = inWindow + 1 }
  44. } else {
  45. ipSendsTable.delete(s.id)
  46. }
  47. }
  48. }
  49. return { inWindow = inWindow inDay = inDay }
  50. }
  51. // ---- step 1: ask for a code ---------------------------------------------------------
  52. // answers { error, limited } or { email, code } — the caller mails the code. The answer
  53. // does not say whether the address has an account (the first login creates it).
  54. // `ip` is the client's IP (the X-Client-IP header, or null — see ipBucket). `limited` is
  55. // true when a limit refused it (the route answers 429 then).
  56. static startLogin = (email, ip) => {
  57. e = normEmail(email)
  58. if (!validEmail(e)) { return { error = 'that is not an email address' } }
  59. t0 = now()
  60. // THE PER-IP LIMIT first: one client, many addresses
  61. bucket = ipBucket(ip)
  62. ipc = ipCounts(bucket, t0)
  63. if (ipc.inWindow >= ipLimit || ipc.inDay >= ipDayLimit) {
  64. return { error = 'too many codes were requested from your network — wait a while and try again' limited = true }
  65. }
  66. let recent = 0
  67. sends = sendsTable.find('email', e)
  68. if (countOf(sends) > 0) {
  69. for (s of sends) {
  70. if (s.at > t0 - sendWindow) { recent = recent + 1 } else { sendsTable.delete(s.id) }
  71. }
  72. }
  73. if (recent >= sendLimit) {
  74. return { error = 'too many codes were sent to this address — wait a few minutes and try again' limited = true }
  75. }
  76. olds = otpTable.find('email', e)
  77. if (countOf(olds) > 0) { for (old of olds) { otpTable.delete(old.id) } }
  78. let code = newOtp()
  79. otpTable.put({ email = e hash = otpHash(e, code) expires = t0 + otpTtl tries = 0 })
  80. sendsTable.put({ email = e at = t0 })
  81. ipSendsTable.put({ ip = bucket at = t0 })
  82. return { email = e code = code }
  83. }
  84. // ---- step 2: check it — and on the FIRST login create the account -------------------
  85. // answers { error } or { account, created }. `timeZone` is the browser's (first use);
  86. // a missing or malformed one is stored as UTC, it can be changed in ident.
  87. static checkCode = (email, code, timeZone) => {
  88. e = normEmail(email)
  89. c = code == null ? '' : ('' + code).trim()
  90. p = first(otpTable.find('email', e))
  91. if (p == null) { return { error = 'no code is waiting for this address — ask for a new one' } }
  92. if (p.expires < now()) {
  93. otpTable.delete(p.id)
  94. return { error = 'the code expired — ask for a new one' }
  95. }
  96. if (otpHash(e, c) != p.hash) {
  97. if (p.tries + 1 >= maxTries) {
  98. otpTable.delete(p.id)
  99. return { error = 'too many wrong codes — ask for a new one' }
  100. }
  101. otpTable.update(p.id, merged(p, { tries = p.tries + 1 }))
  102. return { error = 'wrong code (' + (maxTries - p.tries - 1) + ' tries left)' }
  103. }
  104. otpTable.delete(p.id)
  105. known = accountByEmail(e)
  106. if (known != null) { return { account = known created = false } }
  107. return { account = createAccount(e, timeZone) created = true }
  108. }
  109. // ---- THE PENDING SIGN-IN (ticket ident#20, mission 008 (old 032)) ------------------------------
  110. // After "Send me a code" the browser's SESSION remembers the address, so a reload (a phone
  111. // reloading the tab while the user reads the mail, a second tab, a socket re-seed) still
  112. // shows the code step. It lives in `session.data.pendingEmail` (the app's hybrid on the
  113. // Session — an undeclared member of the instance would not read back, see session.hl).
  114. // It is ONLY honoured while a code for that address is really waiting: in otpTable, not
  115. // expired, not used, not killed by too many wrong tries — so it never shows a code step
  116. // for an address no code was sent to. It holds no secret (the code is only in the mail).
  117. // Cleared on: a successful sign-in (signInWithCode), "Other address" (dropPending), and
  118. // expiry / a dead code (pendingOf drops it the next time it reads it).
  119. static codeWaiting = (email) => {
  120. e = normEmail(email)
  121. if (e == '') { return false }
  122. p = first(otpTable.find('email', e))
  123. return p != null && p.expires >= now() && p.tries < maxTries
  124. }
  125. // the face rememberPending (home.hl): { email } or { error }
  126. static recordPending = (&session, email) => {
  127. if (session == null) { return { error = 'no session — reload the page' } }
  128. if (email == null || hlTypeName(email) != 'String') { return { error = 'field email must be a string' } }
  129. e = normEmail(email)
  130. if (!codeWaiting(e)) { return { error = 'no code is waiting for this address' } }
  131. session.data.pendingEmail = e
  132. return { email = e }
  133. }
  134. // the address whose code step this session shows, or null (and a stale one is dropped)
  135. static pendingOf = (&session) => {
  136. if (session == null || session.data == null) { return null }
  137. e = session.data.pendingEmail
  138. if (e == null || e == '') { return null }
  139. if (!codeWaiting(e)) {
  140. session.data.pendingEmail = null
  141. return null
  142. }
  143. return e
  144. }
  145. static dropPending = (&session) => {
  146. if (session != null && session.data != null) { session.data.pendingEmail = null }
  147. return true
  148. }
  149. // the face verifyCode (components/home.hl): the right code signs the session in and ends its pending sign-in.
  150. // Answers { error } or { account, created, identities }.
  151. static signInWithCode = (&session, email, code, timeZone) => {
  152. r = checkCode(email, code, timeZone)
  153. if (r.error != null) { return { error = r.error } }
  154. beginSession(session, r.account.id)
  155. dropPending(session)
  156. return { account = r.account created = r.created identities = identityRows(r.account.id) }
  157. }

Branches

Latest commits

  • 5fdbb6b2ident mission 009: report — scratch folder notemre
  • 74235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre
  • fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
  • d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
  • 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
  • f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
  • ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
  • a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
  • 98226b41antcolony#40: mission references point to the moved missionsmre
  • ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
  • 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
  • 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre