Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/admin-ui/src/pages/EmailTemplates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ const TEMPLATE_DEFINITIONS: TemplateDefinition[] = [
description: "Sent when a user must verify their email during signup.",
variables: ["name", "verification_link"],
},
{
key: "signup_existing_account_notice",
label: "Existing account signup notice",
description: "Sent when signup is attempted with an email that already has an account.",
variables: ["name", "recovery_link"],
},
{
key: "verification_resend_confirmation",
label: "Resend confirmation",
Expand Down
1 change: 1 addition & 0 deletions packages/admin-ui/src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,7 @@ export interface AdminSetting {

export type EmailTemplateKey =
| "signup_verification"
| "signup_existing_account_notice"
| "verification_resend_confirmation"
| "email_change_verification"
| "password_recovery"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
INSERT INTO "settings" ("key", "name", "type", "category", "description", "tags", "default_value", "value", "secure", "updated_at") VALUES
('users.prevent_email_enumeration_on_registration', 'Prevent Email Enumeration on Registration', 'boolean', 'Users', 'Pretend registration works for existing users when registering using an existing email. Only applies when email verification and SMTP are enabled.', ARRAY['users', 'email', 'security']::text[], 'false'::jsonb, 'false'::jsonb, false, now()),
('email.templates.signup_existing_account_notice', 'signup_existing_account_notice Template', 'object', 'Email / Templates', 'Template for signup_existing_account_notice', ARRAY['email', 'templates']::text[], '{"subject":"Someone tried to create an account with this email","text":"Hello {{name}},\n\nSomeone attempted to create a new account using this email address, but an account already exists.\n\nIf this was you and you forgot your password, recover access here:\n{{recovery_link}}","html":"<p>Hello {{name}},</p><p>Someone attempted to create a new account using this email address, but an account already exists.</p><p>If this was you and you forgot your password, recover access here:</p><p><a href=\"{{recovery_link}}\">Recover account</a></p>"}'::jsonb, '{"subject":"Someone tried to create an account with this email","text":"Hello {{name}},\n\nSomeone attempted to create a new account using this email address, but an account already exists.\n\nIf this was you and you forgot your password, recover access here:\n{{recovery_link}}","html":"<p>Hello {{name}},</p><p>Someone attempted to create a new account using this email address, but an account already exists.</p><p>If this was you and you forgot your password, recover access here:</p><p><a href=\"{{recovery_link}}\">Recover account</a></p>"}'::jsonb, false, now())
ON CONFLICT ("key") DO UPDATE SET
"name" = excluded."name",
"type" = excluded."type",
"category" = excluded."category",
"description" = excluded."description",
"tags" = excluded."tags",
"default_value" = excluded."default_value",
"secure" = excluded."secure",
"updated_at" = now();
7 changes: 7 additions & 0 deletions packages/api/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@
"when": 1772449200000,
"tag": "0018_default_clients_and_org_rbac",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1772538000000,
"tag": "0019_prevent_email_enumeration_on_registration",
"breakpoints": true
}
]
}
1 change: 1 addition & 0 deletions packages/api/src/controllers/admin/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const TemplateSchema = z.object({

const KeySchema = z.enum([
"signup_verification",
"signup_existing_account_notice",
"verification_resend_confirmation",
"email_change_verification",
"password_recovery",
Expand Down
15 changes: 11 additions & 4 deletions packages/api/src/controllers/user/opaqueLoginStart.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { z } from "zod/v4";
import { ValidationError } from "../../errors.ts";
import { NotFoundError, ValidationError } from "../../errors.ts";
import { genericErrors } from "../../http/openapi-helpers.ts";
import { getCachedBody, withRateLimit } from "../../middleware/rateLimit.ts";
import { getUserOpaqueRecordByEmail } from "../../models/users.ts";
Expand Down Expand Up @@ -48,11 +48,18 @@ export const postOpaqueLoginStart = withRateLimit("opaque", (body) =>
}
context.logger.debug({ reqLen: requestBuffer.length }, "decoded request");

const userLookup = await getUserOpaqueRecordByEmail(context, parsed.email);
context.logger.debug({ found: !!userLookup.user }, "user lookup");
let userLookup: Awaited<ReturnType<typeof getUserOpaqueRecordByEmail>> | null = null;
try {
userLookup = await getUserOpaqueRecordByEmail(context, parsed.email);
} catch (error) {
if (!(error instanceof NotFoundError)) {
throw error;
}
}
context.logger.debug({ found: !!userLookup?.user }, "user lookup");

let loginResponse: OpaqueLoginResponse;
if (!userLookup.user) {
if (!userLookup?.user) {
loginResponse = await opaque.startLoginWithDummy(requestBuffer, parsed.email);
} else {
const envelopeBuffer = userLookup.envelope as unknown as Buffer | string | null;
Expand Down
168 changes: 168 additions & 0 deletions packages/api/src/models/registration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { test } from "node:test";
import { createPglite } from "../db/pglite.ts";
import { users } from "../db/schema.ts";
import { ConflictError } from "../errors.ts";
import { setSetting } from "../services/settings.ts";
import type { Context } from "../types.ts";
import { userOpaqueRegisterFinish } from "./registration.ts";

function createLogger() {
return {
error() {},
warn() {},
info() {},
debug() {},
trace() {},
fatal() {},
};
}

async function createTestContext() {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "darkauth-registration-test-"));
const { db, close } = await createPglite(directory);
const context = {
db,
config: {
publicOrigin: "http://localhost:9080",
},
logger: createLogger(),
services: {
opaque: {
finishRegistration: async () => ({
envelope: new Uint8Array([1, 2, 3]),
serverPublicKey: new Uint8Array([4, 5, 6]),
}),
},
},
} as unknown as Context;

const cleanup = async () => {
await close();
fs.rmSync(directory, { recursive: true, force: true });
};

return { context, cleanup };
}

test("duplicate registration returns conflict by default", async () => {
const { context, cleanup } = await createTestContext();
try {
await context.db.insert(users).values({
sub: "user-existing-default",
email: "existing-default@example.com",
name: "Existing Default",
createdAt: new Date(),
});

await assert.rejects(
() =>
userOpaqueRegisterFinish(context, {
record: new Uint8Array([9, 9, 9]),
email: "existing-default@example.com",
name: "New Attempt",
}),
(error: unknown) =>
error instanceof ConflictError &&
error.message === "A user with this email address already exists"
);
} finally {
await cleanup();
}
});

test("duplicate registration returns conflict when anti-enumeration is enabled but verification is disabled", async () => {
const { context, cleanup } = await createTestContext();
try {
await context.db.insert(users).values({
sub: "user-existing-no-verify",
email: "existing-no-verify@example.com",
name: "Existing No Verify",
createdAt: new Date(),
});
await setSetting(context, "users.prevent_email_enumeration_on_registration", true);
await setSetting(context, "users.require_email_verification", false);

await assert.rejects(
() =>
userOpaqueRegisterFinish(context, {
record: new Uint8Array([9, 9, 9]),
email: "existing-no-verify@example.com",
name: "New Attempt",
}),
(error: unknown) =>
error instanceof ConflictError &&
error.message === "A user with this email address already exists"
);
} finally {
await cleanup();
}
});

test("duplicate registration returns conflict when anti-enumeration is enabled and verification is enabled but smtp is unavailable", async () => {
const { context, cleanup } = await createTestContext();
try {
await context.db.insert(users).values({
sub: "user-existing-no-smtp",
email: "existing-no-smtp@example.com",
name: "Existing No SMTP",
createdAt: new Date(),
});
await setSetting(context, "users.prevent_email_enumeration_on_registration", true);
await setSetting(context, "users.require_email_verification", true);
await setSetting(context, "email.smtp.enabled", false);

await assert.rejects(
() =>
userOpaqueRegisterFinish(context, {
record: new Uint8Array([9, 9, 9]),
email: "existing-no-smtp@example.com",
name: "New Attempt",
}),
(error: unknown) =>
error instanceof ConflictError &&
error.message === "A user with this email address already exists"
);
} finally {
await cleanup();
}
});

test("duplicate registration returns conflict when anti-enumeration path cannot send the notice email", async () => {
const { context, cleanup } = await createTestContext();
try {
await context.db.insert(users).values({
sub: "user-existing-send-fail",
email: "existing-send-fail@example.com",
name: "Existing Send Fail",
createdAt: new Date(),
});

await setSetting(context, "users.prevent_email_enumeration_on_registration", true);
await setSetting(context, "users.require_email_verification", true);
await setSetting(context, "email.smtp.enabled", true);
await setSetting(context, "email.transport", "smtp");
await setSetting(context, "email.from", "noreply@example.com");
await setSetting(context, "email.smtp.host", "127.0.0.1");
await setSetting(context, "email.smtp.port", 1);
await setSetting(context, "email.smtp.user", "user");
await setSetting(context, "email.smtp.password", "pass");

await assert.rejects(
() =>
userOpaqueRegisterFinish(context, {
record: new Uint8Array([9, 9, 9]),
email: "existing-send-fail@example.com",
name: "New Attempt",
}),
(error: unknown) =>
error instanceof ConflictError &&
error.message === "A user with this email address already exists"
);
} finally {
await cleanup();
}
});
31 changes: 22 additions & 9 deletions packages/api/src/models/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import {
roles,
users,
} from "../db/schema.ts";
import { ValidationError } from "../errors.ts";
import { sendSignupVerification } from "../services/emailVerification.ts";
import { ConflictError, ValidationError } from "../errors.ts";
import { isEmailSendingAvailable } from "../services/email.ts";
import {
sendSignupExistingAccountNotice,
sendSignupVerification,
} from "../services/emailVerification.ts";
import { createSession } from "../services/sessions.ts";
import { getSetting } from "../services/settings.ts";
import type { Context } from "../types.ts";
Expand All @@ -23,19 +27,28 @@ export async function userOpaqueRegisterFinish(
const opaqueRecord = await context.services.opaque.finishRegistration(data.record, data.email);
const requireEmailVerification =
(await getSetting(context, "users.require_email_verification")) === true;
const preventEmailEnumeration =
(await getSetting(context, "users.prevent_email_enumeration_on_registration")) === true;
const { generateRandomString } = await import("../utils/crypto.ts");
const sub = generateRandomString(16);
// Check if user already exists before transaction
// Return a generic success response to prevent user enumeration attacks
const existingUser = await context.db.query.users.findFirst({
where: eq(users.email, data.email),
});
if (existingUser) {
const { generateRandomString: genFakeId } = await import("../utils/crypto.ts");
return {
sub: genFakeId(16),
requiresEmailVerification: true,
};
const emailSendingAvailable = await isEmailSendingAvailable(context);
if (preventEmailEnumeration && requireEmailVerification && emailSendingAvailable) {
try {
await sendSignupExistingAccountNotice(context, {
email: data.email,
name: existingUser.name || data.name,
});
} catch (error) {
context.logger.warn(error, "Failed to send existing-account signup notice");
throw new ConflictError("A user with this email address already exists");
}
return { sub, requiresEmailVerification: true };
}
throw new ConflictError("A user with this email address already exists");
}

await context.db.transaction(async (tx) => {
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/services/emailTemplates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getSetting, setSetting } from "./settings.ts";

export type EmailTemplateKey =
| "signup_verification"
| "signup_existing_account_notice"
| "verification_resend_confirmation"
| "email_change_verification"
| "password_recovery"
Expand All @@ -20,6 +21,11 @@ const DEFAULT_TEMPLATES: Record<EmailTemplateKey, EmailTemplate> = {
text: "Hello {{name}},\n\nPlease verify your email by opening this link:\n{{verification_link}}\n\nIf you did not create this account, ignore this email.",
html: '<p>Hello {{name}},</p><p>Please verify your email by opening this link:</p><p><a href="{{verification_link}}">Verify email</a></p><p>If you did not create this account, ignore this email.</p>',
},
signup_existing_account_notice: {
subject: "Someone tried to create an account with this email",
text: "Hello {{name}},\n\nSomeone attempted to create a new account using this email address, but an account already exists.\n\nIf this was you and you forgot your password, recover access here:\n{{recovery_link}}",
html: '<p>Hello {{name}},</p><p>Someone attempted to create a new account using this email address, but an account already exists.</p><p>If this was you and you forgot your password, recover access here:</p><p><a href="{{recovery_link}}">Recover account</a></p>',
},
verification_resend_confirmation: {
subject: "A new verification link has been sent",
text: "Hello {{name}},\n\nA new verification link has been requested for this account.\n\nIf this was you, use the newest email in your inbox.",
Expand Down
14 changes: 14 additions & 0 deletions packages/api/src/services/emailVerification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ export async function sendSignupVerification(
});
}

export async function sendSignupExistingAccountNotice(
context: Context,
params: { email: string; name: string }
): Promise<void> {
await sendTemplatedEmail(context, {
to: params.email,
template: "signup_existing_account_notice",
variables: {
name: params.name || params.email,
recovery_link: `${context.config.publicOrigin}/login`,
},
});
}

export async function resendSignupVerificationByEmail(
context: Context,
email: string
Expand Down
40 changes: 21 additions & 19 deletions packages/test-suite/setup/helpers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,26 +33,28 @@ export async function ensureAdminDashboard(
});
}

export async function ensureSelfRegistrationEnabled(page: Page): Promise<void> {
await page.click('a[href="/settings"], button:has-text("Settings")');
const usersSectionTrigger = page.getByRole('button', { name: 'Users', exact: true }).first();
const triggerState = await usersSectionTrigger.getAttribute('data-state');
if (triggerState !== 'open') {
await usersSectionTrigger.click();
}
const usersRegion = page.getByRole('region', { name: 'Users', exact: true });
await expect(usersRegion).toBeVisible({ timeout: 10000 });
const checkbox = usersRegion.getByRole('checkbox').nth(1);
await expect(checkbox).toBeVisible({ timeout: 5000 });
const state = await checkbox.getAttribute('data-state');
if (state === 'checked') return;
const updateResponse = page.waitForResponse((response) => {
return response.url().endsWith('/admin/settings') && response.request().method() === 'PUT';
export async function ensureSelfRegistrationEnabled(
servers: TestServers,
admin: AdminCredentials
): Promise<void> {
const adminSession = await getAdminSession(servers, admin);
const updateResponse = await fetch(`${servers.adminUrl}/admin/settings`, {
method: 'PUT',
headers: {
Cookie: adminSession.cookieHeader,
Origin: servers.adminUrl,
'Content-Type': 'application/json',
'x-csrf-token': adminSession.csrfToken,
},
body: JSON.stringify({
key: 'users.self_registration_enabled',
value: true,
}),
});
await checkbox.click();
const response = await updateResponse;
expect(response.ok()).toBeTruthy();
await expect(checkbox).toHaveAttribute('data-state', 'checked', { timeout: 5000 });
if (!updateResponse.ok) {
const errorText = await updateResponse.text().catch(() => '');
throw new Error(`failed to enable self-registration: ${updateResponse.status} ${errorText}`);
}
}

export async function configureDemoClient(
Expand Down
Loading
Loading