mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-15 06:09:07 +02:00
feat: add ability to force email verification (#1837)
This commit is contained in:
@@ -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 }) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user