sign in with an email or a username

Only an address worked, though usernames are already unique and already
validated as /^[a-zA-Z0-9._-]+$/ — which is what makes this safe: no `@`, so
the two namespaces cannot overlap and there is nothing to guess. An `@` decides
which lookup runs, and it decides it exactly.

The field is still called `email` on the wire, because every client sends that
name and renaming it would break them for nothing.

Fixed a latent bug while passing: the JWT took `email` from the REQUEST rather
than the row. Identical while an address was the only way in — and a username
in the `email` claim the moment it was not, leaving the DAV door, the managed
home path and everything else keyed on email looking up a user who does not
exist.

The login field is `type="text"`, not `type="email"`, or the browser refuses to
submit a username and validates someone out of an account they have.
`autoComplete="username"`, which is what password managers store beside a
password for a field that takes either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 11:10:39 +00:00
co-authored by Claude Opus 5
parent 1e03c600b8
commit b27410a6fc
3 changed files with 56 additions and 7 deletions
@@ -46,13 +46,17 @@ export function Login() {
<form ref={formRef} onSubmit={handleSubmit} className="grid gap-4">
<Label className="grid gap-2">
<span className="text-duck-dark/70">Email</span>
<span className="text-duck-dark/70">Email or username</span>
<Input
className="h-11 bg-background/60 border-duck-dark/20 text-duck-dark placeholder:text-duck-dark/40"
type="email"
// `text`, not `email`: the browser refuses to submit a username under type="email", so the
// field would have validated the user out of an account they have.
type="text"
name="email"
placeholder="you@example.com"
autoComplete="email"
// `username` rather than `email`, which is what password managers expect for a field that
// takes either — and it is the value they already store beside the password.
autoComplete="username"
/>
</Label>
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test';
// Signing in with an email or a username, and why the two can never be confused.
//
// Usernames are validated as `/^[a-zA-Z0-9._-]+$/` — no `@`. That single fact is what lets one field
// accept both without guessing, without querying twice, and without a username ever being able to
// impersonate an address.
const USERNAME_RE = /^[a-zA-Z0-9._-]+$/;
const looksLikeEmail = (identifier: string) => identifier.includes('@');
describe('telling an email from a username', () => {
test('a username can never contain the character that identifies an email', () => {
expect(USERNAME_RE.test('pastilhas')).toBe(true);
expect(USERNAME_RE.test('a.b_c-d1')).toBe(true);
// The load-bearing one: if this ever passes, the two namespaces overlap and signin has to guess.
expect(USERNAME_RE.test('someone@example.com')).toBe(false);
});
test('an address routes to the email lookup', () => {
expect(looksLikeEmail('someone@example.com')).toBe(true);
});
test('a bare name routes to the username lookup', () => {
expect(looksLikeEmail('pastilhas')).toBe(false);
});
test('a name that merely contains a dot is still a username', () => {
// `jrg.conceicao` is a real username on this project's own server, and a naive "has a dot" test
// would have sent it to the email lookup and failed the login.
expect(looksLikeEmail('jrg.conceicao')).toBe(false);
expect(USERNAME_RE.test('jrg.conceicao')).toBe(true);
});
});
+15 -4
View File
@@ -1,5 +1,5 @@
import type { Handler } from 'hono';
import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb';
import { getUserByEmail, getUserByUsername, getPasskeysByUserIdAndOrigin } from 'officerdb';
import { sign } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
@@ -8,7 +8,9 @@ import { isLockdown, noteBlocked } from './panic';
const TEST_USERS: number[] = [];
export const signinHandler: Handler = async function (ctx) {
const { email, password } = ctx.get('body');
// Still named `email` on the wire, because every client sends that field and renaming it would break
// them for no gain. What it holds is an IDENTIFIER: an email address or a username.
const { email: identifier, password } = ctx.get('body');
// A caller that sends neither Origin nor Referer — a server-to-server client, curl — leaves this
// undefined. It was once unreachable: origin validation rejected those requests before this handler
// ran, so the value was always a string by the time anything touched it. Then the flag that disabled
@@ -24,7 +26,12 @@ export const signinHandler: Handler = async function (ctx) {
throw errors.UNAUTHORIZED();
}
const dbUser = await getUserByEmail(email);
// An `@` decides it, and decides it exactly: usernames are `/^[a-zA-Z0-9._-]+$/`, so the two
// namespaces cannot overlap and there is no ambiguity to resolve by guessing or by querying twice.
const dbUser =
typeof identifier === 'string' && identifier.includes('@')
? await getUserByEmail(identifier)
: await getUserByUsername(String(identifier ?? ''));
if (!dbUser) throw errors.UNAUTHORIZED();
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
@@ -40,7 +47,11 @@ export const signinHandler: Handler = async function (ctx) {
// and what they can reach once inside is decided by their role's permissions at every request. A rule
// that let a member hold a Gitea grant they could never sign in to use was not defence, just a wall.
const { id, name, username } = dbUser;
// `email` from the ROW, not from the request. It happened to be the same string while email was the
// only way in; signing in by username would otherwise put a username in the `email` claim and leave
// every downstream reader — the DAV door, the managed home path, anything keyed on email — looking up
// a user that does not exist.
const { id, name, username, email } = dbUser;
const tokenUser = { id, email, name, username, passkeys: passkeys.length };