Fix Turnstile in the mobile app; drop it from login

The Turnstile challenge failed reliably inside the Capacitor WebView,
which blocked login and registration from the Android app.

Three separate causes:

1. Android WebView blocks third-party cookies by default. Turnstile runs
   in a cross-origin iframe from challenges.cloudflare.com and needs its
   own storage, so the widget never emitted a token. MainActivity now
   calls setAcceptThirdPartyCookies on the app's own WebView.

2. The register handler read the Turnstile response with an unscoped
   document.querySelector, which matched the *login* widget's input (it
   comes first in the DOM). Registration therefore submitted the login
   widget's token — single-use with a 5 minute expiry, so any prior login
   attempt or slow signup made it fail server-side.

3. The register and forgot-password widgets auto-rendered inside forms
   that start at display:none, where Turnstile does not reliably complete
   a challenge, and nothing re-rendered them when the form was shown.

Widgets are now rendered explicitly when their form first becomes
visible, and tokens are captured from the render callback instead of
being read back out of the injected input — which makes the unscoped
lookup in (2) structurally impossible. Added error/expired/timeout
callbacks so a widget failure surfaces the Cloudflare error code instead
of failing silently behind a generic toast.

Login is no longer gated at all. It is the path mobile users hit
constantly, and it is already covered by a 10-per-15-min per-IP rate
limit, a constant-time credential check, and TOTP 2FA. Registration and
password reset — the endpoints that actually attract bots — stay gated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-07-30 17:22:03 +02:00
parent bee9361c1d
commit 82ed46d01b
7 changed files with 144 additions and 51 deletions

View file

@ -103,8 +103,7 @@ Authenticate a user. Supports optional TOTP two-factor authentication. On succes
{ {
"email": "string", "email": "string",
"password": "string", "password": "string",
"totpCode": "string (optional, required if 2FA is enabled)", "totpCode": "string (optional, required if 2FA is enabled)"
"turnstileToken": "string"
} }
``` ```
- **Response:** - **Response:**

View file

@ -123,9 +123,23 @@ necessary UX tradeoff over perfect indistinguishability.
## Turnstile (Cloudflare bot protection) ## Turnstile (Cloudflare bot protection)
Applied to `/api/auth/login`, `/register`, `/forgot-password` when Applied to `/api/auth/register` and `/api/auth/forgot-password` when
`TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode). `TURNSTILE_SECRET_KEY` is set. No-op when unset (dev mode).
`/api/auth/login` is deliberately **not** gated: the widget could not
reliably complete a challenge inside the Capacitor WebView, which locked
mobile users out of the app. Login is covered instead by its per-IP rate
limit (10 / 15 min), the constant-time credential check, and TOTP 2FA.
The two remaining widgets are rendered explicitly (`api.js?render=explicit`)
the first time their form becomes visible — Turnstile does not reliably
complete a challenge inside a `display:none` container, and both forms start
hidden. Tokens are captured from the render callback, not read back out of
the injected `[name="cf-turnstile-response"]` input.
Note that the site key is currently **hardcoded** in `public/index.html`.
`TURNSTILE_SITE_KEY` exists in OpenBao but is not read by any code.
## Encryption at rest ## Encryption at rest
`src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from `src/utils/crypto.js` provides AES-256-GCM helpers. Key loaded from

View file

@ -2,14 +2,15 @@
// SESSION PERSISTENCE — full logout → login → still on the same // SESSION PERSISTENCE — full logout → login → still on the same
// tab + same sub-pill. // tab + same sub-pill.
// //
// The UI's login form is gated by a Cloudflare Turnstile token // The test does a programmatic logout (clear the ped_auth cookie,
// whose site key is hardcoded in index.html, which can't be // same effect server-side as clicking Logout) followed by a fresh
// completed in the e2e container (Turnstile rejects the non-prod // programmatic login. This exercises the same localStorage
// origin). So the test does a programmatic logout (clear the // persistence path a real logout/login would.
// ped_auth cookie, same effect server-side as clicking Logout) //
// followed by a fresh programmatic login — this exercises the // (Historically this was a workaround for the Turnstile challenge on
// same localStorage persistence path a real logout/login would, // the login form, which could not be completed in the e2e container.
// without depending on the bot challenge. // Login is no longer gated, but driving it programmatically keeps
// the test focused on persistence rather than form mechanics.)
// ============================================================ // ============================================================
const { test, expect, E2E_BASE, loginAs } = require('../fixtures'); const { test, expect, E2E_BASE, loginAs } = require('../fixtures');

