feat: add ability to force email verification (#1837)

This commit is contained in:
Elias Schneider
2026-10-13 22:04:33 +02:00
committed by GitHub
parent eb8ea6bed2
commit 0ffeb170cf
81 changed files with 2310 additions and 263 deletions
+4 -1
View File
@@ -1,6 +1,6 @@
{
"provider": "sqlite",
"version": 20260929120000,
"version": 20261011120000,
"tableOrder": [
"users",
"user_groups",
@@ -420,6 +420,7 @@
"disabled": false,
"display_name": "Tim Cook",
"email": "tim.cook@test.com",
"email_verification_deadline": null,
"email_verified": true,
"first_name": "Tim",
"id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
@@ -435,6 +436,7 @@
"disabled": false,
"display_name": "Craig Federighi",
"email": "craig.federighi@test.com",
"email_verification_deadline": null,
"email_verified": false,
"first_name": "Craig",
"id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
@@ -450,6 +452,7 @@
"disabled": false,
"display_name": "Eddy Cue",
"email": "eddy.cue@test.com",
"email_verification_deadline": null,
"email_verified": false,
"first_name": "Eddy",
"id": "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
@@ -250,6 +250,8 @@ test('Update email configuration', async ({ page }) => {
await page.getByLabel('Email Login Code Requested by User').click();
await page.getByLabel('Email Login Code from Admin').click();
await page.getByLabel('API Key Expiration').click();
await page.getByLabel('Email Verification', { exact: true }).click();
await page.getByRole('option', { name: /^Required/ }).click();
await saveUnsavedChanges(page);
@@ -264,6 +266,26 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('Email Login Code Requested by User')).toBeChecked();
await expect(page.getByLabel('Email Login Code from Admin')).toBeChecked();
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
await expect(page.getByLabel('Email Verification', { exact: true })).toContainText('Required');
});
test('Required email verification turns off emails verified by default', async ({ page }) => {
await page.getByRole('tab', { name: 'Email' }).click();
const emailsVerifiedByDefault = page.getByLabel('Emails verified by default');
await emailsVerifiedByDefault.click();
await expect(emailsVerifiedByDefault).toBeChecked();
await page.getByLabel('Email Verification', { exact: true }).click();
await page.getByRole('option', { name: /^Required/ }).click();
await expect(emailsVerifiedByDefault).not.toBeChecked();
await expect(emailsVerifiedByDefault).toBeDisabled();
await page.getByLabel('Email Verification', { exact: true }).click();
await page.getByRole('option', { name: /^Optional/ }).click();
await expect(emailsVerifiedByDefault).toBeEnabled();
});
test('Save LDAP configuration while LDAP remains disabled', async ({ page }) => {
+190
View File
@@ -0,0 +1,190 @@
import base, { expect, type APIRequestContext } from '@playwright/test';
import { emailVerificationTokens, oidcClients, users } from '../data';
import { setAppConfigValues } from '../utils/app-config.util';
import authUtil from '../utils/auth.util';
import { cleanupBackend } from '../utils/cleanup.util';
import { startSmtpSink, verificationLinkPath, type SmtpSink } from '../utils/smtp.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
const smtpSinkPort = 18125;
const test = base.extend<{ smtp: SmtpSink }>({
smtp: async ({}, use) => {
const sink = await startSmtpSink(smtpSinkPort);
try {
await use(sink);
} finally {
await sink.close();
}
}
});
// Requires email verification and points Pocket ID at the SMTP sink, so the verification links can be followed
async function requireEmailVerificationWithSmtp(request: APIRequestContext) {
await setAppConfigValues(request, {
emailVerification: 'required',
smtpHost: 'host.docker.internal',
smtpPort: String(smtpSinkPort),
smtpFrom: 'pocket-id@test.com',
smtpTls: 'none'
});
}
// Builds the query of an authorization code request for the Nextcloud client
function nextcloudAuthorizeParams() {
const oidcClient = oidcClients.nextcloud;
return new URLSearchParams({
client_id: oidcClient.id,
response_type: 'code',
scope: 'openid profile email',
redirect_uri: oidcClient.callbackUrl,
state: 'nXx-6Qr-owc1SHBa',
nonce: 'P1gN3PtpKHJgKUVcLpLjm'
});
}
// Marks Craig's email as verified through the admin API
async function markCraigVerified(request: APIRequestContext) {
const craig = await request.get(`/api/users/${users.craig.id}`).then((r) => r.json());
const response = await request.put(`/api/users/${users.craig.id}`, {
data: { ...craig, emailVerified: true }
});
expect(response.ok()).toBe(true);
}
test.describe('Required email verification', () => {
test.beforeEach(async ({ page }) => {
await cleanupBackend();
await requireEmailVerificationWithSmtp(page.request);
// Craig is a regular user whose email is not verified yet
await authUtil.changeUser(page, 'craig');
});
test('Unverified user can only reach their account', async ({ page }) => {
await page.goto('/settings/apps');
await page.waitForURL('/settings/account');
await expect(page.getByText('Verify your email address to use your account')).toBeVisible();
await expect(page.getByRole('link', { name: 'My Apps' })).toHaveCount(0);
const response = await page.request.get('/api/oidc/users/me/clients');
expect(response.status()).toBe(403);
});
test('Unverified user cannot sign in to a client', async ({ page }) => {
await page.goto(`/authorize?${nextcloudAuthorizeParams().toString()}`);
await expect(page).toHaveURL(/\/interaction\/error\?error=/);
await expect(
page.getByRole('paragraph').filter({
hasText: 'You must verify your email address before you can access this service.'
})
).toBeVisible();
await page.getByRole('link', { name: 'Verify email address' }).click();
await page.waitForURL('/settings/account');
});
test('Unverified user is stopped right after signing in during authorization', async ({
page
}) => {
// Sign out so the sign-in happens on the interaction page, with the passkey the setup already registered for Craig
await page.context().clearCookies();
await page.goto(`/authorize?${nextcloudAuthorizeParams().toString()}`);
await page.getByRole('button', { name: 'Sign in' }).click();
// Completing the sign-in step already requires a verified email, so the consent screen is never shown
await expect(
page.getByText('You must verify your email address before you can sign in to applications.')
).toBeVisible();
await expect(page.getByTestId('scopes')).toHaveCount(0);
await page.getByRole('link', { name: 'Verify email address' }).click();
await page.waitForURL('/settings/account');
});
test('Verifying the email restores full access', async ({ page }) => {
const token = emailVerificationTokens.find((t) => !t.expired)!.token;
await page.goto(`/verify-email?token=${token}`);
await page.waitForURL('/settings/account?emailVerificationState=success');
await page.goto('/settings/apps');
await expect(page).toHaveURL('/settings/apps');
await expect(page.getByRole('link', { name: 'My Apps' })).toBeVisible();
});
});
test.describe('Email change with required verification', () => {
test.beforeEach(async ({ page }) => {
await cleanupBackend();
await requireEmailVerificationWithSmtp(page.request);
await markCraigVerified(page.request);
await authUtil.changeUser(page, 'craig');
});
test('A new email takes effect once it is confirmed', async ({ page, smtp }) => {
const newEmail = 'craig.new@test.com';
await page.goto('/settings/account');
await page.getByLabel('Email').fill(newEmail);
await saveUnsavedChanges(page);
// The verified email stays in effect and the account keeps full access until the change is confirmed
await expect(page.getByText('Confirm your new email address')).toBeVisible();
await expect(page.getByLabel('Email')).toHaveValue(users.craig.email);
await page.goto('/settings/apps');
await expect(page).toHaveURL('/settings/apps');
await page.goto(verificationLinkPath(smtp, newEmail));
await page.waitForURL('/settings/account?emailVerificationState=success');
await expect(page.getByLabel('Email')).toHaveValue(newEmail);
await expect(page.getByText('Confirm your new email address')).toHaveCount(0);
});
test('A canceled email change can no longer be confirmed', async ({ page, smtp }) => {
const newEmail = 'craig.canceled@test.com';
await page.goto('/settings/account');
await page.getByLabel('Email').fill(newEmail);
await saveUnsavedChanges(page);
await page.getByRole('button', { name: 'Cancel change' }).click();
await expect(page.getByText('Email change canceled.')).toBeVisible();
await expect(page.getByText('Confirm your new email address')).toHaveCount(0);
await page.goto(verificationLinkPath(smtp, newEmail));
await page.waitForURL(
'/settings/account?emailVerificationState=Email+verification+token+is+invalid'
);
await expect(page.getByLabel('Email')).toHaveValue(users.craig.email);
});
});
test('Signing up sends a verification link that unlocks the account', async ({ page, smtp }) => {
await cleanupBackend();
await requireEmailVerificationWithSmtp(page.request);
await setAppConfigValues(page.request, { allowUserSignups: 'open' });
await page.context().clearCookies();
const email = 'verified.signup@test.com';
await page.goto('/signup');
await page.getByLabel('First name').fill('Verified');
await page.getByLabel('Last name').fill('Signup');
await page.getByLabel('Username').fill('verifiedsignup');
await page.getByLabel('Email').fill(email);
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
await page.getByRole('button', { name: 'Skip for now' }).click();
await page
.getByRole('alertdialog', { name: 'Skip Passkey Setup' })
.getByRole('button', { name: 'Skip for now' })
.click();
await page.waitForURL('/settings/account');
await page.goto(verificationLinkPath(smtp, email));
await page.waitForURL('/settings/account?emailVerificationState=success');
await page.goto('/settings/apps');
await expect(page).toHaveURL('/settings/apps');
});
+34
View File
@@ -1,5 +1,6 @@
import test, { expect, type Page } from '@playwright/test';
import { signupTokens, userGroups, users } from '../data';
import { setAppConfigValues } from '../utils/app-config.util';
import { cleanupBackend } from '../utils/cleanup.util';
import passkeyUtil from '../utils/passkey.util';
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
@@ -296,6 +297,39 @@ test.describe('User Signup', () => {
await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
});
test('Open signup with required email verification restricts the new account', async ({
page
}) => {
// Required verification needs an SMTP server, but nothing listens on it, so the email sent at signup just fails
await setAppConfigValues(page.request, {
emailVerification: 'required',
smtpHost: 'localhost',
smtpFrom: 'pocket-id@test.com'
});
await setSignupMode(page, 'Open Signup');
await page.goto('/signup');
await page.getByLabel('First name').fill('Pending');
await page.getByLabel('Last name').fill('User');
await page.getByLabel('Username').fill('pendinguser');
await page.getByLabel('Email').fill('pending.user@test.com');
await page.getByRole('button', { name: 'Sign Up' }).click();
await page.waitForURL('/signup/add-passkey');
await page.getByRole('button', { name: 'Skip for now' }).click();
await page
.getByRole('alertdialog', { name: 'Skip Passkey Setup' })
.getByRole('button', { name: 'Skip for now' })
.click();
await page.waitForURL('/settings/account');
await expect(page.getByText('Verify your email address to use your account')).toBeVisible();
await expect(page.getByText('your account will be deleted')).toBeVisible();
await page.goto('/settings/apps');
await expect(page).toHaveURL('/settings/account');
});
test('Token usage limit is enforced', async ({ page }) => {
await setSignupMode(page, 'Signup with token');
+22
View File
@@ -0,0 +1,22 @@
import { expect, type APIRequestContext } from '@playwright/test';
// Overrides application configuration values through the API, keeping every other value unchanged
// The request context must be authenticated as an admin
export async function setAppConfigValues(
request: APIRequestContext,
values: Record<string, string>
) {
const configResponse = await request.get('/api/application-configuration/all');
expect(configResponse.ok()).toBe(true);
const config = Object.fromEntries(
((await configResponse.json()) as Array<{ key: string; value: string }>).map(
({ key, value }) => [key, value]
)
);
const updateResponse = await request.put('/api/application-configuration', {
data: { ...config, ...values }
});
expect(updateResponse.ok()).toBe(true);
}
+90
View File
@@ -0,0 +1,90 @@
import { createServer } from 'node:net';
export type CapturedEmail = {
recipients: string[];
body: string;
};
export type SmtpSink = {
emails: CapturedEmail[];
close: () => Promise<void>;
};
// Starts a minimal SMTP server that accepts every message, so tests can read the emails Pocket ID sends
// Docker reaches it through the host gateway, like the back-channel logout receiver
export async function startSmtpSink(port: number): Promise<SmtpSink> {
const emails: CapturedEmail[] = [];
const server = createServer((socket) => {
let buffer = '';
let inData = false;
let recipients: string[] = [];
socket.write('220 smtp-sink ESMTP\r\n');
socket.on('data', (chunk) => {
buffer += chunk.toString();
while (true) {
// The message ends with a line that only holds a dot
if (inData) {
const end = buffer.indexOf('\r\n.\r\n');
if (end === -1) return;
emails.push({ recipients, body: decodeQuotedPrintable(buffer.slice(0, end)) });
buffer = buffer.slice(end + 5);
inData = false;
recipients = [];
socket.write('250 OK\r\n');
continue;
}
const lineEnd = buffer.indexOf('\r\n');
if (lineEnd === -1) return;
const line = buffer.slice(0, lineEnd);
buffer = buffer.slice(lineEnd + 2);
// Answer just enough of the protocol for a client that neither authenticates nor uses TLS
const command = line.slice(0, 4).toUpperCase();
if (command === 'EHLO' || command === 'HELO') {
socket.write('250 smtp-sink\r\n');
} else if (command === 'RCPT') {
recipients.push(line.match(/<(.*)>/)?.[1] ?? '');
socket.write('250 OK\r\n');
} else if (command === 'DATA') {
inData = true;
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
} else if (command === 'QUIT') {
socket.end('221 Bye\r\n');
return;
} else {
socket.write('250 OK\r\n');
}
}
});
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(port, '0.0.0.0', resolve);
});
return {
emails,
close: () =>
new Promise<void>((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
};
}
// Returns the path of the newest email verification link sent to the recipient
export function verificationLinkPath(sink: SmtpSink, recipient: string) {
const email = sink.emails.findLast((e) => e.recipients.includes(recipient));
const token = email?.body.match(/\/verify-email\?token=([A-Za-z0-9]+)/)?.[1];
if (!token) throw new Error(`No verification link was sent to ${recipient}`);
return `/verify-email?token=${token}`;
}
function decodeQuotedPrintable(value: string) {
return value
.replace(/=\r\n/g, '')
.replace(/=([0-9A-F]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
}