Passwordless sign-in: 6-digit email codes, feature-flagged on SendGrid key
/api/auth/email/start issues a 15-min code (60s resend guard, 6 tries); verify creates the account passwordless (sponsor cookie first-touch) and mints the session. UI swaps the password cards for the code flow when config.emailAuth is on; dev mode returns the code inline. Password flow remains until the key lands in the volume (data/sendgrid.key) or SENDGRID_KEY env. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ const chain = require('./chain');
|
||||
const auth = require('./auth');
|
||||
const accounts = require('./accounts');
|
||||
const ads = require('./ads');
|
||||
const mailer = require('./mailer');
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const ROOT = __dirname;
|
||||
@@ -26,6 +27,9 @@ chain.init({ onEvent: ev => pushFeed(ev) });
|
||||
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
|
||||
accounts.init({ dataDir: DATA_DIR });
|
||||
ads.init({ dataDir: DATA_DIR, chain });
|
||||
mailer.init({ dataDir: DATA_DIR });
|
||||
// magic-code sign-in: emailLower -> {code, exp, tries}
|
||||
const emailCodes = new Map();
|
||||
setTimeout(() => ads.dailySweep(), 60 * 1000);
|
||||
setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges
|
||||
|
||||
@@ -112,7 +116,8 @@ const server = http.createServer(async (req, res) => {
|
||||
if (p === '/api/config' && req.method === 'GET') {
|
||||
const c = chain.getConfig();
|
||||
return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId,
|
||||
chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0] }, siteConfig()));
|
||||
chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0],
|
||||
emailAuth: mailer.hasKey() || !IS_PROD }, siteConfig()));
|
||||
}
|
||||
if (p === '/api/catalog' && req.method === 'GET') {
|
||||
return json(res, 200, { products: await chain.catalog() });
|
||||
@@ -158,6 +163,43 @@ const server = http.createServer(async (req, res) => {
|
||||
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
|
||||
}
|
||||
|
||||
// -- passwordless: email code sign-in (signup and login are the same act)
|
||||
if (p === '/api/auth/email/start' && req.method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
const e = String(b.email || '').trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
|
||||
const prev = emailCodes.get(e);
|
||||
if (prev && Date.now() < prev.nextAt) return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' });
|
||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
emailCodes.set(e, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 });
|
||||
if (mailer.hasKey()) {
|
||||
try { await mailer.sendCode(e, code); } catch (err) {
|
||||
console.error('sendCode failed', err.message);
|
||||
return json(res, 502, { error: 'Could not send the email. Try again in a minute.' });
|
||||
}
|
||||
return json(res, 200, { ok: true, sent: true });
|
||||
}
|
||||
if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code });
|
||||
return json(res, 503, { error: 'Email sign-in is not configured yet.' });
|
||||
}
|
||||
if (p === '/api/auth/email/verify' && req.method === 'POST') {
|
||||
const b = await readBody(req);
|
||||
const e = String(b.email || '').trim().toLowerCase();
|
||||
const rec = emailCodes.get(e);
|
||||
if (!rec || rec.exp < Date.now()) return json(res, 400, { error: 'Code expired. Request a fresh one.' });
|
||||
rec.tries += 1;
|
||||
if (rec.tries > 6) { emailCodes.delete(e); return json(res, 400, { error: 'Too many tries. Request a fresh code.' }); }
|
||||
if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' });
|
||||
emailCodes.delete(e);
|
||||
const sid = Number(parseCookies(req)['iap.sponsor']) || 0;
|
||||
const r = accounts.ensure(e, sid); // first touch wins; existing accounts unchanged
|
||||
if (r.error) return json(res, 400, r);
|
||||
let memberId = 0;
|
||||
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} }
|
||||
const token = auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
|
||||
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
|
||||
}
|
||||
|
||||
// -- wallet auth: link-to-account when an email session exists, or
|
||||
// wallet-first sign-in for crypto-native users
|
||||
if (p === '/api/auth/challenge' && req.method === 'POST') {
|
||||
|
||||
Reference in New Issue
Block a user