Stop pretending the two-factor field can work
Signing in with a two-factor code failed with a bare 401 and "Invalid credentials", which sent the user off to check a password that was perfectly good (#75). It cannot work, and the app already knew. Stalwart accepts a TOTP code only through an OAuth flow -- its own web interface is an OAuth client, which is why signing in *there* succeeds -- and it offers only the authorization-code and device flows. There is no password grant, so a client holding a username and password has nowhere to exchange them plus a code for a token. The concatenated `password$code` form this README claimed was accepted is not a route the server has, and appears never to have been. What was verified live on 0.16.19 was enabling and disabling 2FA, never signing in with a code. The contradiction was already in the codebase: turning 2FA *on* mints an app password and reseals the session onto it, precisely because a plain password stops working from that moment. The sign-in page was the one place still assuming otherwise. Three changes, no new capability: - A 401 on a sign-in that carried a code now says what is happening and where to go instead, and says the password is probably fine. A sign-in without a code is untouched, so an ordinary typo still reads as an ordinary typo. - The field stays, and is honest about itself. Removing it would leave someone with 2FA finding nothing at all, which is worse than finding a field that explains the situation and points at app passwords. - The README's claim is corrected rather than quietly dropped, and real 2FA support is written into the roadmap as what it is: an OAuth implementation, handing sign-in to Stalwart and holding a refresh token instead of a sealed password.
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)
|
# → 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
|
## 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)
|
- 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)
|
- 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -174,3 +174,33 @@ test("credential endpoints reject unauthenticated callers", async () => {
|
|||||||
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
|
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
|
||||||
cookie = saved;
|
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);
|
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||||
} catch (err) {
|
} 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);
|
return upstreamFailure(c, err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-1
@@ -79,7 +79,19 @@ export function LoginPage() {
|
|||||||
<div className="field">
|
<div className="field">
|
||||||
<label htmlFor="t">Two-factor code</label>
|
<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 />
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="btn btn-ghost btn-sm" style={{ marginBottom: 12, color: "var(--fg-muted)" }} onClick={() => setShowTotp(true)}>
|
<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