diff --git a/README.md b/README.md index c49bd65..116b7ea 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ docker compose up --build -d # → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf) ``` -Users sign in with their Stalwart mailbox credentials (TOTP codes are supported via the "two-factor code" field, which Stalwart accepts as `password$code`). +Users sign in with their Stalwart mailbox credentials. **An account with two-factor authentication needs an app password**, created in Stalwart's own settings: Stalwart accepts a TOTP code only through an OAuth flow — its web interface is an OAuth client, which is why signing in *there* works — and it offers no password grant, so no client holding a username and password can exchange them plus a code for a token. The concatenated `password$code` form this README used to claim was accepted is not a route the server has. App passwords bypass TOTP and are Stalwart's own answer for clients like this one; ihasmail already relies on that, since turning 2FA on moves the current session onto an app password for exactly this reason. ## Development @@ -306,6 +306,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for - Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away) - Translations (strings are English-only for now) +- **Two-factor sign-in.** Today an account with 2FA must use an app password (see Quick start). Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75) ## License diff --git a/server/src/account.test.ts b/server/src/account.test.ts index c5dbd84..7f6df7a 100644 --- a/server/src/account.test.ts +++ b/server/src/account.test.ts @@ -174,3 +174,33 @@ test("credential endpoints reject unauthenticated callers", async () => { assert.equal((await post("/api/account/2fa/begin", {})).status, 401); cookie = saved; }); + +/** + * A sign-in carrying a two-factor code that the server rejects is almost never + * "wrong password". Stalwart accepts TOTP only through an OAuth flow and offers + * no password grant, so the concatenated form ihasmail sends cannot work — and + * saying "invalid credentials" sends the user to check a password that is fine. + * + * Reported as #75: 2FA sign-in failed with a bare 401 while an app password + * worked, which is Stalwart's documented route and gave no hint of itself. + */ +test("a rejected sign-in carrying a TOTP code explains itself", async () => { + const saved = cookie; + cookie = ""; + const res = await post("/api/auth/login", { username: "demo@example.com", password: "demo-password", totp: "123456" }); + cookie = saved; + assert.equal(res.status, 401); + assert.equal(res.body.error, "totp_unsupported", "not the generic invalid_credentials"); + assert.match(res.body.message, /app password/i, "points at the route that does work"); + assert.match(res.body.message, /probably fine/i, "does not blame the password"); +}); + +test("a rejected sign-in without a code is still a plain credential failure", async () => { + // The explanation must not leak onto ordinary typos. + const saved = cookie; + cookie = ""; + const res = await post("/api/auth/login", { username: "demo@example.com", password: "wrong" }); + cookie = saved; + assert.equal(res.status, 401); + assert.equal(res.body.error, "invalid_credentials"); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 23ca031..f0dab0f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -206,6 +206,32 @@ export function createApp(): Hono { const info = await getAccountInfo(session.id, session.authorization, upstream); return c.json(localizeSession(upstream, sessionExtras(session, info))); } catch (err) { + // A rejected sign-in that carried a two-factor code is worth explaining + // rather than calling "invalid credentials", because the credentials are + // very likely fine. + // + // Stalwart accepts a TOTP code only through an OAuth flow -- its own web + // interface is an OAuth client, which is why signing in there works. It + // offers no password grant, so a client holding a username and password + // cannot exchange them plus a code for a token, and the concatenated + // `password$code` form ihasmail sent is not a route the server has. Its + // documented answer for clients like this one is an app password, which + // bypasses TOTP entirely. + // + // ihasmail already relies on that elsewhere: turning 2FA *on* mints an + // app password and moves the session onto it, precisely because a plain + // password stops working from that moment. The sign-in page was the one + // place still pretending otherwise. + if (totp && err instanceof UpstreamError && err.status === 401) { + return c.json( + { + error: "totp_unsupported", + message: + "This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.", + }, + 401, + ); + } return upstreamFailure(c, err); } }); diff --git a/web/src/views/Login.tsx b/web/src/views/Login.tsx index d2ad56f..54b3dd7 100644 --- a/web/src/views/Login.tsx +++ b/web/src/views/Login.tsx @@ -79,7 +79,19 @@ export function LoginPage() {
setTotp(e.target.value)} autoFocus /> - Enter the code from your authenticator app if your account uses 2FA. + {/* + Kept, and honest about itself. Stalwart accepts a TOTP code only + through an OAuth flow, and offers no password grant, so no client + holding a username and password can pass one — the field cannot + work here today. It stays because someone with 2FA will look for + it, and finding nothing is worse than finding this; the hint sends + them somewhere that does work, and the server explains it again if + they try anyway. + */} + + Most mail servers, Stalwart included, do not accept two-factor codes from webmail — use an app password instead, created in + your mail server's own settings. This field is here for servers that do. +
) : (