From b12ef2c1f0064ac7cc838ac1e806c52134a6802f Mon Sep 17 00:00:00 2001 From: Matthew Valancy Date: Tue, 27 Oct 2026 12:29:32 -0700 Subject: [PATCH 1/4] Require server-issued CAPTCHA proof for authentication actions --- ...-27-captcha-proof-must-be-server-issued.md | 82 +++++++++++++ .agents/issues/INDEX.md | 1 + .agents/issues/security-audit-index.md | 1 + package.json | 3 +- packages/server/src/config/secrets.ts | 5 + packages/server/src/index.ts | 29 +++-- packages/server/src/resolvers/sqlite-auth.ts | 32 ++--- .../server/src/schema/auth-only-schema.ts | 4 +- packages/server/src/utils/captcha.ts | 114 +++++++++++++----- packages/server/tests/captcha-auth.test.ts | 103 ++++++++++++++++ packages/server/tests/captcha.test.ts | 95 +++++++++++++++ .../server/tests/production-startup.test.ts | 1 + .../web/src/components/AuthCaptcha.test.tsx | 48 ++++++++ packages/web/src/components/AuthCaptcha.tsx | 103 ++++++++++++++++ packages/web/src/pages/ForgotPassword.tsx | 9 +- packages/web/src/pages/ResetPassword.tsx | 9 +- packages/web/src/pages/Signin.tsx | 13 +- packages/web/vite.config.ts | 3 + playwright.captcha.config.ts | 12 ++ tests/e2e/auth/passwordless-focus.spec.ts | 5 +- tests/e2e/quality/signup-delivery.spec.ts | 8 +- tests/integration/captcha-browser.spec.ts | 76 ++++++++++++ tests/lib/completeAuthCaptcha.ts | 18 +++ 23 files changed, 691 insertions(+), 83 deletions(-) create mode 100644 .agents/inbox/2026-10-27-captcha-proof-must-be-server-issued.md create mode 100644 packages/server/tests/captcha-auth.test.ts create mode 100644 packages/server/tests/captcha.test.ts create mode 100644 packages/web/src/components/AuthCaptcha.test.tsx create mode 100644 packages/web/src/components/AuthCaptcha.tsx create mode 100644 playwright.captcha.config.ts create mode 100644 tests/integration/captcha-browser.spec.ts create mode 100644 tests/lib/completeAuthCaptcha.ts diff --git a/.agents/inbox/2026-10-27-captcha-proof-must-be-server-issued.md b/.agents/inbox/2026-10-27-captcha-proof-must-be-server-issued.md new file mode 100644 index 00000000..7504b738 --- /dev/null +++ b/.agents/inbox/2026-10-27-captcha-proof-must-be-server-issued.md @@ -0,0 +1,82 @@ +# Client-side puzzle completion is not a server-verifiable authentication proof + +Date: 2026-10-27. Searched the symptom/security indexes and the October 23 +security inventory before addressing #183. The existing helper accepted any +six uppercase alphanumeric characters; login/signup also skipped the helper +when a payload was omitted. Correct credentials remained necessary for password +login: this was an abuse-control bypass, not proof of account takeover. + +## Reproduction and correction + +With Node 22.18.0 / npm 10.8.2 in an owned worktree based on +`ef495eff15ed7aac51e72dc7a1db1bb96d2b3695`: + +```sh +npm run test -w @graphdone/server -- tests/captcha.test.ts --maxWorkers=1 --minWorkers=1 +# Before: arbitrary code ABC123 was accepted; the new rejection assertion failed. +``` + +The corrected policy requires proof for production login/signup and for Core +magic-link, forgot-password and reset-password requests in every mode. Missing +proof is allowed only for login/signup in explicit development/test mode to +retain development quick login. Supplied proofs are always verified. Guest +access retains its existing separate policy; OAuth callbacks and email-code +verification are not reclassified as these challenge actions. + +The existing installed `altcha-lib@1.4.1` / `altcha@2.3.0` protocol is retained; +no dependency upgrade or provider account is added. The server issues signed +SHA-256 challenges with a five-minute expiry and action. A bounded ledger +authenticates the exact issued salt/signature/action, validates the solution, +rechecks expiry after asynchronous verification and atomically consumes it. +At most 5,000 outstanding/pending challenges are admitted; concurrent issuance +reserves capacity before awaiting cryptography. Live proofs are not evicted to +admit more work. Challenge issuance is limited to 30/minute per observed socket +IP (IPv6 normalized); forwarded headers do not redefine Core's trust policy. + +An unset key generates a random process-local signing key. Explicit keys must +be at least 32 characters; the old public fallback is rejected, including at +production startup. Outstanding challenges intentionally fail after restart. + +## Evidence + +- Original failure retained in `captcha-before.log` under the owned temporary + resolution evidence directory. +- All 139 server tests passed, including nine helper/policy negatives, three + real HTTP GraphQL/SQLite tests, existing password/IP/alias-concurrency tests, + and production startup's fallback-key rejection. +- Three UI component tests cover proof invalidation, action/reset, failed policy + loading and accessible retry. Policy fetch failure never becomes success. +- Four headed browser cases passed through the built frontend served by nginx + and a production-mode API: direct REST/GraphQL omission/forgery rejection; + 390px/1440px real worker-solved proof, failed password then new-proof success; + and policy-load failure with keyboard retry. No email was delivered. +- Clean install, types and build passed; lint passed before the final explicit + socket-IP key generator, whose final composed checks remain to be recorded. +- Earlier browser failures are retained: the test initially forgot to choose + the Password tab; `.check()` incorrectly expected a PoW checkbox to become + checked before asynchronous solving; reusing the same API process exhausted + its existing strict rate limit. The corrected test clicks, waits for verified + status and asserts checked state, against a fresh owned API process. Limits + were not weakened. Initial Vite invocation from the wrong cwd omitted styles; + final evidence uses the built artifact through nginx. + +The browser uses the self-host nginx configuration with only its listener and +API upstream replaced for isolated loopback ports (28548 / 28537). Neo4j/APOC +and SQLite fixtures are owned; the user's localhost application is untouched. +Local traces can include synthetic fixture credentials and remain private. + +## Corrective rule and limits + +Enforce challenge policy at each server action, including direct GraphQL aliases +and the auth-only schema. Client disabling/hiding controls is not enforcement. +Clear consumed/expired proofs and provide a keyboard-accessible retry. Preserve +backend contracts explicitly: Core advertises ALTCHA through `/config`; Cloud's +existing contract remains separate and is not proven by these tests. + +This is a bounded proof-of-work abuse control, not proof that a visitor is human. +The ledger/rate limits are process-local; multiple API instances require a +shared or sticky challenge contract before availability parity can be claimed. +Unresponsive, restarted or over-capacity verification fails closed. Email token +purpose, OAuth linking and reset-session revocation remain #184. Hosted parity, +distributed limits, release/deployment and actual email delivery remain separate +work. Independent exact-head review and final CI are still required. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index 4e83a296..c2719eb8 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,7 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| Arbitrary client CAPTCHA strings succeed, or direct login/signup omits proof | [Require server-issued action-bound proof and atomic consumption](../inbox/2026-10-27-captcha-proof-must-be-server-issued.md); server `captcha` / `captcha-auth` tests and `qc:captcha` | | Deleting an owned folder removes or orphans another owner's nested folder; a deletion destination disappears with its parent | [Deletion authority includes pre-existing descendant folders](../inbox/2026-10-23-folder-delete-legacy-hierarchy.md); `packages/server/tests/folder-authorization.test.ts` | | Build/typecheck pass but API startup, supported-Node launcher, container or repository lint fails | [Startup and save evidence pointer](../inbox/2026-10-23-core-startup-and-save-evidence.md); [runtime validation](../../docs/quality/RUNTIME_VALIDATION_2026-10-23.md) | | Production accepts missing/default JWT or session secrets; `.env` key differs from the captured signing key | [Startup and save evidence pointer](../inbox/2026-10-23-core-startup-and-save-evidence.md); `packages/server/tests/production-startup.test.ts`, `production-secrets.test.ts` | diff --git a/.agents/issues/security-audit-index.md b/.agents/issues/security-audit-index.md index e28461dd..081924c2 100644 --- a/.agents/issues/security-audit-index.md +++ b/.agents/issues/security-audit-index.md @@ -2,6 +2,7 @@ | Symptom | Evidence and corrective rule | | --- | --- | +| CAPTCHA validates only a client string's format or skips missing GraphQL proof | [Server policy, signed action/expiry and bounded replay](../inbox/2026-10-27-captcha-proof-must-be-server-issued.md); `packages/server/tests/captcha*.test.ts`, `qc:captcha` | | Green production audit hides development-tool findings or package counts are treated as exploit counts | [Audit scope lesson](../inbox/2026-10-23-dependency-audit-scope.md); [dated security scope](../../docs/quality/SECURITY_SCOPE_2026-10-23.md) | | Nested lint glob stalls the process | `tests/lib/dependencies/minimatch.test.mjs`; scoped parser matcher override and bounded child-process regression | | A fresh audit reports Zod high severity after an earlier green scan; bounded collections still walk rejected elements | [Zod consumer and bounded-validation lesson](../inbox/2026-10-23-zod-constrained-collections.md); recheck the resolved SDK dependency and use an isolated audit cache without inferring why older metadata differed | diff --git a/package.json b/package.json index 10ae27d6..6cc7ba2b 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,8 @@ "qc:project-transfer": "playwright test --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --reporter=line tests/e2e/quality/project-transfer.spec.ts", "qc:operations": "playwright test tests/e2e/quality/operations.spec.ts --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --reporter=line", "qc:operator-login": "playwright test tests/e2e/quality/operator-login.spec.ts --project=GraphDone-Core/dev-neo4j/chromium --headed --workers=1 --reporter=line", - "test:operations": "tsx scripts/test-operations-local.mts && tsx scripts/test-admin-bootstrap.mts" + "test:operations": "tsx scripts/test-operations-local.mts && tsx scripts/test-admin-bootstrap.mts", + "qc:captcha": "playwright test --config playwright.captcha.config.ts --headed --workers=1 --retries=0" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/packages/server/src/config/secrets.ts b/packages/server/src/config/secrets.ts index 1c1a9472..239c01e8 100644 --- a/packages/server/src/config/secrets.ts +++ b/packages/server/src/config/secrets.ts @@ -23,6 +23,11 @@ export function validateProductionSecrets(environment: Record(), async (_req, res) => { const config = { + captcha: { provider: 'altcha', requiredActions: CAPTCHA_ACTIONS.filter(captchaRequired) }, timestamp: new Date().toISOString(), services: { api: { @@ -724,7 +725,7 @@ async function startServer() { } // Verify CAPTCHA - const isCaptchaValid = await verifyCaptcha(captchaPayload); + const isCaptchaValid = await verifyCaptcha(captchaPayload, 'magic-link'); if (!isCaptchaValid) { return res.status(400).json({ error: 'CAPTCHA verification failed' }); } @@ -797,7 +798,7 @@ async function startServer() { } // Verify CAPTCHA - const isCaptchaValid = await verifyCaptcha(captchaPayload); + const isCaptchaValid = await verifyCaptcha(captchaPayload, 'forgot-password'); if (!isCaptchaValid) { return res.status(400).json({ error: 'CAPTCHA verification failed' }); } @@ -875,7 +876,7 @@ async function startServer() { } // Verify CAPTCHA - const isCaptchaValid = await verifyCaptcha(captchaPayload); + const isCaptchaValid = await verifyCaptcha(captchaPayload, 'reset-password'); if (!isCaptchaValid) { return res.status(400).json({ error: 'CAPTCHA verification failed' }); } @@ -970,13 +971,21 @@ async function startServer() { app.options('/api/captcha/challenge', cors(captchaCorsOptions)); - app.get('/api/captcha/challenge', cors(captchaCorsOptions), async (_req, res) => { + const captchaChallengeLimiter = rateLimit({ + windowMs: 60000, limit: 30, standardHeaders: 'draft-8', legacyHeaders: false, + // Preserve Core's current socket-IP policy; never trust a caller's forwarded header. + keyGenerator: req => ipKeyGenerator(req.ip || req.socket.remoteAddress || 'unknown'), + }); + app.get('/api/captcha/challenge', cors(captchaCorsOptions), captchaChallengeLimiter, async (req, res) => { try { - const challenge = await createCaptchaChallenge(); - res.json(challenge); + if (!isCaptchaAction(req.query.action)) return res.status(400).json({ error: 'Invalid verification action' }); + const challenge = await createCaptchaChallenge(req.query.action); + res.setHeader('Cache-Control', 'no-store'); + return res.json(challenge); } catch (error) { - console.error('❌ CAPTCHA challenge creation failed:', error); // eslint-disable-line no-console - res.status(500).json({ error: 'Failed to create CAPTCHA challenge' }); + if (error instanceof CaptchaCapacityError) return res.status(503).json({ error: error.message }); + console.error('CAPTCHA challenge creation failed'); // eslint-disable-line no-console + return res.status(500).json({ error: 'Failed to create CAPTCHA challenge' }); } }); diff --git a/packages/server/src/resolvers/sqlite-auth.ts b/packages/server/src/resolvers/sqlite-auth.ts index b48af429..32ac5757 100644 --- a/packages/server/src/resolvers/sqlite-auth.ts +++ b/packages/server/src/resolvers/sqlite-auth.ts @@ -1,7 +1,7 @@ import { GraphQLError } from 'graphql'; import { sqliteAuthStore } from '../auth/sqlite-auth.js'; import { generateToken } from '../utils/auth.js'; -import { verifyCaptcha } from '../utils/captcha.js'; +import { verifyAuthCaptcha } from '../utils/captcha.js'; import { randomUUID } from 'node:crypto'; import { folderActor, folderCreationOwner, folderResponse, requireFolderAccess, requireFolderDeletion, requireFolderParent, requireGraphAccess, visibleFolderGraphs } from '../auth/folder-access.js'; import { passwordLoginLimiter, PasswordLoginLimitError } from '../auth/password-login-limiter.js'; @@ -329,17 +329,11 @@ export const sqliteAuthResolvers = { const user = await sqliteAuthStore.findUserByEmailOrUsername(identifier); accountAttempt = passwordLoginLimiter.reserveAccount(user ? `user:${user.id}` : `identifier:${identifier}`); - // Verify CAPTCHA if provided - if (input.captchaPayload) { - const isCaptchaValid = await verifyCaptcha(input.captchaPayload); - if (!isCaptchaValid) { - outcome = 'failure'; - console.log('❌ CAPTCHA verification failed'); - throw new GraphQLError('CAPTCHA verification failed', { - extensions: { code: 'CAPTCHA_FAILED' } - }); - } - console.log('✅ CAPTCHA verified'); + if (!await verifyAuthCaptcha(input.captchaPayload, 'login')) { + outcome = 'failure'; + throw new GraphQLError('Please complete verification and try again.', { + extensions: { code: 'CAPTCHA_FAILED' } + }); } if (!user) { @@ -441,16 +435,10 @@ export const sqliteAuthResolvers = { }); } - // Verify CAPTCHA if provided - if (input.captchaPayload) { - const isCaptchaValid = await verifyCaptcha(input.captchaPayload); - if (!isCaptchaValid) { - console.log('❌ CAPTCHA verification failed'); - throw new GraphQLError('CAPTCHA verification failed', { - extensions: { code: 'CAPTCHA_FAILED' } - }); - } - console.log('✅ CAPTCHA verified'); + if (!await verifyAuthCaptcha(input.captchaPayload, 'signup')) { + throw new GraphQLError('Please complete verification and try again.', { + extensions: { code: 'CAPTCHA_FAILED' } + }); } // Check if user already exists diff --git a/packages/server/src/schema/auth-only-schema.ts b/packages/server/src/schema/auth-only-schema.ts index 38765b46..970877ef 100644 --- a/packages/server/src/schema/auth-only-schema.ts +++ b/packages/server/src/schema/auth-only-schema.ts @@ -52,6 +52,7 @@ export const authOnlyTypeDefs = gql` } input SignupInput { + captchaPayload: String email: String! username: String! password: String! @@ -60,6 +61,7 @@ export const authOnlyTypeDefs = gql` } input LoginInput { + captchaPayload: String emailOrUsername: String! password: String! } @@ -284,4 +286,4 @@ export const authOnlyTypeDefs = gql` graphId: String! position: Int! } -`; \ No newline at end of file +`; diff --git a/packages/server/src/utils/captcha.ts b/packages/server/src/utils/captcha.ts index 3a3fdec8..16eba36a 100644 --- a/packages/server/src/utils/captcha.ts +++ b/packages/server/src/utils/captcha.ts @@ -1,41 +1,95 @@ -import { verifySolution, createChallenge } from 'altcha-lib'; +import { randomBytes } from 'node:crypto'; +import { createChallenge, verifySolution } from 'altcha-lib'; -const ALTCHA_HMAC_KEY = process.env.ALTCHA_HMAC_KEY || 'your-secret-hmac-key-change-in-production'; +export const CAPTCHA_ACTIONS = ['login', 'signup', 'magic-link', 'forgot-password', 'reset-password'] as const; +export type CaptchaAction = typeof CAPTCHA_ACTIONS[number]; +export function isCaptchaAction(value: unknown): value is CaptchaAction { + return typeof value === 'string' && CAPTCHA_ACTIONS.some(action => action === value); +} + +/** Development quick-login remains available; production policy never comes + * from a request or browser setting. Supplied proofs are checked in every mode. */ +export function captchaRequired(action: CaptchaAction): boolean { + return !['development', 'test'].includes(process.env.NODE_ENV || '') || !['login', 'signup'].includes(action); +} + +export class CaptchaCapacityError extends Error {} -export async function verifyCaptcha(payload: string | null | undefined): Promise { - if (!payload) { - return false; +export class CaptchaService { + private readonly issued = new Map(); + private pending = 0; + + constructor(private readonly options: { + key: string; + now?: () => number; + ttlMs?: number; + capacity?: number; + maxNumber?: number; + }) {} + + private now(): number { return this.options.now?.() ?? Date.now(); } + private prune(): void { + const now = this.now(); + for (const [id, entry] of this.issued) if (entry.expires <= now) this.issued.delete(id); } - try { - // Check if it's a simple code (6 alphanumeric characters) - // This is for the CodeCaptcha component - const simpleCodePattern = /^[A-Z0-9]{6}$/; - if (simpleCodePattern.test(payload)) { - console.log('✅ Code CAPTCHA verified:', payload); - return true; + async create(action: CaptchaAction) { + if (!isCaptchaAction(action)) throw new Error('Invalid CAPTCHA action'); + this.prune(); + if (this.issued.size + this.pending >= (this.options.capacity ?? 5000)) { + throw new CaptchaCapacityError('Verification is busy. Please try again shortly.'); } + this.pending += 1; + try { + const expires = this.now() + (this.options.ttlMs ?? 300000); + const challenge = await createChallenge({ + hmacKey: this.options.key, algorithm: 'SHA-256', + maxNumber: this.options.maxNumber ?? 100000, + saltLength: 16, expires: new Date(expires), params: { action }, + }); + this.issued.set(challenge.challenge, { action, expires, salt: challenge.salt, signature: challenge.signature }); + return challenge; + } finally { this.pending -= 1; } + } - // Otherwise, try to verify as Altcha payload - const isValid = await verifySolution(payload, ALTCHA_HMAC_KEY); - return isValid; - } catch (error) { - console.error('CAPTCHA verification error:', error); - return false; + async verify(payload: unknown, action: CaptchaAction): Promise { + if (!isCaptchaAction(action) || typeof payload !== 'string' || !payload || payload.length > 4096) return false; + this.prune(); + try { + const proof = JSON.parse(Buffer.from(payload, 'base64').toString('utf8')); + if (!proof || proof.algorithm !== 'SHA-256' || !Number.isSafeInteger(proof.number) || proof.number < 0 || + proof.number > (this.options.maxNumber ?? 100000) || typeof proof.challenge !== 'string') return false; + const entry = this.issued.get(proof.challenge); + if (!entry || entry.action !== action || entry.salt !== proof.salt || entry.signature !== proof.signature) return false; + if (!await verifySolution(proof, this.options.key, false)) return false; + if (entry.expires <= this.now() || this.issued.get(proof.challenge) !== entry) return false; + return this.issued.delete(proof.challenge); + } catch { return false; } } } -export async function createCaptchaChallenge() { - try { - const challenge = await createChallenge({ - hmacKey: ALTCHA_HMAC_KEY, - maxNumber: 100000, - saltLength: 12, - algorithm: 'SHA-256', - }); - return challenge; - } catch (error) { - console.error('CAPTCHA challenge creation error:', error); - throw new Error('Failed to create CAPTCHA challenge'); +let service: CaptchaService | undefined; +function getService(): CaptchaService { + if (!service) { + const configured = process.env.ALTCHA_HMAC_KEY?.trim(); + if (configured !== undefined && (configured.length < 32 || configured === 'your-secret-hmac-key-change-in-production')) { + throw new Error('ALTCHA_HMAC_KEY must be a random secret of at least 32 characters, or unset for a process-local key.'); + } + // Outstanding proofs are invalid after restart, matching the bounded ledger. + service = new CaptchaService({ key: configured ?? randomBytes(32).toString('hex') }); } + return service; +} + +export async function verifyCaptcha(payload: unknown, action: CaptchaAction): Promise { + return getService().verify(payload, action); +} + +export async function verifyAuthCaptcha(payload: unknown, action: CaptchaAction): Promise { + if ((payload === undefined || payload === null || payload === '') && !captchaRequired(action)) return true; + return verifyCaptcha(payload, action); +} + +export async function createCaptchaChallenge(action: CaptchaAction) { + return getService().create(action); } diff --git a/packages/server/tests/captcha-auth.test.ts b/packages/server/tests/captcha-auth.test.ts new file mode 100644 index 00000000..810d3863 --- /dev/null +++ b/packages/server/tests/captcha-auth.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import sqlite3 from 'sqlite3'; +import express from 'express'; +import { createServer, type Server } from 'node:http'; +import { ApolloServer } from '@apollo/server'; +import { expressMiddleware } from '@as-integrations/express4'; +import { solveChallenge } from 'altcha-lib'; +import { sqliteAuthStore } from '../src/auth/sqlite-auth'; +import { sqliteAuthResolvers } from '../src/resolvers/sqlite-auth'; +import { authOnlyTypeDefs } from '../src/schema/auth-only-schema'; +import { createCaptchaChallenge, type CaptchaAction } from '../src/utils/captcha'; + +let database: sqlite3.Database; +let server: Server; +let apollo: ApolloServer; +let endpoint: string; +let account: { id: string; email: string }; +const password = 'OwnedCaptchaFixture!94'; +const store = sqliteAuthStore as unknown as { db: sqlite3.Database | null; initialized: boolean }; + +async function proof(action: CaptchaAction) { + const challenge = await createCaptchaChallenge(action); + const solution = await solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, challenge.maxnumber).promise; + if (!solution) throw new Error('Could not solve owned challenge'); + return Buffer.from(JSON.stringify({ ...challenge, number: solution.number })).toString('base64'); +} + +async function request(operation: 'login' | 'signup', input: Record) { + const response = await fetch(endpoint, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ query: `mutation($input:${operation === 'login' ? 'LoginInput' : 'SignupInput'}!){renamed:${operation}(input:$input){token user{id}}}`, variables: { input } }), + }); + return response.json(); +} + +beforeAll(async () => { + vi.stubEnv('NODE_ENV', 'production'); + vi.stubEnv('ADMIN_EMAIL', 'captcha-seed@example.invalid'); + vi.stubEnv('ADMIN_PASSWORD', 'OwnedSeedPassword!94'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + database = await new Promise((resolve, reject) => { + const connection = new sqlite3.Database(':memory:', error => error ? reject(error) : resolve(connection)); + }); + store.db = database; store.initialized = false; + await sqliteAuthStore.initialize(); + account = await sqliteAuthStore.createUser({ email: 'captcha-login@example.invalid', username: 'captcha-login', name: 'Owned CAPTCHA fixture', password, role: 'USER' }); + apollo = new ApolloServer({ typeDefs: authOnlyTypeDefs, resolvers: { Mutation: { + login: sqliteAuthResolvers.Mutation.login, signup: sqliteAuthResolvers.Mutation.signup, + } } }); + await apollo.start(); + const app = express(); + app.use('/graphql', express.json(), expressMiddleware(apollo, { context: async ({ req }) => ({ req }) })); + server = createServer(app); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected an owned HTTP listener'); + endpoint = `http://127.0.0.1:${address.port}/graphql`; +}); + +afterAll(async () => { + if (apollo) await apollo.stop(); + if (server) await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + if (database) await new Promise((resolve, reject) => database.close(error => error ? reject(error) : resolve())); + store.db = null; store.initialized = false; + vi.restoreAllMocks(); vi.unstubAllEnvs(); +}); + +describe('production CAPTCHA at the actual HTTP GraphQL boundary', () => { + it('refuses missing and forged proofs despite a correct password', async () => { + for (const captchaPayload of [undefined, 'ABC123']) { + const result = await request('login', { emailOrUsername: account.email, password, captchaPayload }); + expect(result.errors?.[0]?.extensions.code).toBe('CAPTCHA_FAILED'); + expect(result.data).toBeNull(); + } + }); + + it('accepts a valid proof once and rejects a proof for another action', async () => { + const input = { emailOrUsername: account.email, password }; + const wrong = await request('login', { ...input, captchaPayload: await proof('signup') }); + expect(wrong.errors?.[0]?.extensions.code).toBe('CAPTCHA_FAILED'); + const captchaPayload = await proof('login'); + const accepted = await request('login', { ...input, captchaPayload }); + expect(accepted.errors).toBeUndefined(); + expect(accepted.data.renamed.user.id).toBe(account.id); + expect(typeof accepted.data.renamed.token).toBe('string'); + const replay = await request('login', { ...input, captchaPayload }); + expect(replay.errors?.[0]?.extensions.code).toBe('CAPTCHA_FAILED'); + expect(replay.data).toBeNull(); + }, 20000); + + it('does not create an account without a valid signup proof', async () => { + const input = { email: 'captcha-signup@example.invalid', username: 'captcha-signup', password, name: 'Owned signup' }; + for (const captchaPayload of [undefined, 'ABC123']) { + const result = await request('signup', { ...input, captchaPayload }); + expect(result.errors?.[0]?.extensions.code).toBe('CAPTCHA_FAILED'); + expect(await sqliteAuthStore.findUserByEmailOrUsername(input.email)).toBeNull(); + } + const accepted = await request('signup', { ...input, captchaPayload: await proof('signup') }); + expect(accepted.errors).toBeUndefined(); + expect(accepted.data.renamed.user.id).toBeTruthy(); + expect((await sqliteAuthStore.findUserByEmailOrUsername(input.email))?.id).toBe(accepted.data.renamed.user.id); + }, 20000); +}); diff --git a/packages/server/tests/captcha.test.ts b/packages/server/tests/captcha.test.ts new file mode 100644 index 00000000..1a00c0dd --- /dev/null +++ b/packages/server/tests/captcha.test.ts @@ -0,0 +1,95 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { solveChallenge } from 'altcha-lib'; +import { randomBytes } from 'node:crypto'; +import { CaptchaService, CaptchaCapacityError, verifyCaptcha, verifyAuthCaptcha, captchaRequired, CAPTCHA_ACTIONS } from '../src/utils/captcha'; +import { validateProductionSecrets } from '../src/config/secrets'; + +const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64'); +async function solve(challenge: Awaited>) { + const solution = await solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, challenge.maxnumber).promise; + if (!solution) throw new Error('Owned challenge was not solved'); + return encode({ ...challenge, number: solution.number }); +} +function fixture(options: Partial[0]> = {}) { + return new CaptchaService({ key: randomBytes(32).toString('hex'), maxNumber: 20, ...options }); +} + +afterEach(() => vi.unstubAllEnvs()); + +it('rejects arbitrary six-character client codes', async () => { + expect(await verifyCaptcha('ABC123', 'magic-link')).toBe(false); +}); + +it('rejects malformed and absent proof', async () => { + for (const payload of [undefined, null, '', 'not-a-proof', 'e30=']) { + expect(await verifyCaptcha(payload, 'magic-link')).toBe(false); + } +}); + +it('enforces production policy for every action and rejects supplied invalid development proof', async () => { + vi.stubEnv('NODE_ENV', 'production'); + for (const action of CAPTCHA_ACTIONS) { + expect(captchaRequired(action)).toBe(true); + expect(await verifyAuthCaptcha(undefined, action)).toBe(false); + expect(await verifyAuthCaptcha('ABC123', action)).toBe(false); + } + vi.stubEnv('NODE_ENV', 'development'); + expect(await verifyAuthCaptcha(undefined, 'login')).toBe(true); + expect(await verifyAuthCaptcha(undefined, 'signup')).toBe(true); + expect(await verifyAuthCaptcha(undefined, 'magic-link')).toBe(false); + expect(await verifyAuthCaptcha('ABC123', 'login')).toBe(false); +}); + +it('accepts a signed solution once, bound to its original action', async () => { + const service = fixture(); + const proof = await solve(await service.create('magic-link')); + expect(await service.verify(proof, 'reset-password')).toBe(false); + expect(await service.verify(proof, 'magic-link')).toBe(true); + expect(await service.verify(proof, 'magic-link')).toBe(false); +}); + +it('allows exactly one concurrent redemption', async () => { + const service = fixture(); + const proof = await solve(await service.create('login')); + const results = await Promise.all(Array.from({ length: 20 }, () => service.verify(proof, 'login'))); + expect(results.filter(Boolean)).toHaveLength(1); +}); + +it('rejects tampering, foreign signatures, oversized input and out-of-range numbers', async () => { + const service = fixture(); + const challenge = await service.create('signup'); + const proof = await solve(challenge); + const parsed = JSON.parse(Buffer.from(proof, 'base64').toString()); + for (const changed of [{ signature: '0'.repeat(64) }, { salt: 'other' }, { algorithm: 'SHA-1' }, { number: -1 }, { number: 21 }, { number: 0.5 }]) { + expect(await service.verify(encode({ ...parsed, ...changed }), 'signup')).toBe(false); + } + expect(await fixture().verify(proof, 'signup')).toBe(false); + expect(await service.verify('A'.repeat(4097), 'signup')).toBe(false); + expect(await service.verify(proof, 'signup')).toBe(true); +}); + +it('expires challenges and never evicts a live proof to admit more work', async () => { + let now = Date.now(); + const service = fixture({ capacity: 1, ttlMs: 1000, now: () => now }); + const proof = await solve(await service.create('login')); + await expect(service.create('login')).rejects.toBeInstanceOf(CaptchaCapacityError); + now += 1000; + expect(await service.verify(proof, 'login')).toBe(false); + const fresh = await solve(await service.create('login')); + expect(await service.verify(fresh, 'login')).toBe(true); +}); + +it('reserves issuance capacity before asynchronous challenge creation', async () => { + const service = fixture({ capacity: 2 }); + const results = await Promise.allSettled(Array.from({ length: 20 }, () => service.create('login'))); + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(2); + expect(results.filter(result => result.status === 'rejected')).toHaveLength(18); +}); + +it('rejects the old production HMAC fallback and permits an unset process-local key', () => { + const environment = { NODE_ENV: 'production', JWT_SECRET: randomBytes(32).toString('hex'), SESSION_SECRET: randomBytes(32).toString('hex') }; + expect(() => validateProductionSecrets(environment)).not.toThrow(); + for (const ALTCHA_HMAC_KEY of ['', 'short', 'your-secret-hmac-key-change-in-production']) { + expect(() => validateProductionSecrets({ ...environment, ALTCHA_HMAC_KEY })).toThrow(/ALTCHA_HMAC_KEY/); + } +}); diff --git a/packages/server/tests/production-startup.test.ts b/packages/server/tests/production-startup.test.ts index 2aad11a8..51ca95dd 100644 --- a/packages/server/tests/production-startup.test.ts +++ b/packages/server/tests/production-startup.test.ts @@ -86,6 +86,7 @@ describe('production API startup secret boundary', () => { ['blank SESSION_SECRET', 'SESSION_SECRET', ' '], ['fallback JWT_SECRET', 'JWT_SECRET', 'your-dev-secret-key'], ['fallback SESSION_SECRET', 'SESSION_SECRET', 'graphdone-default-secret-change-in-production'], + ['fallback ALTCHA_HMAC_KEY', 'ALTCHA_HMAC_KEY', 'your-secret-hmac-key-change-in-production'], ])('rejects %s before database initialization or listening', async (_label, name, value) => { if (value === undefined) delete process.env[name!]; else vi.stubEnv(name!, value); diff --git a/packages/web/src/components/AuthCaptcha.test.tsx b/packages/web/src/components/AuthCaptcha.test.tsx new file mode 100644 index 00000000..8328fc53 --- /dev/null +++ b/packages/web/src/components/AuthCaptcha.test.tsx @@ -0,0 +1,48 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AuthCaptcha } from './AuthCaptcha'; + +vi.mock('altcha', () => ({})); +afterEach(() => { vi.unstubAllGlobals(); }); + +describe('authentication verification UI', () => { + it('clears proof on expiry and retry, and requests the intended server action', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ captcha: { provider: 'altcha' } }) })); + const verified = vi.fn(); + const { container, rerender } = render(); + await screen.findByTestId('server-captcha'); + const widget = container.querySelector('altcha-widget')!; + expect(widget.getAttribute('challengeurl')).toContain('/api/captcha/challenge?action=login'); + act(() => widget.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'verified', payload: 'issued-proof' } }))); + expect(verified).toHaveBeenLastCalledWith('issued-proof'); + expect(screen.getByRole('status')).toHaveTextContent('Verification complete'); + act(() => widget.dispatchEvent(new CustomEvent('statechange', { detail: { state: 'expired' } }))); + expect(verified).toHaveBeenLastCalledWith(''); + expect(screen.getByRole('status')).toHaveTextContent('expired'); + fireEvent.click(screen.getByRole('button', { name: 'Retry verification' })); + expect(container.querySelector('altcha-widget')).not.toBe(widget); + expect(verified).toHaveBeenLastCalledWith(''); + rerender(); + expect(container.querySelector('altcha-widget')!.getAttribute('challengeurl')).toContain('action=reset-password'); + }); + + it('shows an accessible failure and retry when policy loading fails', async () => { + const fetch = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ ok: true, json: async () => ({ captcha: { provider: 'altcha' } }) }); + vi.stubGlobal('fetch', fetch); + const verified = vi.fn(); + render(); + expect(await screen.findByRole('alert')).toHaveTextContent('Verification is unavailable'); + expect(screen.queryByLabelText('Verification answer')).toBeNull(); + expect(verified.mock.calls.every(([value]) => value === '')).toBe(true); + fireEvent.click(screen.getByRole('button', { name: 'Retry verification' })); + await screen.findByTestId('server-captcha'); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('retains an explicitly advertised backend without Core CAPTCHA support', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ oauth: { providers: {} } }) })); + const { container } = render( {}} />); + await waitFor(() => expect(screen.queryByRole('status')).toBeNull()); + expect(container.querySelector('altcha-widget')).toBeNull(); + }); +}); diff --git a/packages/web/src/components/AuthCaptcha.tsx b/packages/web/src/components/AuthCaptcha.tsx new file mode 100644 index 00000000..bcc79143 --- /dev/null +++ b/packages/web/src/components/AuthCaptcha.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react'; +import type {} from 'altcha'; +import { CodeCaptcha } from './CodeCaptcha'; + +type Action = 'login' | 'signup' | 'magic-link' | 'forgot-password' | 'reset-password'; + +/** Core advertises server verification in /config. Older/Cloud backends retain + * their existing contract; a failed policy request never becomes a success. */ +export function AuthCaptcha({ action, onVerified, resetKey = 0, autoFocus = false, compact = false, className = '' }: { + action: Action; + onVerified: (payload: string) => void; + resetKey?: number; + autoFocus?: boolean; + compact?: boolean; + className?: string; +}) { + const [policy, setPolicy] = useState<'loading' | 'altcha' | 'legacy' | 'error'>('loading'); + const [ready, setReady] = useState(false); + const [reload, setReload] = useState(0); + const [attempt, setAttempt] = useState(0); + const [status, setStatus] = useState('Complete verification before continuing.'); + const widget = useRef(null); + const focusOnMount = useRef(autoFocus); + const callback = useRef(onVerified); + callback.current = onVerified; + const apiUrl = (import.meta.env.VITE_API_URL || window.location.origin).replace(/\/$/, ''); + + useEffect(() => { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 15000); + let active = true; + callback.current(''); + setPolicy('loading'); + void (async () => { + try { + const response = await fetch(`${apiUrl}/config`, { signal: controller.signal }); + if (!response.ok) throw new Error('Verification policy unavailable'); + const config = await response.json(); + if (config.captcha?.provider === 'altcha') { + await import('altcha'); + if (active) { setReady(true); setPolicy('altcha'); } + } else if (active) setPolicy('legacy'); + } catch { + if (active) setPolicy('error'); + } finally { window.clearTimeout(timeout); } + })(); + return () => { active = false; controller.abort(); window.clearTimeout(timeout); }; + }, [apiUrl, reload]); + + useEffect(() => { + callback.current(''); + setStatus('Complete verification before continuing.'); + const element = widget.current; + if (!element) return; + const changed = (event: Event) => { + const { state, payload } = (event as CustomEvent<{ state: string; payload?: string }>).detail; + callback.current(state === 'verified' && payload ? payload : ''); + setStatus(state === 'verified' ? 'Verification complete.' + : state === 'verifying' ? 'Verifying…' + : state === 'expired' ? 'Verification expired. Please verify again.' + : state === 'error' ? 'Verification failed. Please retry.' + : 'Complete verification before continuing.'); + }; + element.addEventListener('statechange', changed); + return () => element.removeEventListener('statechange', changed); + }, [policy, ready, action, resetKey, attempt]); + + useEffect(() => { + const element = widget.current; + const wideScreen = typeof window.matchMedia !== 'function' || window.matchMedia('(min-width: 1024px)').matches; + if (!element || !(focusOnMount.current && wideScreen || attempt > 0 || reload > 0)) return; + // The widget's autofocus option concerns verification completion. Focus its + // initial control once on mode entry/retry, never when the user starts typing. + const root = element.shadowRoot || element; + const focus = () => { + const input = root.querySelector('input[type="checkbox"]'); + input?.focus(); + return Boolean(input); + }; + if (focus()) return; + const observer = new MutationObserver(() => { if (focus()) observer.disconnect(); }); + observer.observe(root, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [policy, ready, action, resetKey, attempt, reload]); + + if (policy === 'loading') return

