diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/auth-page.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/auth-page.js | 129 |
1 files changed, 57 insertions, 72 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index e014f61..c5374f8 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -8,6 +8,7 @@ import { import * as platform from './platform.js'; import { Icon } from './icon.js'; import { loadPending } from './invite-link.js'; +import { solve } from './captcha.js'; const PASSWORD_MIN_BITS = 60; const PASSWORD_MIN_LEN = 12; @@ -44,73 +45,63 @@ function PasswordInput({ `; } -// ── reCAPTCHA v2 helper ────────────────────────────────────────────────────── - -let _captchaSiteKey = null; -let _captchaKeyFetched = false; - -async function fetchCaptchaSiteKey() { - if (_captchaKeyFetched) return _captchaSiteKey; - try { - const info = await hubFetch('/v1/hub/info'); - _captchaSiteKey = info.captcha_site_key || null; - } catch { _captchaSiteKey = null; } - _captchaKeyFetched = true; - return _captchaSiteKey; -} - -function loadRecaptchaScript() { - if (document.getElementById('recaptcha-script')) return; - const s = document.createElement('script'); - s.id = 'recaptcha-script'; - s.src = 'https://www.google.com/recaptcha/api.js?render=explicit'; - s.async = true; - s.defer = true; - document.head.appendChild(s); -} +// ── Proof of work (captcha.js) ─────────────────────────────────────────────── +// +// Fetched and solved as soon as the form is on screen, so by the time somebody +// has typed a name, an address and a passphrase twice there is nothing left to +// wait for. Nothing is drawn: there is nothing for a person to do. A hub with +// the check turned off answers 404, and the form then sends no answer at all. function useCaptcha() { - const [siteKey, setSiteKey] = useState(_captchaSiteKey); - const [token, setToken] = useState(null); - const containerRef = useRef(null); - const widgetId = useRef(null); + const pending = useRef(null); + const abort = useRef(null); - useEffect(() => { - fetchCaptchaSiteKey().then(k => { - if (k) { setSiteKey(k); loadRecaptchaScript(); } - }); + const start = useCallback(() => { + if (abort.current) abort.current.abort(); + const ctl = new AbortController(); + abort.current = ctl; + pending.current = (async () => { + let challenge; + try { + challenge = await hubFetch('/v1/users/captcha'); + } catch (err) { + return err.message === 'captcha_disabled' ? { token: null, until: Infinity } : null; + } + // A minute short of the hub's own limit, so an answer is never sent + // that expires on its way there. + const until = Date.now() + (challenge.expires_in - 60) * 1000; + return { token: await solve(challenge, ctl.signal), until }; + })(); }, []); useEffect(() => { - if (!siteKey || !containerRef.current) return; - const poll = setInterval(() => { - if (window.grecaptcha && window.grecaptcha.render && widgetId.current === null) { - clearInterval(poll); - widgetId.current = window.grecaptcha.render(containerRef.current, { - sitekey: siteKey, - callback: (tk) => setToken(tk), - 'expired-callback': () => setToken(null), - theme: document.documentElement.getAttribute('data-theme') === 'dark' - ? 'dark' : 'light', - }); - } - }, 100); - return () => clearInterval(poll); - }, [siteKey]); + start(); + return () => abort.current && abort.current.abort(); + }, [start]); - const reset = useCallback(() => { - if (widgetId.current !== null && window.grecaptcha) { - window.grecaptcha.reset(widgetId.current); - setToken(null); + // The answer for this submission — waited for if it is still being worked + // out, and worked out again if the form sat open past its expiry or the + // first fetch failed. Null only when the hub has no check, or cannot be + // reached, in which case the hub's own refusal is what the person sees. + const take = useCallback(async () => { + let got = await pending.current; + if (!got || got.until < Date.now()) { + start(); + got = await pending.current; } - }, []); + return got ? got.token : null; + }, [start]); - const widget = siteKey - ? html`<div ref=${containerRef} - style="display:flex;justify-content:center;margin:12px 0"></div>` - : null; + // An answer is spent once the hub has checked it, whatever happened after — + // a name already taken is refused past that point. Call after a failure. + return { take, renew: start }; +} - return { token, widget, reset, enabled: !!siteKey }; +// The hub's refusal of the proof of work, which a person can do nothing about +// but try again, in words rather than as the code it is sent as. +function errorText(err) { + return err.message === 'captcha_required' || err.message === 'captcha_failed' + ? t('captcha.failed') : err.message; } function passwordBits(pw) { @@ -411,11 +402,9 @@ export function RegisterPage() { const rk = window.MeshBayKeys.generateRecoveryKey(); // `name` (trimmed), not the raw field: the hub stores the trimmed // username and every key derivation must fold in the same string. - // `captcha.token` rides along — the submit button is already disabled - // until it is set when a captcha is configured (see the form below). const reg = await window.MeshBayKeys.registerUser( name, email, password, emailRecovery ? rk.mnemonic : null, - captcha.token); + await captcha.take()); // An account made in the desktop application starts without browser // access: its identities stay on this device and nothing of them is // left on nodes until the person turns it on, here, natively. @@ -433,17 +422,14 @@ export function RegisterPage() { body: { username: name, email, password, pk_user_ed25519: '', pk_user_x25519: '', - captcha_token: captcha.token, + captcha_token: await captcha.take(), }, }); setPhase('verify'); } } catch (err) { - setError(err.message); - // A reCAPTCHA token is single-use: after a failed attempt (name taken, - // e-mail in use…) it is spent, so clear it and make the user solve a - // fresh one before the next try. No-op when no captcha is configured. - captcha.reset(); + setError(errorText(err)); + captcha.renew(); } finally { setLoading(false); } @@ -609,9 +595,8 @@ export function RegisterPage() { onChange=${e => setEmailRecovery(e.target.checked)} /> <span>${t('register.recovery_email_opt')}</span> </label> - ${captcha.widget} ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${loading || (captcha.enabled && !captcha.token)}> + <button type="submit" disabled=${loading}> ${loading ? t('register.loading') : t('register.submit')} </button> <p class="register-legal"> @@ -658,12 +643,13 @@ export function ResetPasswordPage({ onLogin }) { method: 'POST', body: { username: username.trim(), email: email.trim(), - captcha_token: captcha.token, + captcha_token: await captcha.take(), }, }); setPhase('form'); } catch (err) { - setError(err.message); + setError(errorText(err)); + captcha.renew(); } finally { setBusy(false); } @@ -771,9 +757,8 @@ export function ResetPasswordPage({ onLogin }) { <input type="email" placeholder="${t('register.email')}" value=${email} onInput=${e => setEmail(e.target.value)} autocomplete="email" required /> - ${captcha.widget} ${error && html`<div class="error-msg">${error}</div>`} - <button type="submit" disabled=${busy || (captcha.enabled && !captcha.token)}> + <button type="submit" disabled=${busy}> ${t('reset.send_code')}</button> </form>`} |