Merge pull request #14 from LINUXexpert-org/self-service-credentials
Self-service credentials, plus the account-locale fix, server info and a theme toggle
This commit is contained in:
@@ -52,12 +52,15 @@ ihasmail is a JMAP-first web client: mail, calendars, contacts, files, filters a
|
||||
- Browse folders, upload (drag & drop), download, create folders, rename, move, delete
|
||||
|
||||
**Settings**
|
||||
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:Account/get`), falling back to the browser's; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
|
||||
- **Dates & times**: language/region (every one of the ~620 locales CLDR has data for, each named in its own language and script), date order (locale default, `22.11.2025`, `22/11/2025`, `11/22/2025` or ISO `2025-11-22`) and 12h/24h clock, applied everywhere — message list and headers, calendar, contacts, files, sessions. The default comes from the locale configured for the account in Stalwart (`x:AccountSettings/get`, falling back to `x:Account/get`), and from the browser where the server will not say; POSIX forms are normalised (`de_DE.UTF-8` → `de-DE`) and script modifiers preserved (`sr_RS@latin` → `sr-Latn-RS`). Numerals follow the locale (`٢٢.١١.٢٠٢٥` for `ar-EG`), except under ISO 8601, which pins date *and* clock to Latin digits. Dates are **entered** through custom pickers in the same format (browsers render `<input type="date">` in their own locale and ignore the page's), with a calendar popover, a time list, keyboard navigation, and lenient typing — `22.11.`, `221125`, `6:23pm` and bare ISO all parse
|
||||
- **Self-service credentials** in Settings › Security: change your password, manage **app passwords** (a separate password per mail app or device, revocable on its own), and turn **two-factor authentication** on or off by scanning a QR code. Enrolment codes are verified before anything is stored, so a mistyped key cannot lock you out, and switching 2FA on moves this browser's session onto a dedicated app password instead of signing you straight back out. Works against both Stalwart generations: the `x:AccountPassword` / `x:AppPassword` registry objects on 0.16+, and the `/api/account/auth` REST endpoint on 0.15.x (the latter confirmed live)
|
||||
- **Light and dark** follow the system by default, with a toggle in the top bar for flipping between them and a three-way choice in Settings › Appearance
|
||||
- Identities & signatures, **Sieve filters** (visual rule builder that round-trips to a Sieve script, plus a raw script editor with server-side validation), out-of-office (`VacationResponse`), folders, labels, templates, notifications, calendar defaults, sessions (sign out other devices), keyboard shortcuts, import/export of settings
|
||||
|
||||
**Platform**
|
||||
- Installable PWA (manifest + service worker), mobile layout with bottom tab bar, drawer navigation, full-screen composer, FAB
|
||||
- **Default mail app**: register ihasmail as the browser's handler for `mailto:` links from Settings › General (`registerProtocolHandler`; needs HTTPS and a browser that supports it — Safari does not). Installed as an app it also declares `protocol_handlers` in the manifest, which is what lets the operating system offer ihasmail wherever it asks for a mail client. Links arrive with recipients, Cc, Bcc, subject and body filled in
|
||||
- **About** reports the Stalwart generation ihasmail detected (0.16+ or older) and the edition where the server gives one. Stalwart does not publish a version number to clients, so no version is shown rather than a made-up one
|
||||
- Security: no credentials in the browser (server-side session with per-session encrypted upstream credentials), httpOnly SameSite cookies, CSRF header + Sec-Fetch-Site checks, strict CSP, sandboxed blob downloads, SSRF-safe image proxy, login rate limiting, security headers
|
||||
|
||||
## Architecture
|
||||
@@ -132,15 +135,15 @@ Verified against the mock server and, for the core mail flows, against a live St
|
||||
|
||||
- **HTML signatures** — Stalwart caps identity signatures at 2 KB. ihasmail compacts pasted HTML, moves images to Files and, if still too large, keeps the full signature in Files behind a short marker (other clients see a text fallback). The end-to-end flow (save → compose → send with inline logo) is implemented but not yet confirmed on the live server.
|
||||
- **Files** — the live server runs an older Stalwart build than `main`; `FileNode/query` there rejects `isTopLevel`/`parentId` filters, so ihasmail falls back to listing all nodes and building the tree client-side. Upload/rename/move/delete still need a live pass.
|
||||
- **Self-service credentials** — the **0.15.x REST path is confirmed live** against Stalwart 0.15.5 (2026-08-24): password change, app passwords, and enabling and disabling 2FA, on a real mailbox. The **0.16 registry path has only been exercised against the mock**, which enforces the same rules a real server does (current password required, password policy, a TOTP code on every request once 2FA is on, app passwords exempt from it) — it still wants a pass against a real 0.16 server. Password changes are refused by Stalwart for accounts backed by an external directory (LDAP/SQL/OIDC); the server's own message is shown when that happens.
|
||||
- Recurring events: colour/category/edit/delete apply to the whole series (per-occurrence overrides aren't supported by the server yet).
|
||||
- Editable date boxes are always Gregorian and in Latin digits, even for locales whose *display* uses another calendar or numbering system (`fa-IR`, `th-TH`, `ar-EG`) — they keep the locale's field order and separator, but a Buddhist-era year in a text box does not round-trip against the Gregorian calendar grid. Non-Gregorian calendar support is not implemented.
|
||||
- The account locale is read with Stalwart's `x:Account/get`, which needs the `sysAccountGet` permission; where a regular user is not granted it, ihasmail silently falls back to the browser locale and the setting can be chosen by hand.
|
||||
- The account locale is read from `x:AccountSettings/get`, whose permission the built-in user role has, falling back to `x:Account/get` (which needs the admin-only `sysAccountGet`). Both are Stalwart 0.16 methods: **on older servers neither is reachable** — they do not implement the registry and reject a request that so much as names the `urn:stalwart:jmap` capability — so there the locale still falls back to the browser's and can be chosen by hand.
|
||||
|
||||
## Roadmap / not yet
|
||||
|
||||
- Snooze and scheduled send (needs server-side support)
|
||||
- Read-receipt (MDN) sending, S/MIME / OpenPGP
|
||||
- Self-service password / app-password / 2FA management (Stalwart exposes this through its own account portal)
|
||||
- Translations (strings are English-only for now)
|
||||
|
||||
## License
|
||||
|
||||
Generated
+7
@@ -2459,6 +2459,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
@@ -3826,6 +3832,7 @@
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"wouter": "^3.6.0",
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
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;
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
import { config } from "./config.js";
|
||||
import { absoluteUpstream, UpstreamError, type UpstreamSession } from "./upstream.js";
|
||||
import { generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Self-service credential management, across two incompatible Stalwart APIs.
|
||||
*
|
||||
* 0.16+ JMAP registry objects: x:AccountPassword (a singleton holding the
|
||||
* password and the otpauth URL) and x:AppPassword.
|
||||
* 0.15.x a REST endpoint, POST /api/account/auth, taking a list of actions.
|
||||
*
|
||||
* The registry crate does not exist before 0.16 and the REST endpoint is gone
|
||||
* after it, so which one answers is the only reliable way to tell them apart.
|
||||
*/
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
/** Stalwart's id for a singleton object; the number it encodes spells this. */
|
||||
const SINGLETON = "singleton";
|
||||
/** Returned in place of a stored secret; echo it back to leave one unchanged. */
|
||||
const MASKED = "[********]";
|
||||
|
||||
export type Backend = "registry" | "legacy";
|
||||
|
||||
export interface AppPasswordRow {
|
||||
/** Registry object id, or the name itself on legacy servers. */
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface SecurityState {
|
||||
backend: Backend;
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordRow[];
|
||||
/**
|
||||
* Legacy servers key app passwords by name and hand back nothing else, so
|
||||
* the UI must keep names unique and cannot show when one was created.
|
||||
*/
|
||||
appPasswordsKeyedByName: boolean;
|
||||
}
|
||||
|
||||
/** An error with a message meant for the person using the app. */
|
||||
export class AccountError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status = 400,
|
||||
public readonly code = "account_error",
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AccountError";
|
||||
}
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
authorization: string;
|
||||
session: UpstreamSession;
|
||||
username: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Backend detection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const backendCache = new Map<string, { backend: Backend; at: number }>();
|
||||
const BACKEND_CACHE_MS = 30 * 60_000;
|
||||
|
||||
export function forgetBackend(sessionId: string): void {
|
||||
backendCache.delete(sessionId);
|
||||
}
|
||||
|
||||
export async function detectBackend(sessionId: string, ctx: Ctx): Promise<Backend> {
|
||||
const cached = backendCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.at < BACKEND_CACHE_MS) return cached.backend;
|
||||
const backend = await probeBackend(ctx);
|
||||
backendCache.set(sessionId, { backend, at: Date.now() });
|
||||
return backend;
|
||||
}
|
||||
|
||||
async function probeBackend(ctx: Ctx): Promise<Backend> {
|
||||
// A server with the registry answers x:AccountPassword/get; one without it
|
||||
// fails to parse the method name at all and returns unknownMethod.
|
||||
if (ctx.session.capabilities && STALWART_CAP in ctx.session.capabilities) {
|
||||
try {
|
||||
const res = await jmap(ctx, [["x:AccountPassword/get", { accountId: accountId(ctx), ids: [SINGLETON] }, "p"]]);
|
||||
const [name, args] = res.methodResponses?.[0] ?? [];
|
||||
if (name && name !== "error") return "registry";
|
||||
const type = (args as { type?: string } | undefined)?.type;
|
||||
if (type && type !== "unknownMethod") return "registry"; // present, but refused us
|
||||
} catch {
|
||||
// Not an answer we can read - most likely a server too old to know the
|
||||
// capability we named, which rejects the whole request rather than the
|
||||
// one call. Fall through and try the endpoint such servers do have.
|
||||
}
|
||||
}
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Transports */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function accountId(ctx: Ctx): string {
|
||||
return (
|
||||
ctx.session.primaryAccounts?.[STALWART_CAP] ??
|
||||
ctx.session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
Object.keys(ctx.session.accounts ?? {})[0] ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
type Invocation = [string, Record<string, unknown>, string];
|
||||
|
||||
async function jmap(ctx: Ctx, methodCalls: Invocation[]): Promise<{ methodResponses?: [string, unknown, string][] }> {
|
||||
const res = await fetch(absoluteUpstream(ctx.session.apiUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ using: [JMAP_CORE, STALWART_CAP], methodCalls }),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||
if (!res.ok) throw new UpstreamError(`Stalwart rejected the request (${res.status})`, 502);
|
||||
return (await res.json()) as { methodResponses?: [string, unknown, string][] };
|
||||
}
|
||||
|
||||
async function legacy<T>(ctx: Ctx, init: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${config.stalwartUrl}/api/account/auth`, {
|
||||
...init,
|
||||
headers: { authorization: ctx.authorization, "content-type": "application/json", accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) throw new UpstreamError("Invalid credentials", 401);
|
||||
if (res.status === 404) {
|
||||
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
|
||||
}
|
||||
if (!res.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; details?: string; reason?: string };
|
||||
detail = body.details ?? body.reason ?? body.error ?? "";
|
||||
} catch {
|
||||
/* fall through to the generic message */
|
||||
}
|
||||
throw new AccountError(detail || `The mail server rejected the change (${res.status}).`, 502, "upstream");
|
||||
}
|
||||
return ((await res.json()) as { data: T }).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the single result out of a /set, turning JMAP's several failure shapes
|
||||
* into one error carrying whatever the server was willing to explain.
|
||||
*/
|
||||
function setResult(res: { methodResponses?: [string, unknown, string][] }, kind: "created" | "updated" | "destroyed"): Record<string, unknown> | null {
|
||||
const [name, args] = res.methodResponses?.[0] ?? [];
|
||||
if (!name) throw new AccountError("The mail server sent no response.", 502, "upstream");
|
||||
if (name === "error") {
|
||||
const err = args as { type?: string; description?: string };
|
||||
if (err.type === "unknownMethod") {
|
||||
throw new AccountError("This mail server does not offer self-service credential management.", 501, "unsupported");
|
||||
}
|
||||
throw new AccountError(err.description ?? `The mail server refused the request (${err.type ?? "error"}).`, 502, err.type ?? "upstream");
|
||||
}
|
||||
const body = args as Record<string, Record<string, unknown> | undefined>;
|
||||
const notKind = kind === "created" ? "notCreated" : kind === "updated" ? "notUpdated" : "notDestroyed";
|
||||
const failures = body[notKind];
|
||||
const failure = failures && Object.values(failures)[0];
|
||||
if (failure) {
|
||||
const err = failure as { type?: string; description?: string; properties?: string[] };
|
||||
throw new AccountError(describeSetError(err), err.type === "forbidden" ? 403 : 400, err.type ?? "invalid");
|
||||
}
|
||||
const ok = body[kind];
|
||||
return ok ? ((Object.values(ok)[0] ?? {}) as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function describeSetError(err: { type?: string; description?: string; properties?: string[] }): string {
|
||||
if (err.description) return err.description;
|
||||
if (err.type === "forbidden") return "The mail server refused the change.";
|
||||
if (err.type === "overQuota") return "You have reached the number of app passwords this account allows.";
|
||||
if (err.type === "invalidProperties") {
|
||||
return err.properties?.length ? `The mail server rejected ${err.properties.join(", ")}.` : "The mail server rejected the value.";
|
||||
}
|
||||
return `The mail server refused the change (${err.type ?? "error"}).`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Operations */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export async function getState(sessionId: string, ctx: Ctx): Promise<SecurityState> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "legacy") {
|
||||
const data = await legacy<{ otpEnabled?: boolean; appPasswords?: string[] }>(ctx, { method: "GET" });
|
||||
return {
|
||||
backend,
|
||||
otpEnabled: Boolean(data.otpEnabled),
|
||||
appPasswords: (data.appPasswords ?? []).map((name) => ({ id: name, description: name, createdAt: null, expiresAt: null })),
|
||||
appPasswordsKeyedByName: true,
|
||||
};
|
||||
}
|
||||
const id = accountId(ctx);
|
||||
const res = await jmap(ctx, [
|
||||
["x:AccountPassword/get", { accountId: id, ids: [SINGLETON] }, "p"],
|
||||
["x:AppPassword/get", { accountId: id, ids: null }, "a"],
|
||||
]);
|
||||
const pass = firstListItem(res, "p") as { otpAuth?: { otpUrl?: string | null } } | null;
|
||||
const apps = listOf(res, "a");
|
||||
return {
|
||||
backend,
|
||||
// The URL itself is masked; its presence is what tells us 2FA is on.
|
||||
otpEnabled: Boolean(pass?.otpAuth?.otpUrl),
|
||||
appPasswords: apps.map((a) => ({
|
||||
id: String(a.id ?? ""),
|
||||
description: String(a.description ?? "App password"),
|
||||
createdAt: typeof a.createdAt === "string" ? a.createdAt : null,
|
||||
expiresAt: typeof a.expiresAt === "string" ? a.expiresAt : null,
|
||||
})),
|
||||
appPasswordsKeyedByName: false,
|
||||
};
|
||||
}
|
||||
|
||||
function listOf(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown>[] {
|
||||
const call = res.methodResponses?.find((r) => r[2] === callId);
|
||||
if (!call || call[0] === "error") return [];
|
||||
const list = (call[1] as { list?: unknown }).list;
|
||||
return Array.isArray(list) ? (list as Record<string, unknown>[]) : [];
|
||||
}
|
||||
|
||||
function firstListItem(res: { methodResponses?: [string, unknown, string][] }, callId: string): Record<string, unknown> | null {
|
||||
return listOf(res, callId)[0] ?? null;
|
||||
}
|
||||
|
||||
export async function changePassword(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; next: string; otpCode?: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const update: Record<string, unknown> = { currentSecret: opts.current, secret: opts.next };
|
||||
if (opts.otpCode) update["otpAuth/otpCode"] = opts.otpCode;
|
||||
const res = await jmap(ctx, [["x:AccountPassword/set", { accountId: accountId(ctx), update: { [SINGLETON]: update } }, "s"]]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
// The legacy endpoint changes the password without asking for the old one,
|
||||
// so anyone holding a live session could set it. Prove it ourselves first.
|
||||
await assertCurrentPassword(ctx, opts.current, opts.otpCode);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "setPassword", password: opts.next }]) });
|
||||
}
|
||||
|
||||
export async function createAppPassword(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { description: string },
|
||||
): Promise<{ id: string; secret: string }> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
const description = opts.description.trim() || "App password";
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), create: { n: { description } } }, "s"]]);
|
||||
const created = setResult(res, "created");
|
||||
const secret = created && typeof created.secret === "string" ? created.secret : "";
|
||||
if (!secret) throw new AccountError("The mail server created the app password but did not return it.", 502, "upstream");
|
||||
return { id: String(created?.id ?? description), secret };
|
||||
}
|
||||
// Legacy servers take a secret of our choosing and key it by name.
|
||||
const secret = readableSecret();
|
||||
await legacy<unknown>(ctx, {
|
||||
method: "POST",
|
||||
body: JSON.stringify([{ type: "addAppPassword", name: description, password: secret }]),
|
||||
});
|
||||
return { id: description, secret };
|
||||
}
|
||||
|
||||
export async function revokeAppPassword(sessionId: string, ctx: Ctx, id: string): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [["x:AppPassword/set", { accountId: accountId(ctx), destroy: [id] }, "s"]]);
|
||||
setResult(res, "destroyed");
|
||||
return;
|
||||
}
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "removeAppPassword", name: id }]) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Start enrolment: mint a secret and hand back the URL to show as a QR code.
|
||||
* Nothing is stored until the user proves they can produce a code from it.
|
||||
*/
|
||||
export function beginOtpEnrolment(ctx: Ctx): { secret: string; url: string } {
|
||||
const secret = generateSecret();
|
||||
return { secret, url: otpauthUrl({ secret, account: ctx.username, issuer: config.appName || "ihasmail" }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the user can produce a code from the secret they just scanned.
|
||||
*
|
||||
* Stalwart validates the credentials already on the account and never looks at
|
||||
* the new secret, so without this an authenticator that was mistyped or out of
|
||||
* step would lock the user out of their mailbox at the next sign-in.
|
||||
*/
|
||||
export function assertEnrolmentCode(url: string, code: string): void {
|
||||
const params = parseOtpauthUrl(url);
|
||||
if (!params) throw new AccountError("That two-factor secret is not usable.", 400, "bad_otp_url");
|
||||
if (!verifyTotp(params, code)) {
|
||||
throw new AccountError("That code doesn't match. Check your authenticator app and try the next code.", 400, "bad_code");
|
||||
}
|
||||
}
|
||||
|
||||
export async function enableOtp(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { url: string; code: string; current: string },
|
||||
): Promise<void> {
|
||||
assertEnrolmentCode(opts.url, opts.code);
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{ accountId: accountId(ctx), update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpUrl": opts.url } } },
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "enableOtpAuth", url: opts.url }]) });
|
||||
}
|
||||
|
||||
export async function disableOtp(
|
||||
sessionId: string,
|
||||
ctx: Ctx,
|
||||
opts: { current: string; code: string },
|
||||
): Promise<void> {
|
||||
const backend = await detectBackend(sessionId, ctx);
|
||||
if (backend === "registry") {
|
||||
const res = await jmap(ctx, [
|
||||
[
|
||||
"x:AccountPassword/set",
|
||||
{
|
||||
accountId: accountId(ctx),
|
||||
update: { [SINGLETON]: { currentSecret: opts.current, "otpAuth/otpCode": opts.code, "otpAuth/otpUrl": null } },
|
||||
},
|
||||
"s",
|
||||
],
|
||||
]);
|
||||
setResult(res, "updated");
|
||||
return;
|
||||
}
|
||||
await assertCurrentPassword(ctx, opts.current, opts.code);
|
||||
await legacy<unknown>(ctx, { method: "POST", body: JSON.stringify([{ type: "disableOtpAuth", url: null }]) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a password by authenticating with it, for the legacy endpoint that
|
||||
* would otherwise take our word for it.
|
||||
*/
|
||||
async function assertCurrentPassword(ctx: Ctx, current: string, otpCode?: string): Promise<void> {
|
||||
const secret = otpCode ? `${current}$${otpCode}` : current;
|
||||
const authorization = `Basic ${Buffer.from(`${ctx.username}:${secret}`, "utf8").toString("base64")}`;
|
||||
const res = await fetch(`${config.stalwartUrl}/.well-known/jmap`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
throw new AccountError("That password is incorrect.", 403, "bad_password");
|
||||
}
|
||||
if (!res.ok) throw new UpstreamError(`Could not verify the current password (${res.status})`, 502);
|
||||
}
|
||||
|
||||
/** A legacy app password a person can read off a screen and type. */
|
||||
function readableSecret(): string {
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyz23456789"; // no l/1/0 lookalikes
|
||||
const bytes = randomBytes(20);
|
||||
let out = "";
|
||||
for (let i = 0; i < 20; i++) {
|
||||
if (i > 0 && i % 5 === 0) out += "-";
|
||||
out += alphabet[bytes[i]! % alphabet.length];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export { MASKED };
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getAccountInfo, interpretAccountInfo } from "./upstream.js";
|
||||
|
||||
/**
|
||||
* The account locale used to be read only from `x:Account/get`, which needs
|
||||
* the `sysAccountGet` permission — one the built-in `user` role is not given.
|
||||
* Ordinary users therefore silently fell back to the browser locale. Stalwart
|
||||
* 0.16 exposes the same field on `x:AccountSettings`, which users *can* read,
|
||||
* so both are asked for and whichever answers wins.
|
||||
*/
|
||||
|
||||
type Responses = [string, Record<string, unknown>, string][];
|
||||
|
||||
const settingsOk = (locale: string): Responses[number] => ["x:AccountSettings/get", { list: [{ id: "singleton", locale }] }, "s"];
|
||||
const accountOk = (locale: string): Responses[number] => ["x:Account/get", { list: [{ id: "a1", locale }] }, "a"];
|
||||
const failed = (id: string, type: string): Responses[number] => ["error", { type }, id];
|
||||
|
||||
test("prefers the locale a regular user is allowed to read", () => {
|
||||
const info = interpretAccountInfo([settingsOk("de_DE.UTF-8"), accountOk("fr_FR")]);
|
||||
assert.equal(info.locale, "de-DE");
|
||||
assert.equal(info.generation, "0.16+");
|
||||
});
|
||||
|
||||
test("falls back to x:Account when the settings object is forbidden", () => {
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), accountOk("sr_RS@latin")]);
|
||||
assert.equal(info.locale, "sr-Latn-RS");
|
||||
});
|
||||
|
||||
test("an older server is recognised by its unknownMethod, and still yields a locale", () => {
|
||||
const info = interpretAccountInfo([failed("s", "unknownMethod"), accountOk("en_GB")]);
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
assert.equal(info.locale, "en-GB");
|
||||
});
|
||||
|
||||
test("a server answering the new method is 0.16+ even with no locale set", () => {
|
||||
const info = interpretAccountInfo([["x:AccountSettings/get", { list: [] }, "s"], failed("a", "forbidden")]);
|
||||
assert.equal(info.generation, "0.16+");
|
||||
assert.equal(info.locale, null);
|
||||
});
|
||||
|
||||
test("neither answering leaves everything unknown rather than guessing", () => {
|
||||
const info = interpretAccountInfo([failed("s", "forbidden"), failed("a", "forbidden")]);
|
||||
assert.deepEqual(info, { locale: null, generation: null, edition: null });
|
||||
assert.deepEqual(interpretAccountInfo([]), { locale: null, generation: null, edition: null });
|
||||
});
|
||||
|
||||
test("locales that carry no language are dropped, not passed through", () => {
|
||||
assert.equal(interpretAccountInfo([settingsOk("C")]).locale, null);
|
||||
assert.equal(interpretAccountInfo([settingsOk("POSIX")]).locale, null);
|
||||
});
|
||||
|
||||
test("a server that never heard of the Stalwart capability is reported as pre-0.16", async () => {
|
||||
// 0.16 always advertises urn:stalwart:jmap and nothing older knows it at all,
|
||||
// so its absence is the answer - and asking anyway would fail the whole
|
||||
// request on those servers. This is what the live 0.15.5 box hits.
|
||||
const session = { capabilities: { "urn:ietf:params:jmap:core": {}, "urn:ietf:params:jmap:mail": {} }, accounts: {}, primaryAccounts: {} };
|
||||
const info = await getAccountInfo("session-pre-016", "Basic x", session as never);
|
||||
assert.equal(info.generation, "pre-0.16");
|
||||
assert.equal(info.locale, null);
|
||||
assert.equal(info.edition, null);
|
||||
});
|
||||
|
||||
test("no capabilities at all leaves the generation unknown", async () => {
|
||||
const info = await getAccountInfo("session-no-caps", "Basic x", { accounts: {}, primaryAccounts: {} } as never);
|
||||
assert.equal(info.generation, null);
|
||||
});
|
||||
+221
-7
@@ -6,15 +6,28 @@ import { config } from "./config.js";
|
||||
import { SessionStore, type LiveSession } from "./sessions.js";
|
||||
import { RateLimiter } from "./ratelimit.js";
|
||||
import {
|
||||
type AccountInfo,
|
||||
UpstreamError,
|
||||
absoluteUpstream,
|
||||
expandTemplate,
|
||||
fetchUpstreamSession,
|
||||
forgetUpstreamSession,
|
||||
getAccountLocale,
|
||||
getAccountInfo,
|
||||
getUpstreamSession,
|
||||
localizeSession,
|
||||
} from "./upstream.js";
|
||||
import {
|
||||
AccountError,
|
||||
assertEnrolmentCode,
|
||||
beginOtpEnrolment,
|
||||
changePassword,
|
||||
createAppPassword,
|
||||
disableOtp,
|
||||
enableOtp,
|
||||
forgetBackend,
|
||||
getState,
|
||||
revokeAppPassword,
|
||||
} from "./account.js";
|
||||
import { imageProxyHandler } from "./imageproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
|
||||
@@ -22,6 +35,13 @@ type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
export const sessions = new SessionStore(config.sessionFile);
|
||||
const loginLimiter = new RateLimiter(config.loginRateLimit, 15 * 60_000);
|
||||
/**
|
||||
* Credential changes verify the current password upstream, and Stalwart's
|
||||
* fail2ban counts those failures against the *caller's* IP — which for a proxy
|
||||
* is shared by every user. Keep our own lid on it so one person guessing
|
||||
* cannot get the whole deployment banned.
|
||||
*/
|
||||
const accountLimiter = new RateLimiter(10, 15 * 60_000);
|
||||
|
||||
const HOP_BY_HOP = new Set([
|
||||
"connection",
|
||||
@@ -171,8 +191,8 @@ export function createApp(): Hono<Env> {
|
||||
ip,
|
||||
});
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
const locale = await getAccountLocale(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, locale)));
|
||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||
} catch (err) {
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
@@ -182,8 +202,8 @@ export function createApp(): Hono<Env> {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization, c.req.query("refresh") === "1");
|
||||
const locale = await getAccountLocale(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, locale)));
|
||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||
} catch (err) {
|
||||
if (err instanceof UpstreamError && err.status === 401) {
|
||||
sessions.destroy(session.id);
|
||||
@@ -215,6 +235,183 @@ export function createApp(): Hono<Env> {
|
||||
return c.json({ revoked: n });
|
||||
});
|
||||
|
||||
// ---------- Self-service credentials ----------
|
||||
/**
|
||||
* Password, app passwords and 2FA. These live on the server rather than in
|
||||
* the browser because the pre-0.16 API is REST rather than JMAP (the browser
|
||||
* only ever sees /api/jmap), and because changing a credential means
|
||||
* re-sealing the session cookie that holds it.
|
||||
*/
|
||||
const accountCtx = async (c: Context<Env>) => {
|
||||
const session = c.get("session");
|
||||
const upstream = await getUpstreamSession(session.id, session.authorization);
|
||||
return { authorization: session.authorization, session: upstream, username: session.username };
|
||||
};
|
||||
|
||||
const accountFailure = (c: Context, err: unknown) => {
|
||||
if (err instanceof AccountError) {
|
||||
return c.json({ error: err.code, message: err.message }, err.status as 400);
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
};
|
||||
|
||||
/** Guard the endpoints that check a password against brute-forcing. */
|
||||
const guarded = (c: Context<Env>): Response | null => {
|
||||
const key = `account|${c.get("session").username.toLowerCase()}`;
|
||||
if (accountLimiter.check(key)) return null;
|
||||
c.header("Retry-After", String(accountLimiter.retryAfterSeconds(key)));
|
||||
return c.json({ error: "rate_limited", message: "Too many attempts. Please wait and try again." }, 429);
|
||||
};
|
||||
|
||||
api.get("/account/security", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
return c.json(await getState(session.id, await accountCtx(c)));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/password", requireSession, async (c) => {
|
||||
const limited = guarded(c);
|
||||
if (limited) return limited;
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ current?: string; next?: string; otpCode?: string }>(c);
|
||||
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||
const current = body.current ?? "";
|
||||
const next = body.next ?? "";
|
||||
if (!current || !next) return c.json({ error: "missing_fields", message: "Both passwords are required." }, 400);
|
||||
if (next.length > 1024) return c.json({ error: "bad_request" }, 400);
|
||||
if (next === current) {
|
||||
return c.json({ error: "unchanged", message: "The new password matches the old one." }, 400);
|
||||
}
|
||||
try {
|
||||
await changePassword(session.id, await accountCtx(c), { current, next, otpCode: body.otpCode?.trim() || undefined });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
// The old password is now dead: re-seal this session with the new one and
|
||||
// drop the others, whose sealed copies would fail on their next call.
|
||||
const otpCode = body.otpCode?.trim();
|
||||
sessions.reseal(getCookie(c, config.cookieName), otpCode ? `${next}$${otpCode}` : next);
|
||||
forgetUpstreamSession(session.id);
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
return c.json({ ok: true, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
api.get("/account/app-passwords", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
try {
|
||||
const state = await getState(session.id, await accountCtx(c));
|
||||
return c.json({ appPasswords: state.appPasswords, keyedByName: state.appPasswordsKeyedByName });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/app-passwords", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ description?: string }>(c);
|
||||
if (!body) return c.json({ error: "bad_request" }, 400);
|
||||
const description = (body.description ?? "").trim().slice(0, 120);
|
||||
if (!description) return c.json({ error: "missing_fields", message: "Give the app password a name." }, 400);
|
||||
try {
|
||||
return c.json(await createAppPassword(session.id, await accountCtx(c), { description }));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/app-passwords/revoke", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ id?: string }>(c);
|
||||
if (!body?.id) return c.json({ error: "bad_request" }, 400);
|
||||
try {
|
||||
await revokeAppPassword(session.id, await accountCtx(c), body.id);
|
||||
return c.json({ ok: true });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/2fa/begin", requireSession, async (c) => {
|
||||
try {
|
||||
// Nothing is stored yet; the client hands the URL back to confirm.
|
||||
return c.json(beginOtpEnrolment(await accountCtx(c)));
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
api.post("/account/2fa/enable", requireSession, async (c) => {
|
||||
const limited = guarded(c);
|
||||
if (limited) return limited;
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ url?: string; code?: string; current?: string }>(c);
|
||||
if (!body?.url || !body.code || !body.current) return c.json({ error: "bad_request" }, 400);
|
||||
const ctx = await accountCtx(c);
|
||||
const code = body.code.trim();
|
||||
/*
|
||||
* Every proxied call re-authenticates with the stored password, and once
|
||||
* 2FA is on the server wants a fresh TOTP code alongside it — which we
|
||||
* cannot produce between requests. An app password authenticates without
|
||||
* one, so the session moves onto a dedicated app password rather than
|
||||
* being signed out the moment 2FA is switched on.
|
||||
*
|
||||
* Order matters: mint it while the current credential still works, since
|
||||
* the moment 2FA is enabled this session can no longer authenticate at all.
|
||||
*/
|
||||
try {
|
||||
assertEnrolmentCode(body.url, code);
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
let app: { id: string; secret: string } | null = null;
|
||||
try {
|
||||
app = await createAppPassword(session.id, ctx, { description: appPasswordName(c) });
|
||||
} catch (err) {
|
||||
// Out of app-password quota, say. 2FA is still worth having; the user
|
||||
// just has to sign in again afterwards.
|
||||
console.warn("[ihasmail] could not mint a session app password:", (err as Error).message);
|
||||
}
|
||||
try {
|
||||
await enableOtp(session.id, ctx, { url: body.url, code, current: body.current });
|
||||
} catch (err) {
|
||||
if (app) {
|
||||
// Don't leave a credential behind for a change that never happened.
|
||||
await revokeAppPassword(session.id, ctx, app.id).catch(() => {});
|
||||
}
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
let sessionKept = false;
|
||||
if (app) {
|
||||
sessionKept = sessions.reseal(getCookie(c, config.cookieName), app.secret);
|
||||
if (sessionKept) forgetUpstreamSession(session.id);
|
||||
}
|
||||
// Other sessions still hold the bare password and will be refused.
|
||||
const revoked = sessions.destroyAllForUser(session.username, session.id);
|
||||
return c.json({ ok: true, sessionKept, revokedSessions: revoked });
|
||||
});
|
||||
|
||||
api.post("/account/2fa/disable", requireSession, async (c) => {
|
||||
const limited = guarded(c);
|
||||
if (limited) return limited;
|
||||
const session = c.get("session");
|
||||
const body = await readJson<{ current?: string; code?: string }>(c);
|
||||
if (!body?.current || !body.code) return c.json({ error: "bad_request" }, 400);
|
||||
try {
|
||||
await disableOtp(session.id, await accountCtx(c), { current: body.current, code: body.code.trim() });
|
||||
} catch (err) {
|
||||
return accountFailure(c, err);
|
||||
}
|
||||
// This session may be running on the app password minted when 2FA went on;
|
||||
// the plain password works again now, so put it back.
|
||||
sessions.reseal(getCookie(c, config.cookieName), body.current);
|
||||
forgetUpstreamSession(session.id);
|
||||
forgetBackend(session.id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---------- JMAP API proxy ----------
|
||||
api.post("/jmap", requireSession, async (c) => {
|
||||
const session = c.get("session");
|
||||
@@ -353,7 +550,22 @@ export function createApp(): Hono<Env> {
|
||||
return app;
|
||||
}
|
||||
|
||||
function sessionExtras(session: LiveSession, userLocale: string | null = null) {
|
||||
async function readJson<T>(c: Context): Promise<T | null> {
|
||||
try {
|
||||
return (await c.req.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Name the app password after the browser it will live in. */
|
||||
function appPasswordName(c: Context): string {
|
||||
const ua = c.req.header("user-agent") ?? "";
|
||||
const browser = /Firefox\//.test(ua) ? "Firefox" : /Edg\//.test(ua) ? "Edge" : /Chrome\//.test(ua) ? "Chrome" : /Safari\//.test(ua) ? "Safari" : "browser";
|
||||
return `${config.appName} (${browser})`;
|
||||
}
|
||||
|
||||
function sessionExtras(session: LiveSession, info: AccountInfo = { locale: null, generation: null, edition: null }) {
|
||||
return {
|
||||
ihasmail: {
|
||||
appName: config.appName,
|
||||
@@ -363,7 +575,9 @@ function sessionExtras(session: LiveSession, userLocale: string | null = null) {
|
||||
loginName: session.username,
|
||||
remember: session.remember,
|
||||
/** Locale configured for the account in Stalwart's directory, if readable. */
|
||||
userLocale,
|
||||
userLocale: info.locale,
|
||||
/** What the upstream server would tell us about itself. */
|
||||
server: { generation: info.generation, edition: info.edition },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+100
-3
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { parseOtpauthUrl, verifyTotp } from "../totp.js";
|
||||
|
||||
const PORT = Number(process.env.MOCK_PORT ?? 8788);
|
||||
const ACCOUNT = "a1";
|
||||
@@ -12,6 +13,14 @@ const USER = process.env.MOCK_USER ?? "[email protected]";
|
||||
/** Locale the fake directory reports for the account (POSIX style, as Stalwart does). */
|
||||
const MOCK_LOCALE = process.env.MOCK_LOCALE ?? "en_US";
|
||||
const PASS = process.env.MOCK_PASS ?? "demo";
|
||||
/**
|
||||
* Credential state, mutable so the self-service flows can be exercised against
|
||||
* the mock the way they run against a real 0.16 server: the password changes,
|
||||
* 2FA starts demanding a code on every request, and app passwords keep working
|
||||
* without one.
|
||||
*/
|
||||
export const account = { password: PASS, otpUrl: null as string | null, appPasswords: [] as Obj[] };
|
||||
const MASKED = "[********]";
|
||||
|
||||
type Obj = Record<string, unknown>;
|
||||
const state = { n: 1 };
|
||||
@@ -256,6 +265,13 @@ function genericSet(list: Obj[], prefix: string, onCreate?: (o: Obj) => void) {
|
||||
}
|
||||
|
||||
const handlers: Record<string, Handler> = {
|
||||
// 0.16 exposes the account locale here, under a permission ordinary users
|
||||
// actually have (unlike x:Account below, which needs sysAccountGet).
|
||||
"x:AccountSettings/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? ["singleton"];
|
||||
const list = ids.filter((id) => id === "singleton").map((id) => ({ id, locale: MOCK_LOCALE, timeZone: null, description: null }));
|
||||
return { accountId: ACCOUNT, state: String(state.n), list: list.map((x) => pick(x, a.properties as string[] | null)), notFound: ids.filter((id) => id !== "singleton") };
|
||||
},
|
||||
// Stalwart's directory extension - the client reads the account locale from here.
|
||||
"x:Account/get": (a) => {
|
||||
const ids = (a.ids as string[] | null) ?? [ACCOUNT];
|
||||
@@ -302,6 +318,64 @@ const handlers: Record<string, Handler> = {
|
||||
},
|
||||
"Email/import": (a) => { const created: Obj = {}; for (const [cid, spec] of Object.entries((a.emails as Obj) ?? {})) { const id = `e${counter++}`; emails.push({ id, blobId: (spec as Obj).blobId, threadId: `t${id}`, mailboxIds: (spec as Obj).mailboxIds, keywords: (spec as Obj).keywords ?? {}, size: 100, receivedAt: new Date().toISOString(), subject: "(imported message)", from: [{ name: null, email: "import@example" }], to: null, preview: "", hasAttachment: false, textBody: [], htmlBody: [], attachments: [], bodyValues: {} }); created[cid] = { id }; } recount(); return setResp({ created }); },
|
||||
"Thread/get": (a) => { const ids = a.ids as string[]; const list = ids.map((id) => ({ id, emailIds: emails.filter((e) => e.threadId === id).sort((x, y) => String(x.receivedAt).localeCompare(String(y.receivedAt))).map((e) => e.id) })).filter((t) => t.emailIds.length); return { accountId: ACCOUNT, state: String(state.n), list, notFound: ids.filter((id) => !list.some((t) => t.id === id)) }; },
|
||||
// Stalwart 0.16 registry objects backing self-service credentials.
|
||||
"x:AccountPassword/get": () => ({
|
||||
accountId: ACCOUNT,
|
||||
state: String(state.n),
|
||||
list: [{ id: "singleton", otpAuth: { otpUrl: account.otpUrl ? MASKED : null, otpCode: null } }],
|
||||
notFound: [],
|
||||
}),
|
||||
"x:AccountPassword/set": (a) => {
|
||||
const patch = ((a.update as Obj) ?? {})["singleton"] as Obj | undefined;
|
||||
if (!patch) return setResp({ updated: {} });
|
||||
const current = patch.currentSecret as string | undefined;
|
||||
const code = (patch["otpAuth/otpCode"] ?? (patch.otpAuth as Obj | undefined)?.otpCode) as string | undefined;
|
||||
if (!current) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret must be provided to change the password or OTP auth." } } });
|
||||
}
|
||||
if (current !== account.password) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
|
||||
}
|
||||
if (account.otpUrl && !code) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current OTP code is required to change the password or OTP auth." } } });
|
||||
}
|
||||
if (account.otpUrl && !checkOtp(code!)) {
|
||||
return setResp({ notUpdated: { singleton: { type: "forbidden", description: "Current secret is incorrect." } } });
|
||||
}
|
||||
const secret = patch.secret as string | undefined;
|
||||
if (secret !== undefined && secret !== MASKED) {
|
||||
if (secret.length < 8) {
|
||||
return setResp({ notUpdated: { singleton: { type: "invalidProperties", properties: ["secret"], description: "Password must be at least 8 characters long." } } });
|
||||
}
|
||||
account.password = secret;
|
||||
}
|
||||
if ("otpAuth/otpUrl" in patch) {
|
||||
const url = patch["otpAuth/otpUrl"] as string | null;
|
||||
if (url !== MASKED) account.otpUrl = url;
|
||||
}
|
||||
state.n++;
|
||||
return setResp({ updated: { singleton: null } });
|
||||
},
|
||||
"x:AppPassword/get": (a) => genericGet(account.appPasswords)(a),
|
||||
"x:AppPassword/set": (a) => {
|
||||
const created: Obj = {};
|
||||
const destroyed: string[] = [];
|
||||
for (const [cid, obj] of Object.entries((a.create as Obj) ?? {})) {
|
||||
const id = `ap${randomUUID().slice(0, 6)}`;
|
||||
// Real app passwords carry their credential id, so the server can spot
|
||||
// one by its shape alone. Mirror that.
|
||||
const secret = `$app$${id}$${randomUUID().replace(/-/g, "").slice(0, 20)}`;
|
||||
const row: Obj = { id, description: (obj as Obj).description ?? "App password", createdAt: new Date().toISOString(), expiresAt: null, secret };
|
||||
account.appPasswords.push(row);
|
||||
created[cid] = { id, secret, createdAt: row.createdAt };
|
||||
}
|
||||
for (const id of (a.destroy as string[]) ?? []) {
|
||||
const i = account.appPasswords.findIndex((x) => x.id === id);
|
||||
if (i >= 0) { account.appPasswords.splice(i, 1); destroyed.push(id); }
|
||||
}
|
||||
state.n++;
|
||||
return setResp({ created, destroyed });
|
||||
},
|
||||
"Identity/get": genericGet(identities),
|
||||
"Identity/set": genericSet(identities, "i", (o) => Object.assign(o, { replyTo: null, bcc: null, textSignature: "", htmlSignature: "", mayDelete: true, ...o })),
|
||||
"EmailSubmission/set": (a) => {
|
||||
@@ -349,11 +423,28 @@ function unauthorized(res: ServerResponse) {
|
||||
res.writeHead(401, { "content-type": "application/json", "www-authenticate": 'Basic realm="mock"' });
|
||||
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" }));
|
||||
}
|
||||
function checkOtp(code: string | undefined): boolean {
|
||||
if (!account.otpUrl) return true;
|
||||
const params = parseOtpauthUrl(account.otpUrl);
|
||||
return Boolean(code && params && verifyTotp(params, code));
|
||||
}
|
||||
|
||||
function checkAuth(req: IncomingMessage): boolean {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (!h.startsWith("Basic ")) return false;
|
||||
const [u, p] = Buffer.from(h.slice(6), "base64").toString().split(":");
|
||||
return u === USER && p === PASS;
|
||||
const raw = Buffer.from(h.slice(6), "base64").toString();
|
||||
const sep = raw.indexOf(":");
|
||||
if (sep < 0) return false;
|
||||
const u = raw.slice(0, sep);
|
||||
const p = raw.slice(sep + 1);
|
||||
if (u !== USER) return false;
|
||||
// App passwords are recognised by shape and skip the second factor, which is
|
||||
// exactly what lets a webmail session survive 2FA being switched on.
|
||||
if (account.appPasswords.some((a) => a.secret === p)) return true;
|
||||
if (!account.otpUrl) return p === account.password;
|
||||
const at = p.lastIndexOf("$");
|
||||
if (at < 0) return false;
|
||||
return p.slice(0, at) === account.password && checkOtp(p.slice(at + 1));
|
||||
}
|
||||
function readBody(req: IncomingMessage): Promise<Buffer> {
|
||||
return new Promise((resolve) => { const chunks: Buffer[] = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks))); });
|
||||
@@ -377,13 +468,19 @@ function broadcast(types: string[]) {
|
||||
for (const c of sseClients) c.write(payload);
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
/** Exported so tests can drive the mock in-process and shut it down. */
|
||||
export const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://127.0.0.1:${PORT}`);
|
||||
if (!checkAuth(req)) return unauthorized(res);
|
||||
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify(session()));
|
||||
}
|
||||
// 0.16's account info endpoint; the only place a server reports its edition.
|
||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify({ permissions: ["jmapEmailGet", "sysAccountSettingsGet"], edition: "oss", locale: MOCK_LOCALE }));
|
||||
}
|
||||
if (url.pathname === "/jmap/" && req.method === "POST") {
|
||||
const body = JSON.parse((await readBody(req)).toString()) as { methodCalls: [string, Obj, string][] };
|
||||
const responses: [string, Obj, string][] = [];
|
||||
|
||||
@@ -171,6 +171,30 @@ export class SessionStore {
|
||||
return this.toLive(stored, creds.u, creds.p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seal this session's stored credentials.
|
||||
*
|
||||
* The upstream password is what every proxied call authenticates with, so a
|
||||
* password change (or swapping in an app password when 2FA is switched on)
|
||||
* would otherwise leave the session holding a credential the server no
|
||||
* longer accepts. Needs the cookie: the sealing key is derived from the
|
||||
* secret half of it, which the server never keeps.
|
||||
*/
|
||||
reseal(cookie: string | undefined, password: string): boolean {
|
||||
if (!cookie) return false;
|
||||
const idx = cookie.indexOf(COOKIE_SEP);
|
||||
if (idx <= 0) return false;
|
||||
const id = cookie.slice(0, idx);
|
||||
const secret = cookie.slice(idx + 1);
|
||||
const stored = this.sessions.get(id);
|
||||
if (!stored) return false;
|
||||
if (!safeEqual(stored.secretHash, sha256(secret))) return false;
|
||||
const key = deriveKey(secret, config.appSecret, Buffer.from(stored.salt, "base64"));
|
||||
stored.sealedCredentials = seal(JSON.stringify({ u: stored.username, p: password }), key);
|
||||
this.scheduleSave();
|
||||
return true;
|
||||
}
|
||||
|
||||
destroy(id: string): void {
|
||||
if (this.sessions.delete(id)) this.scheduleSave();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { base32Decode, base32Encode, generateSecret, otpauthUrl, parseOtpauthUrl, verifyTotp } from "./totp.js";
|
||||
|
||||
/** RFC 6238 Appendix B seeds. */
|
||||
const SHA1_SECRET = base32Encode(Buffer.from("12345678901234567890", "ascii"));
|
||||
const SHA256_SECRET = base32Encode(Buffer.from("12345678901234567890123456789012", "ascii"));
|
||||
|
||||
test("base32 matches the RFC 4648 alphabet and round-trips", () => {
|
||||
assert.equal(SHA1_SECRET, "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ");
|
||||
assert.equal(base32Encode(Buffer.from("f", "ascii")), "MY");
|
||||
assert.equal(base32Encode(Buffer.from("foobar", "ascii")), "MZXW6YTBOI");
|
||||
assert.deepEqual(base32Decode("MZXW6YTBOI"), Buffer.from("foobar", "ascii"));
|
||||
// Users paste secrets with spaces, lowercase and padding.
|
||||
assert.deepEqual(base32Decode("mzxw 6ytb-oi==="), Buffer.from("foobar", "ascii"));
|
||||
assert.equal(base32Decode("not base32!"), null);
|
||||
});
|
||||
|
||||
test("verifyTotp accepts the RFC 6238 SHA-1 test vectors", () => {
|
||||
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
|
||||
for (const [time, code] of [
|
||||
[59, "94287082"],
|
||||
[1111111109, "07081804"],
|
||||
[1111111111, "14050471"],
|
||||
[1234567890, "89005924"],
|
||||
[2000000000, "69279037"],
|
||||
[20000000000, "65353130"],
|
||||
] as const) {
|
||||
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyTotp accepts the RFC 6238 SHA-256 test vectors", () => {
|
||||
const params = { secret: SHA256_SECRET, algorithm: "SHA256" as const, digits: 8, period: 30 };
|
||||
for (const [time, code] of [
|
||||
[59, "46119246"],
|
||||
[1111111109, "68084774"],
|
||||
[1234567890, "91819424"],
|
||||
] as const) {
|
||||
assert.equal(verifyTotp(params, code, { window: 0, now: time * 1000 }), true, `t=${time}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("verifyTotp rejects wrong, malformed and mis-sized codes", () => {
|
||||
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
|
||||
const at = { window: 0, now: 59_000 };
|
||||
assert.equal(verifyTotp(params, "94287083", at), false);
|
||||
assert.equal(verifyTotp(params, "9428708", at), false, "too short");
|
||||
assert.equal(verifyTotp(params, "942870822", at), false, "too long");
|
||||
assert.equal(verifyTotp(params, "abcdefgh", at), false);
|
||||
assert.equal(verifyTotp(params, "", at), false);
|
||||
assert.equal(verifyTotp({ ...params, secret: "!!!" }, "94287082", at), false, "bad secret");
|
||||
});
|
||||
|
||||
test("the skew window covers a step either side and no further", () => {
|
||||
const params = { secret: SHA1_SECRET, algorithm: "SHA1" as const, digits: 8, period: 30 };
|
||||
// 94287082 is the code for the step containing t=59.
|
||||
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 89_000 }), true, "one step late");
|
||||
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 29_000 }), true, "one step early");
|
||||
assert.equal(verifyTotp(params, "94287082", { window: 1, now: 119_000 }), false, "two steps late");
|
||||
});
|
||||
|
||||
test("otpauth URLs round-trip through the parser", () => {
|
||||
const secret = generateSecret();
|
||||
const url = otpauthUrl({ secret, account: "[email protected]", issuer: "ihasmail" });
|
||||
assert.match(url, /^otpauth:\/\/totp\/ihasmail:ann%40example\.org\?/);
|
||||
const parsed = parseOtpauthUrl(url);
|
||||
assert.deepEqual(parsed, { secret, algorithm: "SHA1", digits: 6, period: 30 });
|
||||
});
|
||||
|
||||
test("generated secrets are 160-bit and distinct", () => {
|
||||
const a = generateSecret();
|
||||
const b = generateSecret();
|
||||
assert.equal(base32Decode(a)?.length, 20);
|
||||
assert.notEqual(a, b);
|
||||
});
|
||||
|
||||
test("parseOtpauthUrl rejects anything that is not a usable TOTP URL", () => {
|
||||
assert.equal(parseOtpauthUrl("https://example.org"), null);
|
||||
assert.equal(parseOtpauthUrl("otpauth://hotp/a?secret=GEZDGNBV"), null, "counter-based");
|
||||
assert.equal(parseOtpauthUrl("otpauth://totp/a"), null, "no secret");
|
||||
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=!!!"), null, "unusable secret");
|
||||
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&algorithm=MD5"), null);
|
||||
assert.equal(parseOtpauthUrl("otpauth://totp/a?secret=GEZDGNBV&digits=99"), null);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* TOTP (RFC 6238) — just enough to enrol a second factor safely.
|
||||
*
|
||||
* Stalwart stores the otpauth:// URL and checks codes at login, but it does
|
||||
* *not* check the new secret when 2FA is switched on: it verifies the
|
||||
* credentials that are already on the account. A user whose authenticator was
|
||||
* mistyped or whose clock has drifted would be locked out of their mailbox at
|
||||
* the next sign-in. So ihasmail proves the enrolment itself, before asking the
|
||||
* server to store anything.
|
||||
*/
|
||||
|
||||
export interface TotpParams {
|
||||
secret: string;
|
||||
algorithm: "SHA1" | "SHA256" | "SHA512";
|
||||
digits: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Omit<TotpParams, "secret"> = { algorithm: "SHA1", digits: 6, period: 30 };
|
||||
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
export function base32Encode(buf: Buffer): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let out = "";
|
||||
for (const byte of buf) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += BASE32[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Decode base32, tolerating lowercase, padding and the spaces users paste. */
|
||||
export function base32Decode(input: string): Buffer | null {
|
||||
const clean = input.replace(/[\s-]/g, "").replace(/=+$/, "").toUpperCase();
|
||||
if (!clean || /[^A-Z2-7]/.test(clean)) return null;
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out: number[] = [];
|
||||
for (const ch of clean) {
|
||||
value = (value << 5) | BASE32.indexOf(ch);
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
out.push((value >>> (bits - 8)) & 255);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
/** A fresh 160-bit secret — the size RFC 4226 recommends for HMAC-SHA1. */
|
||||
export function generateSecret(): string {
|
||||
return base32Encode(randomBytes(20));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the otpauth:// URL that authenticator apps scan and Stalwart stores.
|
||||
* The label is "issuer:account" with the issuer repeated as a parameter, which
|
||||
* is what totp-rs (Stalwart's parser) and every common app expect.
|
||||
*/
|
||||
export function otpauthUrl(opts: { secret: string; account: string; issuer: string }): string {
|
||||
const label = `${encodeURIComponent(opts.issuer)}:${encodeURIComponent(opts.account)}`;
|
||||
const params = new URLSearchParams({
|
||||
secret: opts.secret,
|
||||
issuer: opts.issuer,
|
||||
algorithm: DEFAULTS.algorithm,
|
||||
digits: String(DEFAULTS.digits),
|
||||
period: String(DEFAULTS.period),
|
||||
});
|
||||
return `otpauth://totp/${label}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function parseOtpauthUrl(url: string): TotpParams | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== "otpauth:" || parsed.host.toLowerCase() !== "totp") return null;
|
||||
const secret = parsed.searchParams.get("secret");
|
||||
if (!secret || !base32Decode(secret)) return null;
|
||||
const algorithm = (parsed.searchParams.get("algorithm") ?? DEFAULTS.algorithm).toUpperCase();
|
||||
if (algorithm !== "SHA1" && algorithm !== "SHA256" && algorithm !== "SHA512") return null;
|
||||
const digits = Number(parsed.searchParams.get("digits") ?? DEFAULTS.digits);
|
||||
const period = Number(parsed.searchParams.get("period") ?? DEFAULTS.period);
|
||||
if (!Number.isInteger(digits) || digits < 6 || digits > 10) return null;
|
||||
if (!Number.isInteger(period) || period < 5 || period > 300) return null;
|
||||
return { secret, algorithm, digits, period };
|
||||
}
|
||||
|
||||
/** The HOTP code for one counter value. */
|
||||
function hotp(key: Buffer, counter: number, algorithm: string, digits: number): string {
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeBigUInt64BE(BigInt(counter));
|
||||
const digest = createHmac(algorithm.toLowerCase(), key).update(buf).digest();
|
||||
const offset = digest[digest.length - 1]! & 0x0f;
|
||||
const binary = digest.readUInt32BE(offset) & 0x7fffffff;
|
||||
return (binary % 10 ** digits).toString().padStart(digits, "0");
|
||||
}
|
||||
|
||||
/** The code an authenticator app would show at `now`. */
|
||||
export function totpCode(params: TotpParams, now = Date.now()): string {
|
||||
const key = base32Decode(params.secret);
|
||||
if (!key || !key.length) throw new Error("unusable TOTP secret");
|
||||
return hotp(key, Math.floor(now / 1000 / params.period), params.algorithm, params.digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a user-supplied code, allowing `window` steps of clock skew either way
|
||||
* (one step = 30s by default, so the default tolerates ±30s).
|
||||
*/
|
||||
export function verifyTotp(params: TotpParams, code: string, opts: { window?: number; now?: number } = {}): boolean {
|
||||
const digits = params.digits;
|
||||
const cleaned = code.replace(/\s/g, "");
|
||||
if (cleaned.length !== digits || !/^\d+$/.test(cleaned)) return false;
|
||||
const key = base32Decode(params.secret);
|
||||
if (!key || !key.length) return false;
|
||||
const window = opts.window ?? 1;
|
||||
const counter = Math.floor((opts.now ?? Date.now()) / 1000 / params.period);
|
||||
let ok = false;
|
||||
// Check every candidate rather than returning early, so the time taken does
|
||||
// not reveal which step matched.
|
||||
for (let i = -window; i <= window; i++) {
|
||||
const step = counter + i;
|
||||
if (step < 0) continue; // only reachable for times within a step of the epoch
|
||||
const expected = hotp(key, step, params.algorithm, digits);
|
||||
if (safeEqual(expected, cleaned)) ok = true;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
+92
-22
@@ -59,7 +59,7 @@ export async function getUpstreamSession(sessionId: string, authorization: strin
|
||||
|
||||
export function forgetUpstreamSession(sessionId: string): void {
|
||||
sessionCache.delete(sessionId);
|
||||
localeCache.delete(sessionId);
|
||||
infoCache.delete(sessionId);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -68,8 +68,25 @@ export function forgetUpstreamSession(sessionId: string): void {
|
||||
|
||||
const STALWART_CAP = "urn:stalwart:jmap";
|
||||
const JMAP_CORE = "urn:ietf:params:jmap:core";
|
||||
const localeCache = new Map<string, { locale: string | null; fetchedAt: number }>();
|
||||
const LOCALE_CACHE_MS = 30 * 60_000;
|
||||
|
||||
export interface AccountInfo {
|
||||
/** BCP-47 tag configured for the account, or null if unreadable. */
|
||||
locale: string | null;
|
||||
/**
|
||||
* Which generation of Stalwart's API answered: "0.16+" has the registry
|
||||
* (`x:AccountSettings`), older builds only have `x:Account`. Null when the
|
||||
* server is not Stalwart or told us nothing.
|
||||
*/
|
||||
generation: "0.16+" | "pre-0.16" | null;
|
||||
/** "oss" | "community" | "enterprise", where the server reports it. */
|
||||
edition: string | null;
|
||||
}
|
||||
|
||||
const infoCache = new Map<string, { info: AccountInfo; fetchedAt: number }>();
|
||||
const INFO_CACHE_MS = 30 * 60_000;
|
||||
const EMPTY_INFO: AccountInfo = { locale: null, generation: null, edition: null };
|
||||
/** A server that has never heard of the registry: nothing to read, but dated. */
|
||||
const PRE_REGISTRY_INFO: AccountInfo = { locale: null, generation: "pre-0.16", edition: null };
|
||||
|
||||
/**
|
||||
* glibc modifiers that name a script rather than a dialect or a currency:
|
||||
@@ -112,47 +129,100 @@ export function normalizeLocale(raw: unknown): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort lookup of the locale configured for this account in Stalwart's
|
||||
* directory (`x:Account/get`, Stalwart's JMAP extension). Servers that do not
|
||||
* expose it — or that deny a regular user the `sysAccountGet` permission —
|
||||
* simply yield null and the client falls back to the browser locale.
|
||||
* Best-effort lookup of what the server can tell us about this account.
|
||||
*
|
||||
* The locale used to come from `x:Account/get`, which needs the `sysAccountGet`
|
||||
* permission — a tenant/admin one that ordinary users are not granted, so the
|
||||
* setting silently fell back to the browser locale for exactly the people most
|
||||
* likely to want it. Stalwart 0.16 exposes the same field on `x:AccountSettings`,
|
||||
* whose `sysAccountSettingsGet` permission *is* part of the built-in user role.
|
||||
* Ask for both in one request and take whichever the server allows, which also
|
||||
* tells us which generation we are talking to.
|
||||
*/
|
||||
async function fetchAccountLocale(authorization: string, session: UpstreamSession): Promise<string | null> {
|
||||
if (!session.capabilities || !(STALWART_CAP in session.capabilities)) return null;
|
||||
async function fetchAccountInfo(authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||
// Every 0.16 build advertises urn:stalwart:jmap, and no earlier one knows it
|
||||
// at all, so its absence already answers the question — and asking anyway
|
||||
// would fail the whole request, since those servers reject a `using` naming
|
||||
// a capability they cannot parse.
|
||||
if (!session.capabilities) return EMPTY_INFO;
|
||||
if (!(STALWART_CAP in session.capabilities)) return PRE_REGISTRY_INFO;
|
||||
const accountId =
|
||||
session.primaryAccounts?.[STALWART_CAP] ??
|
||||
session.primaryAccounts?.["urn:ietf:params:jmap:mail"] ??
|
||||
Object.keys(session.accounts ?? {})[0];
|
||||
if (!accountId) return null;
|
||||
if (!accountId) return EMPTY_INFO;
|
||||
const res = await fetch(absoluteUpstream(session.apiUrl), {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
using: [JMAP_CORE, STALWART_CAP],
|
||||
methodCalls: [["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "l"]],
|
||||
methodCalls: [
|
||||
["x:AccountSettings/get", { accountId, ids: ["singleton"], properties: ["locale"] }, "s"],
|
||||
["x:Account/get", { accountId, ids: [accountId], properties: ["locale"] }, "a"],
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) return EMPTY_INFO;
|
||||
const body = (await res.json()) as { methodResponses?: [string, Record<string, unknown>, string][] };
|
||||
const call = body.methodResponses?.[0];
|
||||
if (!call || call[0] !== "x:Account/get") return null;
|
||||
return interpretAccountInfo(body.methodResponses ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the pair of replies: prefer the locale from `x:AccountSettings`, fall
|
||||
* back to `x:Account` for servers (or permissions) where only that one works,
|
||||
* and note which generation answered.
|
||||
*/
|
||||
export function interpretAccountInfo(responses: [string, Record<string, unknown>, string][]): AccountInfo {
|
||||
const settings = responses.find((r) => r[2] === "s");
|
||||
const account = responses.find((r) => r[2] === "a");
|
||||
// Only 0.16+ knows the method at all; older builds cannot even parse the name.
|
||||
const generation: AccountInfo["generation"] =
|
||||
settings && settings[0] !== "error"
|
||||
? "0.16+"
|
||||
: (settings?.[1] as { type?: string } | undefined)?.type === "unknownMethod"
|
||||
? "pre-0.16"
|
||||
: null;
|
||||
return { locale: localeOf(settings) ?? localeOf(account), generation, edition: null };
|
||||
}
|
||||
|
||||
function localeOf(call: [string, Record<string, unknown>, string] | undefined): string | null {
|
||||
if (!call || call[0] === "error") return null;
|
||||
const list = call[1]?.list;
|
||||
if (!Array.isArray(list) || !list.length) return null;
|
||||
return normalizeLocale((list[0] as { locale?: unknown } | undefined)?.locale);
|
||||
}
|
||||
|
||||
export async function getAccountLocale(sessionId: string, authorization: string, session: UpstreamSession): Promise<string | null> {
|
||||
const cached = localeCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < LOCALE_CACHE_MS) return cached.locale;
|
||||
let locale: string | null = null;
|
||||
/**
|
||||
* Which edition the server is running. Stalwart deliberately does not publish
|
||||
* its version number to clients, but 0.16 does report its edition here.
|
||||
*/
|
||||
async function fetchEdition(authorization: string): Promise<string | null> {
|
||||
try {
|
||||
locale = await fetchAccountLocale(authorization, session);
|
||||
const res = await fetch(`${config.stalwartUrl}/api/account`, {
|
||||
headers: { authorization, accept: "application/json" },
|
||||
signal: AbortSignal.timeout(config.upstreamTimeout),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { edition?: unknown };
|
||||
return typeof body.edition === "string" ? body.edition : null;
|
||||
} catch {
|
||||
/* the server locale is a nicety - never fail the session over it */
|
||||
return null;
|
||||
}
|
||||
localeCache.set(sessionId, { locale, fetchedAt: Date.now() });
|
||||
return locale;
|
||||
}
|
||||
|
||||
export async function getAccountInfo(sessionId: string, authorization: string, session: UpstreamSession): Promise<AccountInfo> {
|
||||
const cached = infoCache.get(sessionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < INFO_CACHE_MS) return cached.info;
|
||||
let info = EMPTY_INFO;
|
||||
try {
|
||||
info = await fetchAccountInfo(authorization, session);
|
||||
if (info.generation === "0.16+") info = { ...info, edition: await fetchEdition(authorization) };
|
||||
} catch {
|
||||
/* all of this is a nicety - never fail the session over it */
|
||||
}
|
||||
infoCache.set(sessionId, { info, fetchedAt: Date.now() });
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@tanstack/react-virtual": "^3.13.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"lucide-react": "^0.477.0",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"wouter": "^3.6.0",
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface JmapSession {
|
||||
remember: boolean;
|
||||
/** Locale configured for the account in Stalwart, if the server exposes it. */
|
||||
userLocale?: string | null;
|
||||
/** What the upstream server was willing to say about itself. */
|
||||
server?: {
|
||||
/** Which API generation answered: Stalwart publishes no version number. */
|
||||
generation?: "0.16+" | "pre-0.16" | null;
|
||||
edition?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { create } from "zustand";
|
||||
import { loadJson, saveJson } from "@/lib/storage";
|
||||
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||
@@ -186,6 +187,23 @@ if (typeof window !== "undefined") {
|
||||
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener("change", () => applyTheme());
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme actually on screen, which is not the same as the setting: "system"
|
||||
* resolves to whatever the OS is doing right now, and follows it as it changes.
|
||||
*/
|
||||
export function useEffectiveTheme(): "light" | "dark" {
|
||||
const theme = useSettings((s) => s.settings.theme);
|
||||
const [systemDark, setSystemDark] = useState(() => window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia?.("(prefers-color-scheme: dark)");
|
||||
if (!mq) return;
|
||||
const onChange = () => setSystemDark(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return theme === "dark" || (theme === "system" && systemDark) ? "dark" : "light";
|
||||
}
|
||||
|
||||
export const settings = () => useSettings.getState().settings;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { act, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Dialog } from "../dialog";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
/**
|
||||
* Dialogs are almost always given an inline arrow for onClose, so its identity
|
||||
* changes on every render of the parent. While that was in the effect's
|
||||
* dependencies, any dialog holding state tore the effect down and set it up
|
||||
* again on each keystroke — and its autofocus dragged the caret back to the
|
||||
* first field. Typing a digit into the second field jumped you to the first.
|
||||
*/
|
||||
|
||||
/** A dialog with two fields, whose parent re-renders as either is typed in. */
|
||||
function TwoFieldDialog() {
|
||||
const [first, setFirst] = useState("");
|
||||
const [second, setSecond] = useState("");
|
||||
return (
|
||||
<Dialog open onClose={() => undefined} title="Two fields">
|
||||
<input id="first" value={first} onChange={(e) => setFirst(e.target.value)} />
|
||||
<input id="second" value={second} onChange={(e) => setSecond(e.target.value)} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
describe("Dialog focus handling", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const type = (el: HTMLInputElement, value: string) => {
|
||||
act(() => {
|
||||
el.focus();
|
||||
// What React's onChange sees when a character is typed.
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
|
||||
setter.call(el, value);
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
it("autofocuses the first field when it opens", async () => {
|
||||
act(() => root.render(<TwoFieldDialog />));
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
expect(document.activeElement?.id).toBe("first");
|
||||
});
|
||||
|
||||
it("leaves the caret alone while a later field is typed in", async () => {
|
||||
act(() => root.render(<TwoFieldDialog />));
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
|
||||
const second = document.getElementById("second") as HTMLInputElement;
|
||||
type(second, "1");
|
||||
// The old effect re-ran here and pulled focus back to the first field.
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
expect(document.activeElement?.id).toBe("second");
|
||||
|
||||
type(second, "12");
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
});
|
||||
expect(document.activeElement?.id).toBe("second");
|
||||
expect(second.value).toBe("12");
|
||||
});
|
||||
});
|
||||
+12
-2
@@ -16,13 +16,23 @@ interface DialogProps {
|
||||
|
||||
export function Dialog({ open, onClose, title, children, footer, size = "md", closeOnBackdrop = true, className }: DialogProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
/*
|
||||
* Callers almost always pass an inline arrow for onClose, so its identity
|
||||
* changes on every render of the parent. Depending on it here would tear the
|
||||
* effect down and set it up again on every keystroke in a dialog that holds
|
||||
* state, and the autofocus below would drag the caret back to the first
|
||||
* field mid-typing. Keep the latest handler in a ref instead, so the effect
|
||||
* depends only on `open`.
|
||||
*/
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
onCloseRef.current();
|
||||
}
|
||||
if (e.key === "Tab" && ref.current) {
|
||||
const focusables = ref.current.querySelectorAll<HTMLElement>('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"]),[contenteditable="true"]');
|
||||
@@ -48,7 +58,7 @@ export function Dialog({ open, onClose, title, children, footer, size = "md", cl
|
||||
document.removeEventListener("keydown", onKey, true);
|
||||
prev?.focus?.();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
}, [open]);
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useMemo } from "react";
|
||||
import qrcode from "qrcode-generator";
|
||||
|
||||
/**
|
||||
* A QR code as inline SVG.
|
||||
*
|
||||
* Drawn as one path of square modules so it scales cleanly and inherits the
|
||||
* current colour, which keeps it legible in both themes without a second
|
||||
* rendering path. Error correction is set to M: enough tolerance for a phone
|
||||
* camera pointed at a screen, without inflating the module count.
|
||||
*/
|
||||
export function QrCode({ value, size = 200, title }: { value: string; size?: number; title?: string }) {
|
||||
const { path, count } = useMemo(() => {
|
||||
const qr = qrcode(0, "M");
|
||||
qr.addData(value);
|
||||
qr.make();
|
||||
const count = qr.getModuleCount();
|
||||
let path = "";
|
||||
for (let row = 0; row < count; row++) {
|
||||
for (let col = 0; col < count; col++) {
|
||||
if (qr.isDark(row, col)) path += `M${col} ${row}h1v1h-1z`;
|
||||
}
|
||||
}
|
||||
return { path, count };
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`-2 -2 ${count + 4} ${count + 4}`}
|
||||
role="img"
|
||||
aria-label={title ?? "QR code"}
|
||||
shapeRendering="crispEdges"
|
||||
style={{ background: "#fff", borderRadius: 8, display: "block" }}
|
||||
>
|
||||
<title>{title ?? "QR code"}</title>
|
||||
<path d={path} fill="#000" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, PenSquare, Settings, Users, LogOut, Plus, RefreshCw } from "lucide-react";
|
||||
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
@@ -70,6 +70,7 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
<button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}>
|
||||
<HelpCircle size={21} />
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<Link href="/settings" className={`icon-btn ${section === "settings" ? "active" : ""}`} aria-label="Settings" title="Settings">
|
||||
<Settings size={21} />
|
||||
</Link>
|
||||
@@ -196,3 +197,27 @@ function QuotaBar() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip between light and dark from the top bar.
|
||||
*
|
||||
* The stored setting has a third value, "system", so the button acts on what
|
||||
* is actually on screen rather than on the setting: whichever theme you can
|
||||
* see, one click gives you the other one. Choosing "match system" again lives
|
||||
* in Settings › Appearance, where the three-way choice belongs.
|
||||
*/
|
||||
function ThemeToggle() {
|
||||
const effective = useEffectiveTheme();
|
||||
const update = useSettings((s) => s.update);
|
||||
const next = effective === "dark" ? "light" : "dark";
|
||||
return (
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label={`Switch to ${next} mode`}
|
||||
title={`Switch to ${next} mode`}
|
||||
onClick={() => update({ theme: next })}
|
||||
>
|
||||
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,11 +19,13 @@ export function AboutSettings() {
|
||||
<table className="sessions-table">
|
||||
<tbody>
|
||||
<tr><td>Signed in as</td><td>{session?.username}</td></tr>
|
||||
<tr><td>Stalwart</td><td>{describeServer(session?.ihasmail?.server)}</td></tr>
|
||||
<tr><td>Accounts</td><td>{Object.values(session?.accounts ?? {}).map((a) => a.name).join(", ")}</td></tr>
|
||||
<tr><td>Max upload</td><td>{Math.round(client.maxSizeUpload / 1048576)} MB</td></tr>
|
||||
<tr><td>Image privacy proxy</td><td>{session?.ihasmail?.imageProxy ? "enabled" : "disabled"}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="hint" style={{ marginTop: 6 }}>Stalwart does not publish its version number to mail clients, so ihasmail reports the API generation it detected instead.</p>
|
||||
<h2>Server capabilities</h2>
|
||||
<div className="row wrap gap-4">
|
||||
{caps.map((c) => <span key={c} className="chip mono" style={{ fontSize: ".78em" }}>{c.replace("urn:ietf:params:jmap:", "")}</span>)}
|
||||
@@ -31,3 +33,15 @@ export function AboutSettings() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stalwart deliberately withholds its version from clients (it reports a fixed
|
||||
* "1.0.0" wherever it publishes one at all), so the most honest thing we can
|
||||
* show is which generation of its API answered us, plus the edition where the
|
||||
* server reports it.
|
||||
*/
|
||||
function describeServer(server: { generation?: "0.16+" | "pre-0.16" | null; edition?: string | null } | undefined): string {
|
||||
if (!server?.generation) return "not detected";
|
||||
const generation = server.generation === "0.16+" ? "0.16 or newer" : "older than 0.16";
|
||||
return server.edition ? `${generation} (${server.edition})` : generation;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Copy, KeyRound, ShieldCheck, Smartphone } from "lucide-react";
|
||||
import { apiFetch, ApiError } from "@/jmap/client";
|
||||
import { useSession } from "@/store/session";
|
||||
import { formatFullDate } from "@/lib/format";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { confirmDialog } from "@/ui/dialog";
|
||||
import { confirmDialog, Dialog } from "@/ui/dialog";
|
||||
import { QrCode } from "@/ui/qrcode";
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
@@ -16,19 +18,72 @@ interface SessionRow {
|
||||
ip: string;
|
||||
}
|
||||
|
||||
interface AppPasswordRow {
|
||||
id: string;
|
||||
description: string;
|
||||
createdAt: string | null;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
interface SecurityState {
|
||||
backend: "registry" | "legacy";
|
||||
otpEnabled: boolean;
|
||||
appPasswords: AppPasswordRow[];
|
||||
appPasswordsKeyedByName: boolean;
|
||||
}
|
||||
|
||||
export function SecuritySettings() {
|
||||
const [rows, setRows] = useState<SessionRow[] | null>(null);
|
||||
const [current, setCurrent] = useState<string>("");
|
||||
const [state, setState] = useState<SecurityState | null>(null);
|
||||
/** Set when the server has no self-service API at all (pre-0.15 or a proxy). */
|
||||
const [unsupported, setUnsupported] = useState<string | null>(null);
|
||||
const session = useSession((s) => s.session);
|
||||
const logout = useSession((s) => s.logout);
|
||||
|
||||
const load = () => apiFetch<{ current: string; sessions: SessionRow[] }>("/api/auth/sessions").then((r) => { setRows(r.sessions); setCurrent(r.current); }).catch(() => setRows([]));
|
||||
|
||||
const loadSecurity = useCallback(async () => {
|
||||
try {
|
||||
setState(await apiFetch<SecurityState>("/api/account/security"));
|
||||
setUnsupported(null);
|
||||
} catch (err) {
|
||||
setState(null);
|
||||
setUnsupported(err instanceof ApiError && err.status === 501 ? err.message : (err as Error).message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
void loadSecurity();
|
||||
}, [loadSecurity]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Security & sessions</h1>
|
||||
<p className="lead">You're signed in as <b>{session?.username}</b>. Your password is never stored in the browser; the server keeps it encrypted per-session for talking to Stalwart.</p>
|
||||
|
||||
<h2>Password</h2>
|
||||
{unsupported ? (
|
||||
<p className="hint">{unsupported}</p>
|
||||
) : (
|
||||
<PasswordForm otpEnabled={state?.otpEnabled ?? false} onChanged={() => { void load(); }} />
|
||||
)}
|
||||
|
||||
<h2>Two-factor authentication</h2>
|
||||
{unsupported ? (
|
||||
<p className="hint">Two-factor authentication is managed by your mail administrator.</p>
|
||||
) : (
|
||||
<TwoFactor state={state} reload={async () => { await loadSecurity(); await load(); }} />
|
||||
)}
|
||||
|
||||
<h2>App passwords</h2>
|
||||
{unsupported ? (
|
||||
<p className="hint">App passwords are managed by your mail administrator.</p>
|
||||
) : (
|
||||
<AppPasswords state={state} reload={loadSecurity} />
|
||||
)}
|
||||
|
||||
<h2>Active webmail sessions</h2>
|
||||
{rows === null ? <p className="hint">Loading…</p> : (
|
||||
<table className="sessions-table">
|
||||
@@ -50,8 +105,290 @@ export function SecuritySettings() {
|
||||
<button className="btn" onClick={async () => { if (await confirmDialog({ title: "Sign out other sessions?", confirmLabel: "Sign out others" })) { const r = await apiFetch<{ revoked: number }>("/api/auth/sessions/revoke-others", { method: "POST" }); toast.success(`Signed out ${r.revoked} other session(s)`); void load(); } }}>Sign out all other sessions</button>
|
||||
<button className="btn btn-ghost" onClick={() => void logout()}>Sign out here</button>
|
||||
</div>
|
||||
<h2>Password & two-factor</h2>
|
||||
<p className="hint">Password changes, app passwords and 2FA are managed by your mail administrator or via Stalwart's self-service portal.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function PasswordForm({ otpEnabled, onChanged }: { otpEnabled: boolean; onChanged: () => void }) {
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (next !== confirm) {
|
||||
toast.error("The new passwords don't match");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ revokedSessions: number }>("/api/account/password", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ current, next, otpCode: code || undefined }),
|
||||
});
|
||||
setCurrent(""); setNext(""); setConfirm(""); setCode("");
|
||||
toast.success(res.revokedSessions ? `Password changed. ${res.revokedSessions} other session(s) signed out.` : "Password changed");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<p className="hint" style={{ marginBottom: 12 }}>Changing your password signs out your other webmail sessions. Any app passwords keep working.</p>
|
||||
<div className="field" style={{ maxWidth: 380 }}>
|
||||
<label htmlFor="pw-current">Current password</label>
|
||||
<input id="pw-current" type="password" autoComplete="current-password" value={current} onChange={(e) => setCurrent(e.target.value)} required />
|
||||
</div>
|
||||
{otpEnabled && (
|
||||
<div className="field" style={{ maxWidth: 380 }}>
|
||||
<label htmlFor="pw-code">Code from your authenticator</label>
|
||||
<input id="pw-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" required />
|
||||
</div>
|
||||
)}
|
||||
<div className="field-row" style={{ maxWidth: 780 }}>
|
||||
<div className="field">
|
||||
<label htmlFor="pw-new">New password</label>
|
||||
<input id="pw-new" type="password" autoComplete="new-password" value={next} onChange={(e) => setNext(e.target.value)} required />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="pw-confirm">Confirm new password</label>
|
||||
<input id="pw-confirm" type="password" autoComplete="new-password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn btn-primary" disabled={busy || !current || !next}>{busy ? "Changing…" : "Change password"}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function TwoFactor({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
|
||||
const [setup, setSetup] = useState<{ secret: string; url: string } | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [disabling, setDisabling] = useState(false);
|
||||
|
||||
if (!state) return <p className="hint">Loading…</p>;
|
||||
|
||||
const begin = async () => {
|
||||
try {
|
||||
setSetup(await apiFetch<{ secret: string; url: string }>("/api/account/2fa/begin", { method: "POST", body: "{}" }));
|
||||
setCode(""); setPassword("");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const enable = async () => {
|
||||
if (!setup) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ sessionKept: boolean }>("/api/account/2fa/enable", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ url: setup.url, code, current: password }),
|
||||
});
|
||||
setSetup(null);
|
||||
await reload();
|
||||
if (res.sessionKept) {
|
||||
toast.success("Two-factor authentication is on. This browser stays signed in.");
|
||||
} else {
|
||||
toast.success("Two-factor authentication is on. You'll need to sign in again with a code.");
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await apiFetch("/api/account/2fa/disable", { method: "POST", body: JSON.stringify({ current: password, code }) });
|
||||
setDisabling(false);
|
||||
await reload();
|
||||
toast.success("Two-factor authentication is off");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="hint" style={{ marginBottom: 12 }}>
|
||||
{state.otpEnabled
|
||||
? "Signing in requires a code from your authenticator app as well as your password."
|
||||
: "Add a one-time code from an authenticator app to your sign-in, so a stolen password isn't enough on its own."}
|
||||
</p>
|
||||
<div className="row" style={{ alignItems: "center", gap: 10 }}>
|
||||
<ShieldCheck size={18} className={state.otpEnabled ? "" : "muted"} />
|
||||
<b>{state.otpEnabled ? "Enabled" : "Not enabled"}</b>
|
||||
{state.otpEnabled
|
||||
? <button className="btn btn-sm" onClick={() => { setDisabling(true); setCode(""); setPassword(""); }}>Turn off</button>
|
||||
: <button className="btn btn-sm btn-primary" onClick={() => void begin()}>Set up</button>}
|
||||
</div>
|
||||
|
||||
<Dialog open={Boolean(setup)} onClose={() => setSetup(null)} title="Set up two-factor authentication" size="md"
|
||||
footer={<>
|
||||
<button className="btn btn-ghost" onClick={() => setSetup(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" disabled={busy || code.length < 6 || !password} onClick={() => void enable()}>{busy ? "Verifying…" : "Turn on"}</button>
|
||||
</>}>
|
||||
{setup && (
|
||||
<div>
|
||||
<ol style={{ paddingLeft: 18, marginTop: 0 }}>
|
||||
<li>Scan this with your authenticator app.</li>
|
||||
<li>Enter the six-digit code it shows, and your password.</li>
|
||||
</ol>
|
||||
<div className="row" style={{ gap: 16, alignItems: "flex-start", flexWrap: "wrap" }}>
|
||||
<QrCode value={setup.url} size={188} title="Two-factor setup code" />
|
||||
<div style={{ minWidth: 220, flex: 1 }}>
|
||||
<div className="field">
|
||||
<label>Can't scan? Enter this key by hand</label>
|
||||
<CopyableSecret value={setup.secret} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="tfa-code">Code from the app</label>
|
||||
<input id="tfa-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="tfa-pw">Your password</label>
|
||||
<input id="tfa-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="hint">Codes are checked before anything is saved, so a mistyped key can't lock you out.</p>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={disabling} onClose={() => setDisabling(false)} title="Turn off two-factor authentication" size="sm"
|
||||
footer={<>
|
||||
<button className="btn btn-ghost" onClick={() => setDisabling(false)}>Cancel</button>
|
||||
<button className="btn btn-danger" disabled={busy || !password || code.length < 6} onClick={() => void disable()}>{busy ? "Working…" : "Turn off"}</button>
|
||||
</>}>
|
||||
<p>Your password alone will be enough to sign in again.</p>
|
||||
<div className="field">
|
||||
<label htmlFor="tfa-off-pw">Your password</label>
|
||||
<input id="tfa-off-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="tfa-off-code">Current code</label>
|
||||
<input id="tfa-off-code" inputMode="numeric" autoComplete="one-time-code" value={code} onChange={(e) => setCode(e.target.value)} placeholder="123456" />
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function AppPasswords({ state, reload }: { state: SecurityState | null; reload: () => Promise<void> }) {
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [issued, setIssued] = useState<{ description: string; secret: string } | null>(null);
|
||||
|
||||
if (!state) return <p className="hint">Loading…</p>;
|
||||
|
||||
const create = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ id: string; secret: string }>("/api/account/app-passwords", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ description: name }),
|
||||
});
|
||||
setIssued({ description: name, secret: res.secret });
|
||||
setName("");
|
||||
await reload();
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const revoke = async (row: AppPasswordRow) => {
|
||||
const ok = await confirmDialog({
|
||||
title: `Revoke "${row.description}"?`,
|
||||
message: "Anything signed in with this password stops working immediately.",
|
||||
confirmLabel: "Revoke",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiFetch("/api/account/app-passwords/revoke", { method: "POST", body: JSON.stringify({ id: row.id }) });
|
||||
await reload();
|
||||
toast.success("App password revoked");
|
||||
} catch (err) {
|
||||
toast.error((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="hint" style={{ marginBottom: 12 }}>
|
||||
A separate password for a mail app or device, which you can revoke on its own. App passwords skip two-factor codes, so they keep working in apps that can't ask for one.
|
||||
</p>
|
||||
{state.appPasswords.length > 0 && (
|
||||
<table className="sessions-table">
|
||||
<thead><tr><th>Name</th>{!state.appPasswordsKeyedByName && <th>Created</th>}<th /></tr></thead>
|
||||
<tbody>
|
||||
{state.appPasswords.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td><KeyRound size={14} style={{ verticalAlign: "-2px", marginRight: 6 }} />{row.description}</td>
|
||||
{!state.appPasswordsKeyedByName && <td>{row.createdAt ? formatFullDate(row.createdAt) : "—"}</td>}
|
||||
<td style={{ textAlign: "right" }}><button className="btn btn-sm btn-ghost" onClick={() => void revoke(row)}>Revoke</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<form onSubmit={create} className="row mt-16" style={{ gap: 8, alignItems: "flex-end", flexWrap: "wrap" }}>
|
||||
<div className="field" style={{ marginBottom: 0, minWidth: 240 }}>
|
||||
<label htmlFor="ap-name">New app password for</label>
|
||||
<input id="ap-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Thunderbird on my laptop" required />
|
||||
</div>
|
||||
<button className="btn" disabled={busy || !name.trim()}>{busy ? "Creating…" : "Create"}</button>
|
||||
</form>
|
||||
{state.appPasswordsKeyedByName && <p className="hint mt-8">This mail server identifies app passwords by name, so give each one a different name.</p>}
|
||||
|
||||
<Dialog open={Boolean(issued)} onClose={() => setIssued(null)} title="Your new app password" size="sm"
|
||||
footer={<button className="btn btn-primary" onClick={() => setIssued(null)}>Done</button>}>
|
||||
{issued && (
|
||||
<div>
|
||||
<p>Copy it into <b>{issued.description}</b> now — it isn't shown again.</p>
|
||||
<CopyableSecret value={issued.secret} />
|
||||
<p className="hint mt-8"><Smartphone size={13} style={{ verticalAlign: "-2px" }} /> Use your usual address as the username.</p>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyableSecret({ value }: { value: string }) {
|
||||
return (
|
||||
<div className="row" style={{ gap: 6, alignItems: "center" }}>
|
||||
<code className="mono" style={{ userSelect: "all", wordBreak: "break-all", flex: 1, padding: "6px 8px", background: "var(--bg-hover)", borderRadius: 6 }}>{value}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-ghost"
|
||||
title="Copy"
|
||||
onClick={() => void navigator.clipboard?.writeText(value).then(() => toast.success("Copied"), () => toast.error("Could not copy"))}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user