View file

@ -15,6 +15,7 @@ import android.print.PrintDocumentAdapter;
import android.print.PrintManager; import android.print.PrintManager;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.util.Base64; import android.util.Base64;
import android.webkit.CookieManager;
import android.webkit.PermissionRequest; import android.webkit.PermissionRequest;
import android.webkit.WebChromeClient; import android.webkit.WebChromeClient;
import android.webkit.WebViewClient; import android.webkit.WebViewClient;
@ -47,6 +48,9 @@ public class MainActivity extends BridgeActivity {
new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE); new String[]{ Manifest.permission.RECORD_AUDIO }, MIC_PERMISSION_CODE);
} }
// Allow the Cloudflare Turnstile iframe to use storage.
setupThirdPartyCookies();
// Setup WebView mic permission granting // Setup WebView mic permission granting
setupWebViewPermissions(); setupWebViewPermissions();
@ -60,6 +64,26 @@ public class MainActivity extends BridgeActivity {
setupFileBridge(); setupFileBridge();
} }
// Third-Party Cookies
//
// Android WebView blocks third-party cookies by default (unlike Chrome,
// which still allows them for now). Cloudflare Turnstile runs inside a
// cross-origin iframe from challenges.cloudflare.com and needs its own
// storage to run and persist a challenge without this the widget
// silently stalls or errors and never emits a token, so registration and
// password reset are impossible from inside the app.
//
// This is scoped to our own WebView, which only ever loads the PedScribe
// origin (see allowNavigation in capacitor.config.json), so it is not a
// general relaxation of the app's cookie policy.
private void setupThirdPartyCookies() {
WebView webView = this.bridge.getWebView();
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.setAcceptCookie(true);
cookieManager.setAcceptThirdPartyCookies(webView, true);
}
// WebView Microphone Permission // WebView Microphone Permission
private void setupWebViewPermissions() { private void setupWebViewPermissions() {

View file

@ -11,7 +11,11 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"
integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer"> integrity="sha384-/o6I2CkkWC//PSjvWC/eYN7l3xM3tJm8ZzVkCOfp//W05QcE3mlGskpoHB6XqI+B" crossorigin="anonymous" referrerpolicy="no-referrer">
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> <!-- Explicit render mode: the register/forgot widgets live inside forms that
start hidden, and Turnstile's implicit auto-render does not reliably
complete a challenge inside a display:none container. auth.js renders
each widget the first time its form is shown. -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.1.6/purify.min.js"
integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a" integrity="sha384-+VfUPEb0PdtChMwmBcBmykRMDd+v6D/oFmB3rZM/puCMDYcIvF968OimRh4KQY9a"
crossorigin="anonymous" referrerpolicy="no-referrer" defer></script> crossorigin="anonymous" referrerpolicy="no-referrer" defer></script>
@ -70,7 +74,6 @@
<label>2FA Code</label> <label>2FA Code</label>
<input type="text" id="login-totp" placeholder="6-digit code" maxlength="6"> <input type="text" id="login-totp" placeholder="6-digit code" maxlength="6">
</div> </div>
<div class="cf-turnstile" id="turnstile-login" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div>
<button type="submit" class="btn-auth" id="btn-local-login">Sign In</button> <button type="submit" class="btn-auth" id="btn-local-login">Sign In</button>
<div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;"> <div id="sso-divider" class="hidden" style="display:none;text-align:center;margin:16px 0 12px;position:relative;">
<span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span> <span style="background:white;padding:0 12px;color:#9ca3af;font-size:12px;position:relative;z-index:1;">or</span>
@ -104,7 +107,7 @@
<label>Password (8+ characters)</label> <label>Password (8+ characters)</label>
<input type="password" id="reg-password" required minlength="8" placeholder="••••••••"> <input type="password" id="reg-password" required minlength="8" placeholder="••••••••">
</div> </div>
<div class="cf-turnstile" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div> <div id="turnstile-register" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div>
<button type="submit" class="btn-auth">Create Account</button> <button type="submit" class="btn-auth">Create Account</button>
<div class="auth-links"> <div class="auth-links">
<a href="#" id="show-login">Back to sign in</a> <a href="#" id="show-login">Back to sign in</a>
@ -118,7 +121,7 @@
<label>Email</label> <label>Email</label>
<input type="email" id="forgot-email" required placeholder="your@email.com"> <input type="email" id="forgot-email" required placeholder="your@email.com">
</div> </div>
<div class="cf-turnstile" id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6" data-theme="light"></div> <div id="turnstile-forgot" data-sitekey="0x4AAAAAAC0VtKAhC8rzpMx6"></div>
<button type="submit" class="btn-auth">Send Reset Link</button> <button type="submit" class="btn-auth">Send Reset Link</button>
<div class="auth-links"> <div class="auth-links">
<a href="#" id="show-login-2">Back to sign in</a> <a href="#" id="show-login-2">Back to sign in</a>

View file

@ -214,6 +214,71 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
// ---- CLOUDFLARE TURNSTILE ----
// Gates registration and password reset. Login is deliberately NOT gated:
// it is already covered by a 10-per-15-min rate limit and a constant-time
// credential check, and the widget is unreliable inside the Capacitor
// WebView — which locked mobile users out of the app entirely.
//
// Tokens are captured from the render callback rather than read back out of
// the injected [name="cf-turnstile-response"] input. That lookup is easy to
// leave unscoped, which is exactly how the register form ended up
// submitting the login widget's token (single-use, 5-minute expiry).
//
// Rendering is explicit and deferred until the owning form is visible:
// both widgets live in forms that start at display:none, and Turnstile does
// not reliably complete a challenge inside a hidden container.
var turnstileWidgets = {
register: { el: 'turnstile-register', id: null, token: '', pending: false },
forgot: { el: 'turnstile-forgot', id: null, token: '', pending: false }
};
var turnstileReady = false;
// api.js?render=explicit invokes this once the Turnstile API is available.
window.onloadTurnstileCallback = function() {
turnstileReady = true;
Object.keys(turnstileWidgets).forEach(function(name) {
// Catch up on any form shown before the script finished loading.
if (turnstileWidgets[name].pending) renderTurnstile(name);
});
};
function renderTurnstile(name) {
var w = turnstileWidgets[name];
if (!w || w.id !== null) return; // already rendered
var el = document.getElementById(w.el);
if (!el) return;
if (!turnstileReady || !window.turnstile) { w.pending = true; return; }
w.pending = false;
w.id = window.turnstile.render(el, {
sitekey: el.getAttribute('data-sitekey'),
theme: 'light',
callback: function(token) { w.token = token; },
'expired-callback': function() { w.token = ''; },
'timeout-callback': function() { w.token = ''; },
// Without this a widget failure is silent and the user only ever sees
// the generic "complete the verification" toast with no way to tell
// whether the challenge failed, expired, or never loaded at all.
'error-callback': function(code) {
w.token = '';
console.error('[Auth] Turnstile error on ' + name + ' widget:', code);
showToast('Verification unavailable (' + (code || 'error') + '). Check your connection and try again.', 'error');
}
});
}
function turnstileToken(name) {
var w = turnstileWidgets[name];
return w ? w.token : '';
}
function resetTurnstile(name) {
var w = turnstileWidgets[name];
if (!w) return;
w.token = '';
if (w.id !== null && window.turnstile) window.turnstile.reset(w.id);
}
// ---- HELPER FUNCTIONS ---- // ---- HELPER FUNCTIONS ----
function showLoginForm() { function showLoginForm() {
@ -226,12 +291,14 @@ document.addEventListener('DOMContentLoaded', function() {
if (loginForm) loginForm.style.display = 'none'; if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'block'; if (registerForm) registerForm.style.display = 'block';
if (forgotForm) forgotForm.style.display = 'none'; if (forgotForm) forgotForm.style.display = 'none';
renderTurnstile('register');
} }
function showForgotForm() { function showForgotForm() {
if (loginForm) loginForm.style.display = 'none'; if (loginForm) loginForm.style.display = 'none';
if (registerForm) registerForm.style.display = 'none'; if (registerForm) registerForm.style.display = 'none';
if (forgotForm) forgotForm.style.display = 'block'; if (forgotForm) forgotForm.style.display = 'block';
renderTurnstile('forgot');
} }
function enterApp(user, token) { function enterApp(user, token) {
@ -527,7 +594,7 @@ document.addEventListener('DOMContentLoaded', function() {
// ---- BIOMETRIC LOGIN BUTTON ---- // ---- BIOMETRIC LOGIN BUTTON ----
// Reads the email + password from the OS-secured keychain (gated behind // Reads the email + password from the OS-secured keychain (gated behind
// Face ID / Touch ID / fingerprint) and fills the login form. Submits the // Face ID / Touch ID / fingerprint) and fills the login form. Submits the
// form so all the existing flow (turnstile, 2FA prompt, error handling, // form so all the existing flow (2FA prompt, error handling,
// session storage) runs unchanged. If biometric verification fails, the // session storage) runs unchanged. If biometric verification fails, the
// user just gets a toast and falls through to typing the password. // user just gets a toast and falls through to typing the password.
var bioBtn = document.getElementById('btn-bio-login'); var bioBtn = document.getElementById('btn-bio-login');
@ -544,10 +611,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (emailEl) emailEl.value = creds.username; if (emailEl) emailEl.value = creds.username;
if (pwEl) pwEl.value = creds.password; if (pwEl) pwEl.value = creds.password;
// Trigger the same submit path as the password form so all the // Trigger the same submit path as the password form so all the
// existing handling (turnstile token, 2FA, session storage, etc.) // existing handling (2FA, session storage, etc.) runs unchanged.
// runs unchanged. If turnstile hasn't auto-solved yet the form
// will toast "Please complete the verification" — same as a
// manual login attempt before turnstile resolves.
if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit(); if (loginForm && typeof loginForm.requestSubmit === 'function') loginForm.requestSubmit();
else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); else if (loginForm) loginForm.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
}) })
@ -577,17 +641,9 @@ document.addEventListener('DOMContentLoaded', function() {
return false; return false;
} }
// Cloudflare Turnstile
var loginTurnstile = document.querySelector('#login-form [name="cf-turnstile-response"]');
var loginToken = loginTurnstile ? loginTurnstile.value : '';
if (!loginToken) {
showToast('Please complete the verification', 'error');
return false;
}
showLoading('Signing in...'); showLoading('Signing in...');
var body = { email: email, password: password, turnstileToken: loginToken }; var body = { email: email, password: password };
if (totpCode) body.totpCode = totpCode; if (totpCode) body.totpCode = totpCode;
fetch('/api/auth/login', { fetch('/api/auth/login', {
@ -637,14 +693,12 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} else { } else {
showToast(data.error || 'Login failed', 'error'); showToast(data.error || 'Login failed', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
} }
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
console.error('[Auth] Login error:', err); console.error('[Auth] Login error:', err);
showToast('Connection error', 'error'); showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-login');
}); });
return false; return false;
@ -703,9 +757,8 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// Cloudflare Turnstile verification // Cloudflare Turnstile verification
var turnstileResponse = document.querySelector('[name="cf-turnstile-response"]'); var regToken = turnstileToken('register');
var turnstileToken = turnstileResponse ? turnstileResponse.value : ''; if (!regToken) {
if (!turnstileToken) {
showToast('Please complete the verification challenge', 'error'); showToast('Please complete the verification challenge', 'error');
return false; return false;
} }
@ -715,7 +768,7 @@ document.addEventListener('DOMContentLoaded', function() {
fetch('/api/auth/register', { fetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: turnstileToken }) body: JSON.stringify({ name: name, email: email, password: password, turnstileToken: regToken })
}) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@ -729,17 +782,20 @@ document.addEventListener('DOMContentLoaded', function() {
showToast(data.message || 'Account created!', 'success'); showToast(data.message || 'Account created!', 'success');
} else if (data.success && data.needsVerification) { } else if (data.success && data.needsVerification) {
showToast(data.message || 'Check email to verify', 'success'); showToast(data.message || 'Check email to verify', 'success');
// The token was just consumed server-side — clear it so coming back
// to this form doesn't resubmit a spent one.
resetTurnstile('register');
showLoginForm(); showLoginForm();
} else { } else {
showToast(data.error || 'Registration failed', 'error'); showToast(data.error || 'Registration failed', 'error');
if (window.turnstile) turnstile.reset(); resetTurnstile('register');
} }
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
console.error('[Auth] Register error:', err); console.error('[Auth] Register error:', err);
showToast('Connection error', 'error'); showToast('Connection error', 'error');
if (window.turnstile) turnstile.reset(); resetTurnstile('register');
}); });
return false; return false;
@ -756,8 +812,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!email) { showToast('Enter email', 'error'); return false; } if (!email) { showToast('Enter email', 'error'); return false; }
// Cloudflare Turnstile // Cloudflare Turnstile
var forgotTurnstile = document.querySelector('#forgot-form [name="cf-turnstile-response"]'); var forgotToken = turnstileToken('forgot');
var forgotToken = forgotTurnstile ? forgotTurnstile.value : '';
if (!forgotToken) { if (!forgotToken) {
showToast('Please complete the verification', 'error'); showToast('Please complete the verification', 'error');
return false; return false;
@ -774,12 +829,13 @@ document.addEventListener('DOMContentLoaded', function() {
.then(function(data) { .then(function(data) {
hideLoading(); hideLoading();
showToast(data.message || 'Check your email', 'success'); showToast(data.message || 'Check your email', 'success');
resetTurnstile('forgot');
showLoginForm(); showLoginForm();
}) })
.catch(function(err) { .catch(function(err) {
hideLoading(); hideLoading();
showToast('Error', 'error'); showToast('Error', 'error');
if (window.turnstile) turnstile.reset('#turnstile-forgot'); resetTurnstile('forgot');
}); });
return false; return false;

View file

@ -299,19 +299,15 @@ router.post('/resend-verification', async (req, res) => {
// ============================================================ // ============================================================
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
try { try {
var { email, password, totpCode, turnstileToken } = req.body; var { email, password, totpCode } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' }); if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
// Cloudflare Turnstile verification // No Turnstile on login. The widget could not reliably complete a
if (process.env.TURNSTILE_SECRET_KEY) { // challenge inside the Capacitor WebView, which locked mobile users out.
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification' }); // Brute-force cover here comes from the 10-per-15-min per-IP rate limit
var tsRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { // (server.js), the constant-time bcrypt comparison below, and TOTP 2FA.
method: 'POST', headers: { 'Content-Type': 'application/json' }, // Registration and password reset — the endpoints that actually attract
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip }) // bots — are still gated.
});
var tsData = await tsRes.json();
if (!tsData.success) return res.status(400).json({ error: 'Verification failed. Please try again.' });
}
var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]); var user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
// Enumeration-resistant: always run bcrypt to keep timing constant, and return // Enumeration-resistant: always run bcrypt to keep timing constant, and return