Every key needed an operator with a shell. This adds the login system that made that necessary, which is the thing I argued against building until it was asked for — so it is built as if it will be attacked, because a public login form is. Signing up produces an account that can do nothing. Approval is what makes it work and stays on the CLI: having a shell on the control plane is what makes someone an administrator, and that is a smaller thing to get right than roles and an admin UI that mints credentials. The details that matter: Passwords are argon2id, where a slow hash is correct — the input is chosen by a human and has a dictionary, which is the opposite of an API key. Both kinds of authentication now live in this codebase and they are in separate packages so neither set of assumptions leaks into the other. Length is the only rule: composition rules push people towards Password1! and cost more entropy than they buy. Signup never says whether an address already has an account, and sign-in answers the same way for a wrong password and a missing account — verifying against a dummy hash when there is no user, so a stopwatch cannot enumerate accounts. Account state is checked after the password, not before, so guessing an address reveals nothing about it. Guessing is bounded per address rather than per IP: an attacker has many addresses to come from and one to guess against, and limiting by IP locks out an office behind one NAT. Sessions are server-side, so signing out and suspending take effect immediately rather than whenever a token expires; suspension is checked in the same query that resolves the session, because a second round trip is a check somebody eventually forgets. Every state-changing form carries a CSRF token verified against a hash in the session — a cookie authenticates the browser, not the page that asked. Key ownership is enforced in the UPDATE rather than by reading first, so a guessed key id from another account changes nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
48 lines
2.1 KiB
SQL
48 lines
2.1 KiB
SQL
-- Accounts, so a person can sign up, be approved, and then issue their own keys
|
|
-- without an operator running a command for each one.
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY,
|
|
-- Lowercased at the boundary. Two accounts differing only in case would be
|
|
-- two accounts to the database and one to every human who looks at them.
|
|
email TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
-- argon2id, encoded with its own parameters so the cost can be raised later
|
|
-- without invalidating existing hashes. Slow hashing is right here and wrong
|
|
-- for API keys: this input is chosen by a human and has a dictionary.
|
|
password_hash TEXT NOT NULL,
|
|
-- 'pending' until an operator approves, then 'active'. 'suspended' keeps the
|
|
-- account and stops it working.
|
|
state TEXT NOT NULL,
|
|
-- Assigned at approval: which project this person's keys belong to, and
|
|
-- therefore which limits they inherit.
|
|
project_id TEXT REFERENCES projects(id),
|
|
created_at INTEGER NOT NULL,
|
|
approved_at INTEGER,
|
|
approved_by TEXT,
|
|
last_login_at INTEGER
|
|
);
|
|
CREATE INDEX users_by_state ON users(state);
|
|
|
|
-- Server-side sessions rather than signed cookies, so signing out and
|
|
-- suspending an account take effect immediately instead of whenever a token
|
|
-- happens to expire.
|
|
CREATE TABLE sessions (
|
|
token_hash BLOB PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
-- Per session, submitted with every form. A cookie alone authenticates the
|
|
-- browser, not the page that asked, which is what makes a cross-site POST
|
|
-- work.
|
|
csrf_hash BLOB NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
last_seen INTEGER NOT NULL
|
|
);
|
|
CREATE INDEX sessions_by_user ON sessions(user_id);
|
|
CREATE INDEX sessions_by_expiry ON sessions(expires_at);
|
|
|
|
-- A key issued through the profile page belongs to the person who made it, so
|
|
-- they can see and revoke their own and nobody else's. NULL means a key an
|
|
-- operator issued directly.
|
|
ALTER TABLE api_keys ADD COLUMN user_id TEXT REFERENCES users(id);
|
|
CREATE INDEX api_keys_by_user ON api_keys(user_id);
|