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:
+13
-1
@@ -68,6 +68,18 @@ function login(email, password) {
|
|||||||
acct.lastSeen = Date.now(); save();
|
acct.lastSeen = Date.now(); save();
|
||||||
return { ok: true, account: publicView(acct) };
|
return { ok: true, account: publicView(acct) };
|
||||||
}
|
}
|
||||||
|
// Passwordless path: a verified email code proves ownership, so the account
|
||||||
|
// may exist with no password at all.
|
||||||
|
function ensure(email, sponsorId) {
|
||||||
|
const e = normEmail(email);
|
||||||
|
if (!EMAIL_RE.test(e)) return { error: 'That email address does not look right.' };
|
||||||
|
if (!db.byEmail[e]) {
|
||||||
|
db.byEmail[e] = { email: e, pass: null, sponsorId: Number(sponsorId) || 0, address: null, created: Date.now() };
|
||||||
|
db.joins += 1;
|
||||||
|
save();
|
||||||
|
}
|
||||||
|
return { ok: true, account: publicView(db.byEmail[e]) };
|
||||||
|
}
|
||||||
function byEmail(email) { const a = db.byEmail[normEmail(email)]; return a ? publicView(a) : null; }
|
function byEmail(email) { const a = db.byEmail[normEmail(email)]; return a ? publicView(a) : null; }
|
||||||
function byAddress(address) {
|
function byAddress(address) {
|
||||||
const e = db.byAddress[normAddr(address)];
|
const e = db.byAddress[normAddr(address)];
|
||||||
@@ -96,4 +108,4 @@ function publicView(a) {
|
|||||||
}
|
}
|
||||||
function count() { return Object.keys(db.byEmail).length; }
|
function count() { return Object.keys(db.byEmail).length; }
|
||||||
|
|
||||||
module.exports = { init, signup, login, byEmail, byAddress, linkWallet, count };
|
module.exports = { init, signup, login, ensure, byEmail, byAddress, linkWallet, count };
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Outbound mail via SendGrid v3 (domain-authenticated instantadpay.com).
|
||||||
|
// Key sources: SENDGRID_KEY env, else DATA_DIR/sendgrid.key in the volume.
|
||||||
|
// No key = email sign-in stays feature-flagged off in production.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
let DATA_DIR = null;
|
||||||
|
const FROM = { email: 'no-reply@instantadpay.com', name: 'InstantAdPay' };
|
||||||
|
|
||||||
|
function init(opts) { DATA_DIR = opts.dataDir; }
|
||||||
|
function key() {
|
||||||
|
if (process.env.SENDGRID_KEY) return process.env.SENDGRID_KEY.trim();
|
||||||
|
try { return fs.readFileSync(path.join(DATA_DIR, 'sendgrid.key'), 'utf8').trim(); } catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
function hasKey() { return !!key(); }
|
||||||
|
|
||||||
|
function send(to, subject, text) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
personalizations: [{ to: [{ email: to }] }],
|
||||||
|
from: FROM,
|
||||||
|
subject,
|
||||||
|
content: [{ type: 'text/plain', value: text }]
|
||||||
|
});
|
||||||
|
const req = https.request({ hostname: 'api.sendgrid.com', path: '/v3/mail/send', method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + key(), 'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body) }, timeout: 15000 },
|
||||||
|
res => {
|
||||||
|
let d = '';
|
||||||
|
res.on('data', c => d += c);
|
||||||
|
res.on('end', () => res.statusCode < 300 ? resolve(true) : reject(new Error('sendgrid ' + res.statusCode + ': ' + d.slice(0, 200))));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => req.destroy(new Error('sendgrid timeout')));
|
||||||
|
req.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCode(to, code) {
|
||||||
|
return send(to, code + ' is your InstantAdPay sign-in code',
|
||||||
|
'Your sign-in code is: ' + code + '\n\n'
|
||||||
|
+ 'It works for 15 minutes. If you did not request it, ignore this email.\n\n'
|
||||||
|
+ 'InstantAdPay\nAdvertise and earn instantly. Locked in code, not promises.\nhttps://instantadpay.com');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, hasKey, send, sendCode };
|
||||||
@@ -120,6 +120,35 @@
|
|||||||
finally { btn.disabled = false; }
|
finally { btn.disabled = false; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// passwordless (feature-flagged on config.emailAuth): code replaces passwords
|
||||||
|
(async () => {
|
||||||
|
const cfg = await IAP.getConfig();
|
||||||
|
if (!cfg.emailAuth) return;
|
||||||
|
$('passCards').hidden = true;
|
||||||
|
$('magicCard').hidden = false;
|
||||||
|
const start = busy($('mcSendBtn'), async () => {
|
||||||
|
const r = await api('/api/auth/email/start', { email: $('mcEmail').value });
|
||||||
|
$('mcCodeRow').hidden = false;
|
||||||
|
$('mcVerifyBtn').hidden = false;
|
||||||
|
$('mcSendBtn').hidden = true;
|
||||||
|
$('mcResend').hidden = false;
|
||||||
|
if (r.devCode) { $('mcCode').value = r.devCode; IAP.status('Dev mode: code filled in for you.', 'ok'); }
|
||||||
|
else IAP.status('Code sent. Check your inbox (and spam, the first time).', 'ok');
|
||||||
|
$('mcCode').focus();
|
||||||
|
});
|
||||||
|
$('mcSendBtn').addEventListener('click', start);
|
||||||
|
$('mcResend').addEventListener('click', busy($('mcResend'), async () => {
|
||||||
|
const r = await api('/api/auth/email/start', { email: $('mcEmail').value });
|
||||||
|
if (r.devCode) $('mcCode').value = r.devCode;
|
||||||
|
IAP.status('Fresh code sent.', 'ok');
|
||||||
|
}));
|
||||||
|
$('mcVerifyBtn').addEventListener('click', busy($('mcVerifyBtn'), async () => {
|
||||||
|
await api('/api/auth/email/verify', { email: $('mcEmail').value, code: $('mcCode').value });
|
||||||
|
IAP.status('You are in.', 'ok');
|
||||||
|
await render();
|
||||||
|
}));
|
||||||
|
})();
|
||||||
|
|
||||||
$('signupBtn').addEventListener('click', busy($('signupBtn'), async () => {
|
$('signupBtn').addEventListener('click', busy($('signupBtn'), async () => {
|
||||||
await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value });
|
await api('/api/signup', { email: $('suEmail').value, password: $('suPass').value });
|
||||||
IAP.status('Welcome aboard. You are in.', 'ok');
|
IAP.status('Welcome aboard. You are in.', 'ok');
|
||||||
|
|||||||
+11
-1
@@ -15,7 +15,17 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div id="authArea">
|
<div id="authArea">
|
||||||
<div class="grid c2">
|
<div class="card" id="magicCard" hidden>
|
||||||
|
<h3>Sign in or join free</h3>
|
||||||
|
<p class="muted small">Type your email and we send a 6-digit code. No password to invent,
|
||||||
|
no password to forget. New emails get a free account automatically.</p>
|
||||||
|
<p><input id="mcEmail" type="email" placeholder="Email" autocomplete="email" style="width:100%;max-width:420px"></p>
|
||||||
|
<p id="mcCodeRow" hidden><input id="mcCode" inputmode="numeric" placeholder="6-digit code" style="width:100%;max-width:420px"></p>
|
||||||
|
<button class="btn" id="mcSendBtn">Email me a code</button>
|
||||||
|
<button class="btn" id="mcVerifyBtn" hidden>Sign in</button>
|
||||||
|
<button class="btn sec small" id="mcResend" hidden>Send a fresh code</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid c2" id="passCards">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>Create your free account</h3>
|
<h3>Create your free account</h3>
|
||||||
<p class="muted small">Takes ten seconds. No wallet needed to join.</p>
|
<p class="muted small">Takes ten seconds. No wallet needed to join.</p>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const chain = require('./chain');
|
|||||||
const auth = require('./auth');
|
const auth = require('./auth');
|
||||||
const accounts = require('./accounts');
|
const accounts = require('./accounts');
|
||||||
const ads = require('./ads');
|
const ads = require('./ads');
|
||||||
|
const mailer = require('./mailer');
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3000);
|
const PORT = Number(process.env.PORT || 3000);
|
||||||
const ROOT = __dirname;
|
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' });
|
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
|
||||||
accounts.init({ dataDir: DATA_DIR });
|
accounts.init({ dataDir: DATA_DIR });
|
||||||
ads.init({ dataDir: DATA_DIR, chain });
|
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);
|
setTimeout(() => ads.dailySweep(), 60 * 1000);
|
||||||
setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges
|
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') {
|
if (p === '/api/config' && req.method === 'GET') {
|
||||||
const c = chain.getConfig();
|
const c = chain.getConfig();
|
||||||
return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId,
|
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') {
|
if (p === '/api/catalog' && req.method === 'GET') {
|
||||||
return json(res, 200, { products: await chain.catalog() });
|
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) });
|
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 auth: link-to-account when an email session exists, or
|
||||||
// wallet-first sign-in for crypto-native users
|
// wallet-first sign-in for crypto-native users
|
||||||
if (p === '/api/auth/challenge' && req.method === 'POST') {
|
if (p === '/api/auth/challenge' && req.method === 'POST') {
|
||||||
|
|||||||
Reference in New Issue
Block a user