ident
All repositories: gitoria
8.0 KB
// lib/login.hl — THE LOGIN to ident (piece 1, ticket #24): email → a six-digit code by mail → signed in. The// code tables and every write to them, the limits (per address, per client IP), the check of a code (the first// right one creates the account), and the PENDING sign-in of the code page (ident#20). The code and IP-bucket// helpers: lib/login-helpers.hl. Statics only, the server realm.//// otpTable pk @id index email a pending one-time login code:// { email, hash = sha256(email:code), expires, tries } (one per email)// sendsTable pk @id index email one row per code mailed: { email, at } (rate limit)// ipSendsTable pk @id index ip one row per code mailed: { ip (the client's BUCKET), at }// (the per-IP limit, mission 006 (old 010))//// Codes are never stored in clear, only their sha256.import { MPackDB } from 'hl:mpackdb'import { now } from 'hl:time'import { dir, envNumber, countOf, first, merged, normEmail, validEmail } from './util.hl'import { newOtp, otpHash, ipBucket } from './login-helpers.hl'import { accountByEmail, createAccount, beginSession } from './accounts.hl'import { identityRows } from './identities.hl'static otpTable = new MPackDB(file = dir + '/otp.db', primaryKey = '@id', indexes = ['email'])static sendsTable = new MPackDB(file = dir + '/sends.db', primaryKey = '@id', indexes = ['email'])static ipSendsTable = new MPackDB(file = dir + '/ipsends.db', primaryKey = '@id', indexes = ['ip'])// THE CLOCKS AND LIMITS (ms). The env names exist for the gate's short-clock server.static otpTtl = envNumber('IDENT_OTP_TTL_MS', 600000) // a login code: 10 minstatic maxTries = 5 // wrong codes before it diesstatic sendWindow = envNumber('IDENT_SEND_WINDOW_MS', 600000) // rate limit window: 10 minstatic sendLimit = envNumber('IDENT_SEND_LIMIT', 3) // codes per email per window// THE PER-IP LIMIT (mission 006 (old 010)): codes mailed per client IP BUCKET (login-helpers.hl ipBucket) —// 10 per 10 min and 30 per 24 h, so one client cannot use ident to mail many addressesstatic ipWindow = envNumber('IDENT_IP_WINDOW_MS', 600000) // short window: 10 minstatic ipLimit = envNumber('IDENT_IP_LIMIT', 10) // codes per IP per short windowstatic ipDayWindow = envNumber('IDENT_IP_DAY_WINDOW_MS', 86400000) // long window: 24 hstatic ipDayLimit = envNumber('IDENT_IP_DAY_LIMIT', 30) // codes per IP per long window// how many codes went to this bucket in the short and in the long window (and the rows// older than the long window are removed on the way)static ipCounts = (bucket, t0) => {let inWindow = 0let inDay = 0rows = ipSendsTable.find('ip', bucket)if (countOf(rows) > 0) {for (s of rows) {if (s.at > t0 - ipDayWindow) {inDay = inDay + 1if (s.at > t0 - ipWindow) { inWindow = inWindow + 1 }} else {ipSendsTable.delete(s.id)}}}return { inWindow = inWindow inDay = inDay }}// ---- step 1: ask for a code ---------------------------------------------------------// answers { error, limited } or { email, code } — the caller mails the code. The answer// does not say whether the address has an account (the first login creates it).// `ip` is the client's IP (the X-Client-IP header, or null — see ipBucket). `limited` is// true when a limit refused it (the route answers 429 then).static startLogin = (email, ip) => {e = normEmail(email)if (!validEmail(e)) { return { error = 'that is not an email address' } }t0 = now()// THE PER-IP LIMIT first: one client, many addressesbucket = ipBucket(ip)ipc = ipCounts(bucket, t0)if (ipc.inWindow >= ipLimit || ipc.inDay >= ipDayLimit) {return { error = 'too many codes were requested from your network — wait a while and try again' limited = true }}let recent = 0sends = sendsTable.find('email', e)if (countOf(sends) > 0) {for (s of sends) {if (s.at > t0 - sendWindow) { recent = recent + 1 } else { sendsTable.delete(s.id) }}}if (recent >= sendLimit) {return { error = 'too many codes were sent to this address — wait a few minutes and try again' limited = true }}olds = otpTable.find('email', e)if (countOf(olds) > 0) { for (old of olds) { otpTable.delete(old.id) } }let code = newOtp()otpTable.put({ email = e hash = otpHash(e, code) expires = t0 + otpTtl tries = 0 })sendsTable.put({ email = e at = t0 })ipSendsTable.put({ ip = bucket at = t0 })return { email = e code = code }}// ---- step 2: check it — and on the FIRST login create the account -------------------// answers { error } or { account, created }. `timeZone` is the browser's (first use);// a missing or malformed one is stored as UTC, it can be changed in ident.static checkCode = (email, code, timeZone) => {e = normEmail(email)c = code == null ? '' : ('' + code).trim()p = first(otpTable.find('email', e))if (p == null) { return { error = 'no code is waiting for this address — ask for a new one' } }if (p.expires < now()) {otpTable.delete(p.id)return { error = 'the code expired — ask for a new one' }}if (otpHash(e, c) != p.hash) {if (p.tries + 1 >= maxTries) {otpTable.delete(p.id)return { error = 'too many wrong codes — ask for a new one' }}otpTable.update(p.id, merged(p, { tries = p.tries + 1 }))return { error = 'wrong code (' + (maxTries - p.tries - 1) + ' tries left)' }}otpTable.delete(p.id)known = accountByEmail(e)if (known != null) { return { account = known created = false } }return { account = createAccount(e, timeZone) created = true }}// ---- THE PENDING SIGN-IN (ticket ident#20, mission 008 (old 032)) ------------------------------// After "Send me a code" the browser's SESSION remembers the address, so a reload (a phone// reloading the tab while the user reads the mail, a second tab, a socket re-seed) still// shows the code step. It lives in `session.data.pendingEmail` (the app's hybrid on the// Session — an undeclared member of the instance would not read back, see session.hl).// It is ONLY honoured while a code for that address is really waiting: in otpTable, not// expired, not used, not killed by too many wrong tries — so it never shows a code step// for an address no code was sent to. It holds no secret (the code is only in the mail).// Cleared on: a successful sign-in (signInWithCode), "Other address" (dropPending), and// expiry / a dead code (pendingOf drops it the next time it reads it).static codeWaiting = (email) => {e = normEmail(email)if (e == '') { return false }p = first(otpTable.find('email', e))return p != null && p.expires >= now() && p.tries < maxTries}// the face rememberPending (home.hl): { email } or { error }static recordPending = (&session, email) => {if (session == null) { return { error = 'no session — reload the page' } }if (email == null || hlTypeName(email) != 'String') { return { error = 'field email must be a string' } }e = normEmail(email)if (!codeWaiting(e)) { return { error = 'no code is waiting for this address' } }session.data.pendingEmail = ereturn { email = e }}// the address whose code step this session shows, or null (and a stale one is dropped)static pendingOf = (&session) => {if (session == null || session.data == null) { return null }e = session.data.pendingEmailif (e == null || e == '') { return null }if (!codeWaiting(e)) {session.data.pendingEmail = nullreturn null}return e}static dropPending = (&session) => {if (session != null && session.data != null) { session.data.pendingEmail = null }return true}// the face verifyCode (components/home.hl): the right code signs the session in and ends its pending sign-in.// Answers { error } or { account, created, identities }.static signInWithCode = (&session, email, code, timeZone) => {r = checkCode(email, code, timeZone)if (r.error != null) { return { error = r.error } }beginSession(session, r.account.id)dropPending(session)return { account = r.account created = r.created identities = identityRows(r.account.id) }}
Branches
- mainmain branch
Latest commits
- 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