Loading verification…

; + if (policy === 'error') return
+

Verification is unavailable. Please try again.

+ +
; + if (policy === 'legacy') return action === 'login' || action === 'signup' ? null + : ; + return
+ {ready &&
} +

{status}

+ +
; +} diff --git a/packages/web/src/pages/ForgotPassword.tsx b/packages/web/src/pages/ForgotPassword.tsx index 2261dd68..df472a35 100644 --- a/packages/web/src/pages/ForgotPassword.tsx +++ b/packages/web/src/pages/ForgotPassword.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; import { Mail, ArrowLeft, CheckCircle, XCircle, Shield } from 'lucide-react'; import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; -import { CodeCaptcha } from '../components/CodeCaptcha'; +import { AuthCaptcha } from '../components/AuthCaptcha'; import { isValidEmail } from '../utils/validation'; export function ForgotPassword() { @@ -14,6 +14,7 @@ export function ForgotPassword() { const [rateLimitError, setRateLimitError] = useState(''); const [rateLimitRetryAfter, setRateLimitRetryAfter] = useState(null); const [captchaPayload, setCaptchaPayload] = useState(''); + const [captchaReset, setCaptchaReset] = useState(0); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -34,7 +35,7 @@ export function ForgotPassword() { setRateLimitRetryAfter(null); try { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:4127'; + const apiUrl = import.meta.env.VITE_API_URL || window.location.origin; const response = await fetch(`${apiUrl}/auth/forgot-password`, { method: 'POST', headers: { @@ -60,6 +61,7 @@ export function ForgotPassword() { } catch { setError('Failed to send reset link. Please try again.'); } finally { + setCaptchaReset(value => value + 1); setLoading(false); } }; @@ -192,9 +194,8 @@ export function ForgotPassword() { {/* CAPTCHA */} - setCaptchaPayload(code)} - onError={() => setCaptchaPayload('')} /> {/* Submit Button */} diff --git a/packages/web/src/pages/ResetPassword.tsx b/packages/web/src/pages/ResetPassword.tsx index 1648a3e3..f2ae6751 100644 --- a/packages/web/src/pages/ResetPassword.tsx +++ b/packages/web/src/pages/ResetPassword.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { useSearchParams, useNavigate, Link } from 'react-router-dom'; import { Lock, Eye, EyeOff, CheckCircle, XCircle, ArrowLeft } from 'lucide-react'; import { InsecureConnectionBanner } from '../components/TlsStatusIndicator'; -import { CodeCaptcha } from '../components/CodeCaptcha'; +import { AuthCaptcha } from '../components/AuthCaptcha'; import { PasswordRequirements } from '../components/PasswordRequirements'; import { validatePassword, getPasswordStrength } from '../utils/validation'; @@ -20,6 +20,7 @@ export function ResetPassword() { const [error, setError] = useState(''); const [passwordsMatch, setPasswordsMatch] = useState(null); const [captchaPayload, setCaptchaPayload] = useState(''); + const [captchaReset, setCaptchaReset] = useState(0); useEffect(() => { if (!token) { @@ -47,7 +48,7 @@ export function ResetPassword() { setError(''); try { - const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:4127'; + const apiUrl = import.meta.env.VITE_API_URL || window.location.origin; const response = await fetch(`${apiUrl}/auth/reset-password`, { method: 'POST', headers: { @@ -69,6 +70,7 @@ export function ResetPassword() { } catch { setError('Failed to reset password. Please try again.'); } finally { + setCaptchaReset(value => value + 1); setLoading(false); } }; @@ -218,9 +220,8 @@ export function ResetPassword() { {/* CAPTCHA */} - setCaptchaPayload(code)} - onError={() => setCaptchaPayload('')} /> ; + if (optional) return null; if (policy === 'legacy') return action === 'login' || action === 'signup' ? null : ; return
@@ -97,7 +110,7 @@ export function AuthCaptcha({ action, onVerified, resetKey = 0, autoFocus = fals style={{ '--altcha-max-width': '100%', '--altcha-color-base': '#1f2937', '--altcha-color-text': '#f3f4f6' }} />
}

{status}

; } diff --git a/packages/web/src/pages/ForgotPassword.tsx b/packages/web/src/pages/ForgotPassword.tsx index df472a35..04367e70 100644 --- a/packages/web/src/pages/ForgotPassword.tsx +++ b/packages/web/src/pages/ForgotPassword.tsx @@ -61,6 +61,7 @@ export function ForgotPassword() { } catch { setError('Failed to send reset link. Please try again.'); } finally { + setCaptchaPayload(''); setCaptchaReset(value => value + 1); setLoading(false); } diff --git a/packages/web/src/pages/ResetPassword.tsx b/packages/web/src/pages/ResetPassword.tsx index f2ae6751..fba9feae 100644 --- a/packages/web/src/pages/ResetPassword.tsx +++ b/packages/web/src/pages/ResetPassword.tsx @@ -70,6 +70,7 @@ export function ResetPassword() { } catch { setError('Failed to reset password. Please try again.'); } finally { + setCaptchaPayload(''); setCaptchaReset(value => value + 1); setLoading(false); } diff --git a/packages/web/src/pages/Signin.captcha.test.tsx b/packages/web/src/pages/Signin.captcha.test.tsx new file mode 100644 index 00000000..e1eed0cf --- /dev/null +++ b/packages/web/src/pages/Signin.captcha.test.tsx @@ -0,0 +1,69 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { Signin } from './Signin'; + +type CaptchaProps = { onVerified: (proof: string) => void; onReadyChange?: (ready: boolean) => void; resetKey: number }; +const fixture = vi.hoisted(() => ({ + mutate: vi.fn(async () => ({})), + captcha: null as CaptchaProps | null, + callbacks: null as { onCompleted: (data: unknown) => void } | null, +})); +vi.mock('../contexts/AuthContext', () => ({ useAuth: () => ({ login: vi.fn() }) })); +vi.mock('../components/AuthCaptcha', () => ({ AuthCaptcha: (props: CaptchaProps) => { + fixture.captcha = props; + return
Owned verification control
; +} })); +vi.mock('@apollo/client', async original => ({ + ...await original(), + useQuery: () => ({ data: undefined }), + useMutation: (document: { definitions: Array<{ name?: { value: string } }> }, options: typeof fixture.callbacks) => { + if (document.definitions[0].name?.value === 'Login') fixture.callbacks = options; + return [fixture.mutate, { loading: false }]; + }, +})); + +beforeEach(() => { + localStorage.clear(); + fixture.mutate.mockClear(); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); +}); +afterEach(() => { vi.unstubAllGlobals(); }); + +function form() { + render(); + fireEvent.change(screen.getByLabelText('Email address or username'), { target: { value: 'owned@example.invalid' } }); + const password = screen.getByLabelText('Password', { exact: true }); + fireEvent.change(password, { target: { value: 'OwnedPassword!94' } }); + return { form: password.closest('form')!, submit: screen.getByRole('button', { name: 'Sign In' }) }; +} + +it('never sends login while proof is pending/failed/expired, including direct form submission', () => { + const view = form(); + expect(view.submit).toBeDisabled(); + fireEvent.submit(view.form); + expect(fixture.mutate).not.toHaveBeenCalled(); + act(() => { fixture.captcha!.onVerified('owned-proof'); fixture.captcha!.onReadyChange!(true); }); + expect(view.submit).toBeEnabled(); + fireEvent.submit(view.form); + expect(fixture.mutate).toHaveBeenCalledTimes(1); + act(() => { fixture.captcha!.onVerified(''); fixture.captcha!.onReadyChange!(false); }); + expect(view.submit).toBeDisabled(); + fireEvent.submit(view.form); + expect(fixture.mutate).toHaveBeenCalledTimes(1); + // An explicitly optional development/legacy action can proceed without proof. + act(() => fixture.captcha!.onReadyChange!(true)); + fireEvent.submit(view.form); + expect(fixture.mutate).toHaveBeenCalledTimes(2); +}); + +it('clears consumed proof when unverified email keeps the form open', () => { + const view = form(); + act(() => { fixture.captcha!.onVerified('owned-proof'); fixture.captcha!.onReadyChange!(true); }); + act(() => fixture.callbacks!.onCompleted({ login: { user: { isEmailVerified: false } } })); + expect(view.submit).toBeDisabled(); + expect(fixture.captcha!.resetKey).toBe(1); + expect(screen.getByRole('alert')).toHaveTextContent('Please verify your email'); + fireEvent.submit(view.form); + expect(fixture.mutate).not.toHaveBeenCalled(); +}); diff --git a/packages/web/src/pages/Signin.tsx b/packages/web/src/pages/Signin.tsx index 707a709d..640aa0e5 100644 --- a/packages/web/src/pages/Signin.tsx +++ b/packages/web/src/pages/Signin.tsx @@ -96,6 +96,7 @@ export function Signin({ initialMagicLink = true }: { initialMagicLink?: boolean }); const [captchaPayload, setCaptchaPayload] = useState(null); const [captchaReset, setCaptchaReset] = useState(0); + const [passwordCaptchaReady, setPasswordCaptchaReady] = useState(false); const [magicLinkCaptchaPayload, setMagicLinkCaptchaPayload] = useState(null); const magicLinkEmailRef = useRef(null); const [loginCodeInput, setLoginCodeInput] = useState(''); @@ -265,6 +266,9 @@ export function Signin({ initialMagicLink = true }: { initialMagicLink?: boolean const [login, { loading }] = useMutation(LOGIN_MUTATION, { onCompleted: (data) => { if (!data.login.user.isEmailVerified) { + setPasswordCaptchaReady(false); + setCaptchaPayload(null); + setCaptchaReset(value => value + 1); setErrors({ submit: 'Please verify your email before logging in. Check your inbox for the verification link.' }); @@ -277,6 +281,8 @@ export function Signin({ initialMagicLink = true }: { initialMagicLink?: boolean navigate('/'); }, onError: (error) => { + setPasswordCaptchaReady(false); + setCaptchaPayload(null); setCaptchaReset(value => value + 1); const newAttempts = loginAttempts + 1; setLoginAttempts(newAttempts); @@ -329,6 +335,10 @@ export function Signin({ initialMagicLink = true }: { initialMagicLink?: boolean if (!validateForm()) { return; } + if (!passwordCaptchaReady) { + setErrors({ submit: 'Please complete verification before signing in.' }); + return; + } if (rememberMe) { localStorage.setItem('rememberedUsername', formData.emailOrUsername); @@ -451,6 +461,7 @@ export function Signin({ initialMagicLink = true }: { initialMagicLink?: boolean } catch { setErrors({ submit: 'We could not reach the sign-in service. Please try again shortly.' }); } finally { + setMagicLinkCaptchaPayload(null); setCaptchaReset(value => value + 1); setMagicLinkLoading(false); } @@ -946,7 +957,7 @@ export function Signin({ initialMagicLink = true }: { initialMagicLink?: boolean {errors.password && } - + {/* Keep me logged in & Forgot Password */}
@@ -1015,7 +1026,7 @@ export function Signin({ initialMagicLink = true }: { initialMagicLink?: boolean {/* Submit Button */}