Settings › Security grows three working sections instead of a note telling people to use Stalwart's own portal. Stalwart moved this API between releases, so ihasmail speaks both: 0.16+ has the x:AccountPassword singleton and x:AppPassword registry objects over JMAP, while 0.15.x has the /api/account/auth REST endpoint. Which one answers the probe is the only reliable way to tell them apart, and the result is cached per session. The built-in `user` role already grants sysAccountPassword* and sysAppPassword*, so no administrator setup is needed. Two problems are worth calling out, because both would bite a user hard: Stalwart validates the credentials already on the account when 2FA is turned on and never checks the new secret, so an authenticator that was mistyped or out of step would lock someone out of their mailbox at the next sign-in. We verify a code against the new secret ourselves first (RFC 6238, tested against the spec's vectors) and only then ask the server to store anything. Every proxied call re-authenticates with the credential sealed into the session, and from the moment 2FA is on Stalwart wants a fresh TOTP code with it — which we cannot produce between requests. Turning 2FA on would therefore sign the user out of the browser they just turned it on in. App passwords authenticate without a second factor, so the session is moved onto one minted for this browser, and the session cookie is re-sealed with it. The order matters: it is minted while the old credential still works, and revoked again if enabling then fails. Password changes re-seal this session too and drop the others, whose sealed copies of the old password would fail on their next call. The mock now enforces what a real server does — current password, password policy, a TOTP code on every request once 2FA is on, app passwords exempt — so the whole flow is exercised in tests rather than only by hand.
164 lines
7.2 KiB
TypeScript
164 lines
7.2 KiB
TypeScript
import { test, before, after } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
/**
|
|
* End-to-end self-service credential flows against the mock, which enforces
|
|
* the same rules a real 0.16 server does: the current password is checked,
|
|
* password policy is applied, and once 2FA is on every request wants a fresh
|
|
* TOTP code — except one authenticating with an app password.
|
|
*/
|
|
|
|
const PORT = 18797;
|
|
process.env.MOCK_PORT = String(PORT);
|
|
process.env.MOCK_USER = "[email protected]";
|
|
process.env.MOCK_PASS = "demo-password";
|
|
process.env.STALWART_URL = `http://127.0.0.1:${PORT}`;
|
|
process.env.APP_SECRET = "test-secret-for-account-flows";
|
|
|
|
const mock = await import("./mock/index.js");
|
|
const { createApp } = await import("./app.js");
|
|
const { parseOtpauthUrl, totpCode } = await import("./totp.js");
|
|
|
|
const app = createApp();
|
|
let cookie = "";
|
|
|
|
const HEADERS = { "content-type": "application/json", "x-requested-with": "ihasmail" };
|
|
|
|
async function call(path: string, init: RequestInit = {}): Promise<{ status: number; body: any }> {
|
|
const res = await app.request(path, {
|
|
...init,
|
|
headers: { ...HEADERS, ...(init.headers as Record<string, string>), ...(cookie ? { cookie } : {}) },
|
|
});
|
|
const setCookie = res.headers.get("set-cookie");
|
|
if (setCookie) cookie = setCookie.split(";")[0]!;
|
|
const text = await res.text();
|
|
return { status: res.status, body: text ? JSON.parse(text) : null };
|
|
}
|
|
|
|
const post = (path: string, body: unknown) => call(path, { method: "POST", body: JSON.stringify(body) });
|
|
|
|
before(async () => {
|
|
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password" });
|
|
assert.equal(res.status, 200, "login should succeed against the mock");
|
|
});
|
|
|
|
after(() => {
|
|
(mock as { server?: { close(): void } }).server?.close();
|
|
});
|
|
|
|
test("the 0.16 registry backend is detected and reported empty", async () => {
|
|
const res = await call("/api/account/security");
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.backend, "registry");
|
|
assert.equal(res.body.otpEnabled, false);
|
|
assert.deepEqual(res.body.appPasswords, []);
|
|
assert.equal(res.body.appPasswordsKeyedByName, false);
|
|
});
|
|
|
|
test("app passwords are created, listed once with their secret, and revoked", async () => {
|
|
const created = await post("/api/account/app-passwords", { description: "Thunderbird" });
|
|
assert.equal(created.status, 200);
|
|
assert.match(created.body.secret, /^\$app\$/, "the server's generated secret is returned");
|
|
assert.ok(created.body.id);
|
|
|
|
const list = await call("/api/account/security");
|
|
assert.equal(list.body.appPasswords.length, 1);
|
|
assert.equal(list.body.appPasswords[0].description, "Thunderbird");
|
|
assert.equal(list.body.appPasswords[0].secret, undefined, "the secret is never listed again");
|
|
|
|
const revoked = await post("/api/account/app-passwords/revoke", { id: created.body.id });
|
|
assert.equal(revoked.status, 200);
|
|
assert.deepEqual((await call("/api/account/security")).body.appPasswords, []);
|
|
});
|
|
|
|
test("an app password needs a name", async () => {
|
|
const res = await post("/api/account/app-passwords", { description: " " });
|
|
assert.equal(res.status, 400);
|
|
assert.equal(res.body.error, "missing_fields");
|
|
});
|
|
|
|
test("the wrong current password is refused with the server's reason", async () => {
|
|
const res = await post("/api/account/password", { current: "not-my-password", next: "a-much-longer-password" });
|
|
assert.equal(res.status, 403);
|
|
assert.match(res.body.message, /Current secret is incorrect/);
|
|
});
|
|
|
|
test("the server's password policy is surfaced verbatim", async () => {
|
|
const res = await post("/api/account/password", { current: "demo-password", next: "short" });
|
|
assert.equal(res.status, 400);
|
|
assert.match(res.body.message, /at least 8 characters/);
|
|
});
|
|
|
|
test("a password unchanged from the old one is rejected before we ask upstream", async () => {
|
|
const res = await post("/api/account/password", { current: "demo-password", next: "demo-password" });
|
|
assert.equal(res.status, 400);
|
|
assert.equal(res.body.error, "unchanged");
|
|
});
|
|
|
|
test("changing the password keeps this session working", async () => {
|
|
const res = await post("/api/account/password", { current: "demo-password", next: "a-brand-new-password" });
|
|
assert.equal(res.status, 200);
|
|
// The stored credential was re-sealed, so the next proxied call still passes
|
|
// upstream authentication with the new password.
|
|
assert.equal((await call("/api/auth/session")).status, 200);
|
|
assert.equal((await call("/api/account/security")).status, 200);
|
|
});
|
|
|
|
test("enabling 2FA rejects a code the new secret did not produce", async () => {
|
|
const begin = await post("/api/account/2fa/begin", {});
|
|
assert.equal(begin.status, 200);
|
|
assert.match(begin.body.url, /^otpauth:\/\/totp\//);
|
|
const res = await post("/api/account/2fa/enable", { url: begin.body.url, code: "000000", current: "a-brand-new-password" });
|
|
assert.equal(res.status, 400);
|
|
assert.equal(res.body.code, undefined);
|
|
assert.match(res.body.message, /doesn't match/);
|
|
assert.equal((await call("/api/account/security")).body.otpEnabled, false, "nothing was stored");
|
|
});
|
|
|
|
test("enabling 2FA switches the session onto an app password so it survives", async () => {
|
|
const begin = await post("/api/account/2fa/begin", {});
|
|
const params = parseOtpauthUrl(begin.body.url);
|
|
assert.ok(params);
|
|
const res = await post("/api/account/2fa/enable", {
|
|
url: begin.body.url,
|
|
code: totpCode(params),
|
|
current: "a-brand-new-password",
|
|
});
|
|
assert.equal(res.status, 200);
|
|
assert.equal(res.body.sessionKept, true);
|
|
|
|
const state = await call("/api/account/security");
|
|
assert.equal(state.status, 200, "the session still authenticates upstream");
|
|
assert.equal(state.body.otpEnabled, true);
|
|
assert.equal(state.body.appPasswords.length, 1, "one app password was minted for this browser");
|
|
assert.match(state.body.appPasswords[0].description, /\(/, "it is named after the browser");
|
|
});
|
|
|
|
test("with 2FA on, a password change needs the current code too", async () => {
|
|
const withoutCode = await post("/api/account/password", { current: "a-brand-new-password", next: "yet-another-password" });
|
|
assert.equal(withoutCode.status, 403);
|
|
assert.match(withoutCode.body.message, /OTP code is required/);
|
|
});
|
|
|
|
test("2FA is switched off with the password and a current code", async () => {
|
|
const state = await call("/api/account/security");
|
|
assert.equal(state.body.otpEnabled, true);
|
|
// The enrolment secret is known only to the client, so disabling uses a code
|
|
// from the authenticator - here, the one the mock stored.
|
|
const stored = (mock as { account: { otpUrl: string | null } }).account.otpUrl;
|
|
const params = parseOtpauthUrl(stored!);
|
|
assert.ok(params);
|
|
const res = await post("/api/account/2fa/disable", { current: "a-brand-new-password", code: totpCode(params) });
|
|
assert.equal(res.status, 200);
|
|
assert.equal((await call("/api/account/security")).body.otpEnabled, false);
|
|
});
|
|
|
|
test("credential endpoints reject unauthenticated callers", async () => {
|
|
const saved = cookie;
|
|
cookie = "";
|
|
assert.equal((await call("/api/account/security")).status, 401);
|
|
assert.equal((await post("/api/account/password", { current: "a", next: "b" })).status, 401);
|
|
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
|
|
cookie = saved;
|
|
});
|