Merge pull request #77 from LINUXexpert-org/totp-honest-failure
Stop pretending the two-factor field can work
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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: "[email protected]", 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: "[email protected]", password: "wrong" });
|
||||
cookie = saved;
|
||||
assert.equal(res.status, 401);
|
||||
assert.equal(res.body.error, "invalid_credentials");
|
||||
});
|
||||
|
||||
@@ -206,6 +206,32 @@ export function createApp(): Hono<Env> {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
+13
-1
@@ -79,7 +79,19 @@ export function LoginPage() {
|
||||
<div className="field">
|
||||
<label htmlFor="t">Two-factor code</label>
|
||||
<input id="t" className="input" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" value={totp} onChange={(e) => setTotp(e.target.value)} autoFocus />
|
||||
<span className="hint">Enter the code from your authenticator app if your account uses 2FA.</span>
|
||||
{/*
|
||||
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.
|
||||
*/}
|
||||
<span className="hint">
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="btn btn-ghost btn-sm" style={{ marginBottom: 12, color: "var(--fg-muted)" }} onClick={() => setShowTotp(true)}>
|
||||
|
||||
Reference in New Issue
Block a user