summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js
diff options
context:
space:
mode:
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.js129
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>`}