fix: signup gate order

This commit is contained in:
Daniel Salazar committed 2026-11-02 13:25:36 -07:00
1 parent abb70c14a6
commit 8a50c5ebbc
11 files changed
+442 -151

No files matched your search

@@ -5132,6 +5132,26 @@ describe('AuthController phone verification — staging & reuse', () => {
expect(staged).toBe('+14155550123');
});
it('makes an email-gate-last account verify its card before its phone', async () => {
const { user, actor } = await makeUserAndActor({
requires_phone_verification: 1,
requires_card_verification: 1,
});
await server.stores.user.updateMetadata(user.id, {
emailGateLast: true,
});
const prelude = stubPrelude();
await withClients({ prelude }, async () => {
await expect(
controller.handleSendConfirmPhone(
makeReq({ phone: '+14155550123' }, { actor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 409 });
});
expect(prelude.createVerification).not.toHaveBeenCalled();
});
it('forwards an abuse-extension veto as 429 with the opaque reason, and sends nothing', async () => {
const { user, actor } = await makeUserAndActor();
const emitAndWait = vi.fn(
+33 -2
View File
@@ -66,6 +66,7 @@ import {
isCardFallbackEnabled,
phoneAttemptsKey,
} from '../../util/cardFallback.js';
import { isEmailGateLast } from '../../util/verificationOrder.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isStorableEmail } from '../../util/email.js';
import { isGodmodeApp } from '../../util/godmodeApps.js';
@@ -1220,6 +1221,18 @@ export class AuthController extends PuterController {
}
}
/** The email-gate-last confirmation email; a failure never fails the gate. */
private async sendDeferredConfirmation(userId: number): Promise<void> {
try {
await this.services.signup.sendDeferredConfirmationEmail(userId);
} catch (e) {
console.warn(
'[verification] deferred confirmation email failed:',
e,
);
}
}
private async isCardFallbackEligible(user: {
id: number;
requires_phone_verification?: boolean | number | null;
@@ -1265,6 +1278,12 @@ export class AuthController extends PuterController {
throw new HttpError(403, 'Account suspended.', {
legacyCode: 'account_suspended',
});
if (isEmailGateLast(user) && user.requires_card_verification)
throw new HttpError(
409,
'Card verification must be completed first.',
{ legacyCode: 'conflict' },
);
if (!this.clients.prelude?.isConfigured())
throw await this.smsSendError(
503,
@@ -1574,6 +1593,7 @@ export class AuthController extends PuterController {
requires_phone_verification: 0,
phone: pendingPhone,
});
await this.sendDeferredConfirmation(user.id);
// Run the verified-event listeners synchronously (emitAndWait, not
// fire-and-forget emit) so a carrier-based card-verification waiver in
@@ -1654,7 +1674,11 @@ export class AuthController extends PuterController {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification && !fallbackEligible)
if (
user.requires_phone_verification &&
!fallbackEligible &&
!isEmailGateLast(user)
)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1717,6 +1741,7 @@ export class AuthController extends PuterController {
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
await this.sendDeferredConfirmation(user.id);
res.json({ card_verified: true, disabled: true });
return;
}
@@ -1776,7 +1801,11 @@ export class AuthController extends PuterController {
res.json({ card_verified: true });
return;
}
if (user.requires_phone_verification && !fallbackEligible)
if (
user.requires_phone_verification &&
!fallbackEligible &&
!isEmailGateLast(user)
)
throw new HttpError(
409,
'Phone verification must be completed first.',
@@ -1817,6 +1846,7 @@ export class AuthController extends PuterController {
await this.stores.user.update(user.id, {
requires_card_verification: 0,
});
await this.sendDeferredConfirmation(user.id);
res.json({ card_verified: true, disabled: true });
return;
}
@@ -1841,6 +1871,7 @@ export class AuthController extends PuterController {
requires_card_verification: 0,
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
await this.sendDeferredConfirmation(user.id);
// Awaited like `user.phone-verified`, so whatever a listener grants on
// verification is in place before the client refreshes. emitAndWait
@@ -836,6 +836,34 @@ describe('requireVerifiedAccount', () => {
expectHttpError(got, 403, 'card_verification_required');
});
it('asks an email-gate-last account for card, then phone, then email', () => {
const pending = {
uuid: 'u-1',
requires_email_confirmation: true,
email_confirmed: false,
requires_phone_verification: true,
requires_card_verification: true,
metadata: { emailGateLast: true },
};
const gateFor = (user: Record<string, unknown>) =>
runGate(requireVerifiedAccount(), { actor: { user } });
expectHttpError(gateFor(pending), 403, 'card_verification_required');
expectHttpError(
gateFor({ ...pending, requires_card_verification: false }),
403,
'phone_verification_required',
);
expectHttpError(
gateFor({
...pending,
requires_card_verification: false,
requires_phone_verification: false,
}),
403,
'email_confirmation_required',
);
});
it('returns 403 two_factor_required for a seat whose team requires 2FA', async () => {
const teams = { getOrgSeat: async () => ({ require_2fa: 1 }) };
const got = await runGateAsync(requireTeam2fa(teams), {
+22 -10
View File
@@ -32,6 +32,7 @@ import {
type CardFallbackDeps,
} from '../../../util/cardFallback';
import { reserveUsernames } from '../../../util/reservedUsernames';
import { isEmailGateLast } from '../../../util/verificationOrder';
import {
isAccountContext,
isAppActor,
@@ -309,11 +310,25 @@ export const requireVerifiedAccount = (): RequestHandler =>
export const assertVerifiedAccount = (
user: AccountGateUser | undefined,
): void => {
if (user?.requires_email_confirmation && !user?.email_confirmed) {
throw new HttpError(403, 'Please confirm your email to continue', {
legacyCode: 'email_confirmation_required',
});
}
const assertEmail = () => {
if (user?.requires_email_confirmation && !user?.email_confirmed) {
throw new HttpError(403, 'Please confirm your email to continue', {
legacyCode: 'email_confirmation_required',
});
}
};
const assertCard = () => {
if (user?.requires_card_verification) {
throw new HttpError(403, 'Please verify your card to continue', {
legacyCode: 'card_verification_required',
});
}
};
// Email → phone → card, or card → phone → email for an email-gate-last
// account, so the client is sent to the gate it has to clear next.
const emailLast = isEmailGateLast(user);
if (emailLast) assertCard();
else assertEmail();
if (user?.requires_phone_verification) {
throw new HttpError(
403,
@@ -323,11 +338,8 @@ export const assertVerifiedAccount = (
},
);
}
if (user?.requires_card_verification) {
throw new HttpError(403, 'Please verify your card to continue', {
legacyCode: 'card_verification_required',
});
}
if (emailLast) assertEmail();
else assertCard();
// A team seat signs in on a password its administrator still holds,
// so it reaches nothing until it has replaced that password.
if (user?.requires_password_change) {
+1
View File
@@ -76,6 +76,7 @@ export type AccountGateUser = Partial<
| 'phone'
| 'card_fingerprint'
| 'otp_enabled'
| 'metadata'
>
> & { id?: number };
@@ -172,6 +172,87 @@ describe('SignupService.signup', () => {
});
});
describe('SignupService email gate last', () => {
/** Signs up with the validate hook asking for `gates`; returns sent emails. */
const signupWith = async (gates: Record<string, boolean>) => {
const send = vi.fn(async () => true);
const clients = server.clients as unknown as { email: unknown };
const realEmail = clients.email;
clients.email = { send };
const require = (_k: string, data: unknown) => {
Object.assign(data as Record<string, unknown>, gates);
};
server.clients.event.on('puter.signup.validate', require);
const username = `egl_${slug()}`;
try {
const user = await server.services.signup.signup({
req,
body: {
username,
email: `${username}@example.com`,
password: 'correct horse battery',
},
isTemp: false,
fingerprint: null,
bonusCode: null,
});
return { user, send, restore: () => (clients.email = realEmail) };
} finally {
server.clients.event.off('puter.signup.validate', require);
}
};
it('holds the confirmation email until card and phone are cleared, then sends it once', async () => {
const { user, send, restore } = await signupWith({
requires_phone_verification: true,
requires_card_verification: true,
email_gate_last: true,
});
try {
expect(send).not.toHaveBeenCalled();
const stored = await server.stores.user.getById(user.id, {
force: true,
});
expect(stored!.metadata?.emailGateLast).toBe(true);
await server.stores.user.update(user.id, {
requires_card_verification: 0,
});
await server.services.signup.sendDeferredConfirmationEmail(user.id);
expect(send).not.toHaveBeenCalled();
await server.stores.user.update(user.id, {
requires_phone_verification: 0,
});
await server.services.signup.sendDeferredConfirmationEmail(user.id);
await server.services.signup.sendDeferredConfirmationEmail(user.id);
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
`${user.username}@example.com`,
'email_verification_code',
{ code: stored!.email_confirm_code },
);
} finally {
restore();
}
});
it('ignores the flag when there is no phone or card gate', async () => {
const { user, send, restore } = await signupWith({
email_gate_last: true,
});
try {
expect(send).toHaveBeenCalledTimes(1);
const stored = await server.stores.user.getById(user.id, {
force: true,
});
expect(stored!.metadata?.emailGateLast).toBeUndefined();
} finally {
restore();
}
});
});
describe('SignupService.announceSignup', () => {
it('leaves out the form-only fields for a source that has none', async () => {
const username = `an_${slug()}`;
+49 -4
View File
@@ -32,6 +32,7 @@ import {
validateSignupBonus,
} from '../../util/signupBonus.js';
import { isUsernameTaken } from '../../util/username.js';
import { isEmailGateLast } from '../../util/verificationOrder.js';
import {
generateDefaultFsentries,
promoteToVerifiedGroup,
@@ -47,6 +48,8 @@ export type SignupGateOutcome =
requiresEmailConfirmation: boolean;
requiresPhoneVerification: boolean;
requiresCardVerification: boolean;
/** Card and phone come before email (see `isEmailGateLast`). */
emailGateLast: boolean;
}
| { verdict: 'bonus_code_invalid' }
| {
@@ -175,6 +178,9 @@ export class SignupService extends PuterService {
requires_email_confirmation: false,
requires_phone_verification: false,
requires_card_verification: false,
// Clear card and phone before email, and send the confirmation
// email only then. Meaningless without a phone or card gate.
email_gate_last: false,
message: null as string | null,
code: null as string | null,
...(input.fingerprint !== undefined ? { fingerprint } : {}),
@@ -250,6 +256,9 @@ export class SignupService extends PuterService {
),
requiresPhoneVerification,
requiresCardVerification,
emailGateLast:
Boolean(validateEvent.email_gate_last) &&
(requiresPhoneVerification || requiresCardVerification),
};
}
@@ -507,7 +516,10 @@ export class SignupService extends PuterService {
}
}
if (!isTemp && user.requires_email_confirmation) {
const emailGateLast = !isTemp && gate.emailGateLast;
if (emailGateLast) user = await this.#markEmailGateLast(user);
if (!isTemp && user.requires_email_confirmation && !emailGateLast) {
await this.sendConfirmationEmail(
user,
'signup',
@@ -582,13 +594,46 @@ export class SignupService extends PuterService {
}
}
/** Record that this account clears card and phone before email. */
async #markEmailGateLast(user: UserRow): Promise<UserRow> {
await this.stores.user.updateMetadata(user.id, { emailGateLast: true });
return { ...user, metadata: { ...user.metadata, emailGateLast: true } };
}
/**
* Send an email-gate-last account its confirmation email once card and
* phone are cleared. Once per account; `/send-confirm-email` resends.
*/
async sendDeferredConfirmationEmail(userId: number): Promise<void> {
const user = await this.stores.user.getById(userId, { force: true });
if (!user || !isEmailGateLast(user)) return;
if (user.metadata?.emailGateLastSent === true) return;
if (user.email_confirmed || !user.requires_email_confirmation) return;
if (user.requires_phone_verification || user.requires_card_verification)
return;
await this.stores.user.updateMetadata(user.id, {
emailGateLastSent: true,
});
let code =
typeof user.email_confirm_code === 'string'
? user.email_confirm_code
: null;
if (!code) {
code = String(crypto.randomInt(100000, 1000000));
await this.stores.user.update(user.id, {
email_confirm_code: code,
});
}
await this.sendConfirmationEmail(user, 'deferred', { code });
}
/**
* Send the confirmation code (or link) for the account's address. A lost
* message is alarmed rather than thrown: the account exists either way.
*/
async sendConfirmationEmail(
user: UserRow,
stage: 'signup' | 'resend',
stage: 'signup' | 'resend' | 'deferred',
message: { code: string } | { token: string },
): Promise<void> {
if (!this.clients.email || !user.email) return;
@@ -762,7 +807,7 @@ export class SignupService extends PuterService {
* phone/card gate is outstanding either.
*/
#confirmationEmailFailed(
stage: 'signup' | 'resend',
stage: 'signup' | 'resend' | 'deferred',
user: UserRow,
cause: unknown,
): void {
@@ -774,7 +819,7 @@ export class SignupService extends PuterService {
? 'no transport configured (message dropped)'
: String(cause);
console.warn(
`[${stage === 'signup' ? 'signup' : 'send-confirm-email'}] ` +
`[${stage === 'resend' ? 'send-confirm-email' : 'signup'}] ` +
`confirmation email not delivered: ${detail}`,
);
// Best-effort: failing to alarm must not fail the signup.
+3
View File
@@ -28,6 +28,7 @@ import {
type CardFallbackClients,
} from './cardFallback.js';
import { getTaskbarItems } from './taskbarItems.js';
import { isEmailGateLast } from './verificationOrder.js';
type TaskbarDeps = Parameters<typeof getTaskbarItems>[1];
@@ -153,6 +154,8 @@ export async function buildUserDetails(
// client reads it. Only the verification flag ships.
requires_phone_verification: user.requires_phone_verification,
requires_card_verification: user.requires_card_verification,
// Card and phone come before email for this account.
email_gate_last: isEmailGateLast(user),
// A seat reaches nothing until it replaces its admin's password.
requires_password_change: user.requires_password_change,
// The SMS-to-card escape hatch. It ships here because by the time the
+27
View File
@@ -0,0 +1,27 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Whether the account clears card and phone verification before confirming its
* email, and is only sent the confirmation email once it gets there. Decided by
* the signup gate and recorded in the account's metadata.
*/
export const isEmailGateLast = (
user: { metadata?: Record<string, unknown> | null } | null | undefined,
): boolean => user?.metadata?.emailGateLast === true;
+36 -21
View File
@@ -596,26 +596,31 @@ function UIWindowSignup(options) {
show_email_dialog
) {
$(el_window).close();
// Verification gates run in order: email →
// phone (SMS) → card, matching the server-side
// order in assertVerifiedAccount.
if (show_email_dialog) {
email_verified =
await UIWindowEmailConfirmationRequired({
stay_on_top: true,
has_head: true,
reload_on_success:
options.reload_on_success,
window_options:
options.window_options ?? {},
});
}
// Verification gates run in the server's order
// (see assertVerifiedAccount): email → phone
// (SMS) → card, or card → phone → email when the
// account clears email last.
const emailGate = async () => {
if (show_email_dialog) {
email_verified =
await UIWindowEmailConfirmationRequired({
stay_on_top: true,
has_head: true,
reload_on_success:
options.reload_on_success,
window_options:
options.window_options ?? {},
});
}
};
// A user the SMS path keeps failing may take the
// card fallback instead; that resolves 'card' and
// clears the card gate along with the phone gate
// (see UIWindowPhoneVerificationRequired).
let card_gate_cleared_by_fallback = false;
if (data.user?.requires_phone_verification) {
const phoneGate = async () => {
if (!data.user?.requires_phone_verification)
return;
let phone_ok = false;
do {
phone_ok =
@@ -632,12 +637,13 @@ function UIWindowSignup(options) {
} while (!phone_ok);
card_gate_cleared_by_fallback =
phone_ok === 'card';
}
// Card verification is the last gate.
if (
data.user?.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
};
const cardGate = async () => {
if (
!data.user?.requires_card_verification ||
card_gate_cleared_by_fallback
)
return;
let card_ok = false;
do {
card_ok =
@@ -649,6 +655,15 @@ function UIWindowSignup(options) {
options.window_options ?? {},
});
} while (!card_ok);
};
if (data.user?.email_gate_last) {
await cardGate();
await phoneGate();
await emailGate();
} else {
await emailGate();
await phoneGate();
await cardGate();
}
}
+142 -114
View File
@@ -2041,61 +2041,75 @@ window.initgui = async function (options) {
}
if (whoami) {
// Verification gates run in order: email → phone (SMS) → card,
// matching the server-side order in assertVerifiedAccount.
if (whoami.requires_email_confirmation) {
let is_verified;
do {
is_verified = await UIWindowEmailConfirmationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
}
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
// Verification gates run in the server's order (see
// assertVerifiedAccount): email → phone (SMS) → card, or card →
// phone → email when the account clears email last.
let card_gate_cleared_by_fallback = false;
if (whoami.requires_phone_verification) {
let is_verified;
do {
is_verified = await UIWindowPhoneVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
// Already out of SMS attempts (the offer outlives the
// send window, and no send can report it any more).
card_fallback_available:
whoami.card_fallback_available,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
// Card verification is the last gate: only show it once the email and
// phone (SMS) gates are cleared, since those show up first.
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
const emailGate = async () => {
if (whoami.requires_email_confirmation) {
let is_verified;
do {
is_verified = await UIWindowEmailConfirmationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
}
};
const phoneGate = async () => {
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
if (whoami.requires_phone_verification) {
let is_verified;
do {
is_verified = await UIWindowPhoneVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
// Already out of SMS attempts (the offer outlives the
// send window, and no send can report it any more).
card_fallback_available:
whoami.card_fallback_available,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
};
const cardGate = async () => {
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
window_options: {
is_draggable: false,
},
});
} while (!is_verified);
}
};
if (whoami.email_gate_last) {
await cardGate();
await phoneGate();
await emailGate();
} else {
await emailGate();
await phoneGate();
await cardGate();
}
// Last, matching assertVerifiedAccount's order.
if (whoami.requires_password_change) {
@@ -2319,67 +2333,81 @@ window.initgui = async function (options) {
// verification gates below: the landing's max z-index would cover
// them.
reveal_anonymous_markup(false);
// Verification gates run in order: email → phone (SMS) → card,
// matching the server-side order in assertVerifiedAccount.
if (whoami.requires_email_confirmation) {
let is_verified;
do {
is_verified = await UIWindowEmailConfirmationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
}
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
// Verification gates run in the server's order (see
// assertVerifiedAccount): email → phone (SMS) → card, or card →
// phone → email when the account clears email last.
let card_gate_cleared_by_fallback = false;
if (whoami.requires_phone_verification) {
let is_verified;
do {
is_verified = await UIWindowPhoneVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
// Already out of SMS attempts (the offer outlives the
// send window, and no send can report it any more).
card_fallback_available:
whoami.card_fallback_available,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
// Card verification is the last gate: only show it once the email and
// phone (SMS) gates are cleared, since those show up first.
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
const emailGate = async () => {
if (whoami.requires_email_confirmation) {
let is_verified;
do {
is_verified = await UIWindowEmailConfirmationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
}
};
const phoneGate = async () => {
// is phone verification required? (hard gate for low-rep signups)
// A user the SMS path keeps failing may be offered the card
// fallback instead; that resolves 'card' and clears the card gate
// too (see UIWindowPhoneVerificationRequired).
if (whoami.requires_phone_verification) {
let is_verified;
do {
is_verified = await UIWindowPhoneVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
// Already out of SMS attempts (the offer outlives the
// send window, and no send can report it any more).
card_fallback_available:
whoami.card_fallback_available,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
card_gate_cleared_by_fallback = is_verified === 'card';
}
};
const cardGate = async () => {
if (
whoami.requires_card_verification &&
!card_gate_cleared_by_fallback
) {
let is_verified;
do {
is_verified = await UIWindowCardVerificationRequired({
show_close_button: false,
stay_on_top: true,
has_head: false,
logout_in_footer: true,
window_options: {
is_draggable: false,
cover_page: window.is_embedded,
},
});
} while (!is_verified);
}
};
if (whoami.email_gate_last) {
await cardGate();
await phoneGate();
await emailGate();
} else {
await emailGate();
await phoneGate();
await cardGate();
}
// Last, matching assertVerifiedAccount's order.
if (whoami.requires_password_change) {