diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index dde0d523d..ca118f903 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -2534,6 +2534,312 @@ describe('AuthController.handleCardVerificationConfirm', () => { }); }); +describe('AuthController SMS → card fallback', () => { + const stubPrelude = (over: Record = {}) => ({ + isConfigured: () => true, + isCountrySupported: () => true, + defaultCountry: 'US', + createVerification: vi.fn(async () => ({ status: 'success' })), + ...over, + }); + const withPrelude = async ( + prelude: unknown, + fn: () => Promise, + ): Promise => { + const ctrl = controller as { clients: { prelude: unknown } }; + const real = ctrl.clients.prelude; + ctrl.clients.prelude = prelude; + try { + await fn(); + } finally { + ctrl.clients.prelude = real; + } + }; + const withFallbackConfig = async ( + value: unknown, + fn: () => Promise, + ): Promise => { + const cfg = (controller as { config: Record }).config; + const prev = cfg.phone_verification_card_fallback; + cfg.phone_verification_card_fallback = value; + try { + await fn(); + } finally { + cfg.phone_verification_card_fallback = prev; + } + }; + // Drive the attempt counter directly so the threshold is deterministic + // (the handler keys it the same way: `phone-verify-attempts:`). + const seedAttempts = (userId: number, attempts: number) => + server.stores.kv.incr({ + key: `phone-verify-attempts:${userId}`, + pathAndAmountMap: { attempts }, + }); + // Stamp the eligibility flag the card endpoints check, the same way a + // threshold-crossing send does (`card-fallback-open:`). + const openFallback = (userId: number) => + server.stores.kv.set({ + key: `card-fallback-open:${userId}`, + value: true, + }); + + it('offers the fallback on send once the attempt threshold is reached', async () => { + const { actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + // No after_attempts → exercises the default threshold of 2. + await withFallbackConfig({ enabled: true }, async () => { + await withPrelude(stubPrelude(), async () => { + const first = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + first, + ); + // First attempt is below the threshold — no offer yet. + expect(first.body).toEqual({}); + + const second = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + second, + ); + expect(second.body).toEqual({ + card_fallback_available: true, + }); + }); + }); + }); + + it('never offers the fallback on send when disabled', async () => { + const { actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + await withFallbackConfig( + { enabled: false, after_attempts: 1 }, + async () => { + await withPrelude(stubPrelude(), async () => { + const res = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + res, + ); + expect(res.body).toEqual({}); + }); + }, + ); + }); + + it('flags the fallback on a Prelude block once eligible', async () => { + const { actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + await withFallbackConfig( + { enabled: true, after_attempts: 1 }, + async () => { + await withPrelude( + stubPrelude({ + createVerification: vi.fn(async () => ({ + status: 'blocked', + })), + }), + async () => { + await expect( + controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ + statusCode: 429, + fields: { card_fallback_available: true }, + }); + }, + ); + }, + ); + }); + + it('lets card setup proceed past the phone gate once eligible', async () => { + const { user, actor } = await makeUserAndActor({ + requires_card_verification: 1, + requires_phone_verification: 1, + phone: '+14155550123', + }); + await openFallback(user.id); + await withFallbackConfig( + { enabled: true }, + async () => { + const res = makeRes(); + await withCardSetupOverride( + (data) => { + data.enabled = true; + data.client_secret = 'seti_secret'; + data.publishable_key = 'pk_test'; + }, + () => + controller.handleCardVerificationSetup( + makeReq({}, { actor }), + res, + ), + ); + expect(res.body).toEqual({ + client_secret: 'seti_secret', + publishable_key: 'pk_test', + }); + }, + ); + }); + + it('still 409s card setup when no send has opened the fallback', async () => { + const { user, actor } = await makeUserAndActor({ + requires_card_verification: 1, + requires_phone_verification: 1, + phone: '+14155550123', + }); + // Counter above the threshold but no flag: eligibility is the flag a + // threshold-crossing send stamps, never the raw counter. + await seedAttempts(user.id, 5); + await withFallbackConfig( + { enabled: true, after_attempts: 3 }, + async () => { + await expect( + controller.handleCardVerificationSetup( + makeReq({}, { actor }), + makeRes(), + ), + ).rejects.toMatchObject({ statusCode: 409 }); + }, + ); + }); + + it('send crossing the threshold opens card setup end-to-end', async () => { + const { actor } = await makeUserAndActor({ + requires_card_verification: 1, + requires_phone_verification: 1, + }); + await withFallbackConfig( + { enabled: true, after_attempts: 1 }, + async () => { + await withPrelude(stubPrelude(), async () => { + const sendRes = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + sendRes, + ); + expect(sendRes.body).toEqual({ + card_fallback_available: true, + }); + }); + const res = makeRes(); + await withCardSetupOverride( + (data) => { + data.enabled = true; + data.client_secret = 'seti_secret'; + data.publishable_key = 'pk_test'; + }, + () => + controller.handleCardVerificationSetup( + makeReq({}, { actor }), + res, + ), + ); + expect(res.body).toEqual({ + client_secret: 'seti_secret', + publishable_key: 'pk_test', + }); + }, + ); + }); + + it('clamps after_attempts to the send route rate limit', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + }); + // 9 prior attempts + this send = 10, the route limit. A threshold of + // 50 could never be crossed, so it clamps down and the offer opens. + await seedAttempts(user.id, 9); + await withFallbackConfig( + { enabled: true, after_attempts: 50 }, + async () => { + await withPrelude(stubPrelude(), async () => { + const res = makeRes(); + await controller.handleSendConfirmPhone( + makeReq({ phone: '+14155550123' }, { actor }), + res, + ); + expect(res.body).toEqual({ + card_fallback_available: true, + }); + }); + }, + ); + }); + + it('clears BOTH gates when the fallback card verifies', async () => { + const { user, actor } = await makeUserAndActor({ + requires_card_verification: 1, + requires_phone_verification: 1, + phone: '+14155550123', + }); + await openFallback(user.id); + await withFallbackConfig( + { enabled: true }, + async () => { + const res = makeRes(); + await withCardConfirmOverride( + (data) => { + data.enabled = true; + data.verified = true; + }, + () => + controller.handleCardVerificationConfirm( + makeReq({ setup_intent_id: 'seti_1' }, { actor }), + res, + ), + ); + expect(res.body).toMatchObject({ + card_verified: true, + phone_verified: true, + }); + }, + ); + const after = await server.stores.user.getById(user.id, { + force: true, + }); + expect(after!.requires_card_verification).toBe(false); + expect(after!.requires_phone_verification).toBe(false); + }); + + it('clears the phone gate via card even when card was not required', async () => { + const { user, actor } = await makeUserAndActor({ + requires_phone_verification: 1, + phone: '+14155550123', + }); + await openFallback(user.id); + await withFallbackConfig( + { enabled: true }, + async () => { + const res = makeRes(); + await withCardConfirmOverride( + (data) => { + data.enabled = true; + data.verified = true; + }, + () => + controller.handleCardVerificationConfirm( + makeReq({ setup_intent_id: 'seti_1' }, { actor }), + res, + ), + ); + expect(res.body).toMatchObject({ phone_verified: true }); + }, + ); + const after = await server.stores.user.getById(user.id, { + force: true, + }); + expect(after!.requires_phone_verification).toBe(false); + }); +}); + describe('AuthController.handleConfirmEmail', () => { it('throws 400 when code is missing', async () => { const { actor } = await makeUserAndActor(); diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index 70d649660..7de3948a3 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -61,6 +61,17 @@ const USERNAME_REGEX = /^\w{1,}$/; const USERNAME_MAX_LENGTH = 45; const FINGERPRINT_MAX_LENGTH = 128; const DISPATCH_ID_MAX_LENGTH = 128; +// Default SMS send attempts before the card fallback opens. +const DEFAULT_CARD_FALLBACK_ATTEMPTS = 2; +// /send-confirm-phone route rate limit. Also caps the fallback's +// `after_attempts`: requests past the route limit are rejected in middleware +// and never reach the attempt counter, so a higher threshold could never be +// crossed. +const SEND_PHONE_RATE_LIMIT = 10; +const SEND_PHONE_RATE_WINDOW_MS = 60 * 60_000; +// Once the threshold is crossed the fallback stays open this long, so the +// user can finish the card flow without racing the attempt counter's expiry. +const CARD_FALLBACK_OPEN_TTL_SECONDS = 24 * 60 * 60; const RESERVED_USERNAMES = new Set([ 'admin', 'administrator', @@ -1056,14 +1067,117 @@ export class AuthController extends PuterController { }); } + // -- SMS-to-card fallback ----------------------------------------- + // + // Once a user has made enough SMS send attempts in the rate-limit window + // without getting through, they can verify a card instead to clear the + // phone gate. Off unless config enables it. + // + // Two KV keys: a short-lived counter tied to the send rate-limit window + // triggers the fallback, and a longer-lived "open" flag holds eligibility + // once the threshold is crossed. The card endpoints check only the flag — + // deriving eligibility from the raw counter would let it expire while the + // user is mid-way through the card flow. Every KV failure fails closed + // (fallback unavailable), never open. + + private cardFallbackConfig(): { enabled: boolean; afterAttempts: number } { + const cfg = this.config.phone_verification_card_fallback; + const afterAttempts = Math.min( + typeof cfg?.after_attempts === 'number' && cfg.after_attempts > 0 + ? cfg.after_attempts + : DEFAULT_CARD_FALLBACK_ATTEMPTS, + SEND_PHONE_RATE_LIMIT, + ); + return { enabled: Boolean(cfg?.enabled), afterAttempts }; + } + + private phoneAttemptsKey(userId: number): string { + return `phone-verify-attempts:${userId}`; + } + + private cardFallbackFlagKey(userId: number): string { + return `card-fallback-open:${userId}`; + } + + // TTL ties the counter to the send rate-limit window, so it resets with it. + private async bumpPhoneAttempts(userId: number): Promise { + try { + const { res } = await this.stores.kv.incr({ + key: this.phoneAttemptsKey(userId), + pathAndAmountMap: { attempts: 1 }, + expireAt: + Math.floor(Date.now() / 1000) + + SEND_PHONE_RATE_WINDOW_MS / 1000, + }); + const count = (res as { attempts?: number } | null)?.attempts; + return typeof count === 'number' ? count : 0; + } catch (e) { + console.warn('[send-confirm-phone] attempt-count bump failed:', e); + return 0; + } + } + + /** + * Count a send attempt and, once the threshold is crossed, stamp the + * eligibility flag the card endpoints check. Returns whether the fallback + * is open so send responses (success or 429) can advertise it. + */ + private async recordPhoneAttemptForFallback(user: { + id: number; + requires_phone_verification?: boolean | number | null; + }): Promise { + const attempts = await this.bumpPhoneAttempts(user.id); + const { enabled, afterAttempts } = this.cardFallbackConfig(); + const open = + enabled && + Boolean(user.requires_phone_verification) && + attempts >= afterAttempts; + if (open) { + try { + // Plain set, so each eligible attempt refreshes the window. + await this.stores.kv.set({ + key: this.cardFallbackFlagKey(user.id), + value: true, + expireAt: + Math.floor(Date.now() / 1000) + + CARD_FALLBACK_OPEN_TTL_SECONDS, + }); + } catch (e) { + console.warn( + '[send-confirm-phone] fallback flag stamp failed:', + e, + ); + return false; + } + } + return open; + } + + private async isCardFallbackEligible(user: { + id: number; + requires_phone_verification?: boolean | number | null; + }): Promise { + const { enabled } = this.cardFallbackConfig(); + if (!enabled || !user.requires_phone_verification) return false; + try { + const { res } = await this.stores.kv.get({ + key: this.cardFallbackFlagKey(user.id), + }); + return res === true; + } catch (e) { + console.warn('[card-verification] fallback flag read failed:', e); + return false; + } + } + @Post('/send-confirm-phone', { subdomain: ['api', ''], requireUserActor: true, allowUnconfirmed: true, rateLimit: { scope: 'send-confirm-phone', - limit: 10, - window: 60 * 60_000, + limit: SEND_PHONE_RATE_LIMIT, + window: SEND_PHONE_RATE_WINDOW_MS, key: 'user', }, }) @@ -1127,6 +1241,14 @@ export class AuthController extends PuterController { { legacyCode: 'phone_country_not_supported' as never }, ); + // Counted before the abuse / Prelude checks so a blocked attempt still + // counts toward the fallback threshold. + const fallbackAvailable = + await this.recordPhoneAttemptForFallback(user); + const fallbackFields = fallbackAvailable + ? { card_fallback_available: true } + : {}; + // Abuse caps live ENTIRELY in a listening abuse extension, consulted // via `puter.phone-verification.check`. The backend ships no thresholds // or detection of its own (so none of it is readable in the open-source @@ -1169,9 +1291,12 @@ export class AuthController extends PuterController { }, { legacyCode: 'phone_verification_unavailable' as never, - fields: abuseCheck.reason - ? { reason: abuseCheck.reason } - : {}, + fields: { + ...fallbackFields, + ...(abuseCheck.reason + ? { reason: abuseCheck.reason } + : {}), + }, }, ); @@ -1232,7 +1357,10 @@ export class AuthController extends PuterController { userUid: user.uuid, country: parsed.country, }, - { legacyCode: 'too_many_requests' as never }, + { + legacyCode: 'too_many_requests' as never, + fields: fallbackFields, + }, ); } } catch (e) { @@ -1269,7 +1397,7 @@ export class AuthController extends PuterController { } catch { // ignore — best-effort velocity signal } - res.json({}); + res.json(fallbackAvailable ? { card_fallback_available: true } : {}); } @Post('/confirm-phone', { @@ -1420,11 +1548,14 @@ export class AuthController extends PuterController { throw new HttpError(403, 'Account suspended.', { legacyCode: 'account_suspended', }); - if (!user.requires_card_verification) { + // Phone normally comes first, but the fallback lets a phone-gated user + // in once they've exhausted SMS attempts. + const fallbackEligible = await this.isCardFallbackEligible(user); + if (!user.requires_card_verification && !fallbackEligible) { res.json({ card_verified: true }); return; } - if (user.requires_phone_verification) + if (user.requires_phone_verification && !fallbackEligible) throw new HttpError( 409, 'Phone verification must be completed first.', @@ -1472,6 +1603,16 @@ export class AuthController extends PuterController { // Kill switch: the extension reports the feature disabled — unstick // any user still carrying the flag instead of dead-ending them. if (setupEvent.enabled === false) { + // A fallback user is here BECAUSE SMS isn't working for them, and + // now the card path is off too — they stay phone-gated with no + // way through. Surface it; don't clear a gate with nothing + // verified. + if (fallbackEligible) + console.warn( + '[card-verification/setup] card verification disabled;' + + ` fallback-eligible user ${user.uuid} remains` + + ' phone-gated with no working verification path', + ); await this.stores.user.update(user.id, { requires_card_verification: 0, }); @@ -1528,11 +1669,13 @@ export class AuthController extends PuterController { throw new HttpError(404, 'User not found.', { legacyCode: 'not_found', }); - if (!user.requires_card_verification) { + // Same fallback exception as setup: card may come before phone. + const fallbackEligible = await this.isCardFallbackEligible(user); + if (!user.requires_card_verification && !fallbackEligible) { res.json({ card_verified: true }); return; } - if (user.requires_phone_verification) + if (user.requires_phone_verification && !fallbackEligible) throw new HttpError( 409, 'Phone verification must be completed first.', @@ -1562,6 +1705,12 @@ export class AuthController extends PuterController { // Kill switch — same semantics as /card-verification/setup. if (confirmEvent.enabled === false) { + if (fallbackEligible) + console.warn( + '[card-verification/confirm] card verification disabled;' + + ` fallback-eligible user ${user.uuid} remains` + + ' phone-gated with no working verification path', + ); await this.stores.user.update(user.id, { requires_card_verification: 0, }); @@ -1579,8 +1728,15 @@ export class AuthController extends PuterController { return; } + // A fallback card clears the phone gate too — the point of the + // fallback. `fallbackEligible &&` makes the invariant local instead + // of leaning on the 409 guard above: only a fallback user's card can + // ever clear a phone gate. + const clearedPhoneGate = + fallbackEligible && Boolean(user.requires_phone_verification); await this.stores.user.update(user.id, { requires_card_verification: 0, + ...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}), }); try { @@ -1605,11 +1761,21 @@ export class AuthController extends PuterController { 'user.card_verified', { original_client_socket_id }, ); + // The fallback cleared the phone gate too — tell phone-gate UIs. + if (clearedPhoneGate) + await this.services.socket?.send( + { room: user.id }, + 'user.phone_verified', + { original_client_socket_id }, + ); } catch { // ignore — best-effort } - res.json({ card_verified: true }); + res.json({ + card_verified: true, + ...(clearedPhoneGate ? { phone_verified: true } : {}), + }); } // -- Password recovery ------------------------------------------- diff --git a/src/backend/types.ts b/src/backend/types.ts index 29ab59d00..3e95fcf13 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -584,6 +584,30 @@ interface IConfigOptional { * signups). Requires a payments extension to actually run the $0 auth. */ always_require_card_verification: boolean; + /** + * Let a user who keeps getting blocked on SMS phone verification fall + * back to credit-card verification, which clears the phone gate (and the + * card gate too, when one is set). Off by default. + * + * The fallback opens after `after_attempts` SMS *send* attempts inside + * the send rate-limit window — successful sends count too, so a user who + * receives codes fine can still choose the card path after that many + * requests. This trades the phone signal for a card signal; it does NOT + * guarantee SMS actually failed. Once open, the fallback stays open for + * 24 hours so the user can finish the card flow. Requires a payments + * extension to run the actual card check. + */ + phone_verification_card_fallback: { + enabled: boolean; + /** + * SMS send attempts (within the send rate-limit window) before the + * card fallback opens. Defaults to 2 when omitted. Values above the + * send route's rate limit (10/hour) are clamped down to it — requests + * past the route limit never reach the attempt counter, so a higher + * threshold could never be crossed. + */ + after_attempts?: number; + }; /** Captcha configuration. */ captcha: { enabled: boolean; difficulty?: 'easy' | 'medium' | 'hard' }; /** OIDC / OAuth2 providers (google + custom). */