76 lines
3.9 KiB
SQL
76 lines
3.9 KiB
SQL
-- §6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
|
|
--
|
|
-- After a successful OTC or passcode sign-in, the user can check
|
|
-- "trust this device for 30 days." The framework then issues a
|
|
-- server-issued opaque device-trust token, stores its hash on this
|
|
-- table, and sets a long-lived HttpOnly + Secure + SameSite=Lax
|
|
-- cookie carrying the raw token. On a subsequent visit, the cookie is
|
|
-- presented at `/auth/device-trust/start`; if the server can match the
|
|
-- hash to a non-expired non-revoked row, the user is signed in without
|
|
-- another OTC / passcode round-trip.
|
|
--
|
|
-- v0.11.0 introduces no new env vars. The 30-day window is hard-coded
|
|
-- in `backend/app/device_trust.py`; raising or lowering it (or making
|
|
-- it user-selectable) is a §19.2 candidate, alongside the cross-device
|
|
-- session-revocation surface this table will eventually share with the
|
|
-- v0.10.0 passcode-lockout shape (see SPEC §19.2 / SESSIONS-AND-DEVICES).
|
|
--
|
|
-- Storage shape:
|
|
--
|
|
-- * `id` — surrogate key. Lets the revoke-device UI address a single
|
|
-- row by id without leaking the token shape.
|
|
-- * `user_id` — FK into users(id) with cascade on delete. A deleted
|
|
-- user automatically loses every trusted device.
|
|
-- * `device_token_hash` — bcrypt hash of the random opaque token
|
|
-- issued at trust-time. The raw token only ever lives in the
|
|
-- outbound `Set-Cookie` header and the inbound `Cookie` header;
|
|
-- server-side storage is the hash, so a DB compromise does not
|
|
-- hand attackers a stash of valid device tokens.
|
|
-- * `created_at` — when the row was issued.
|
|
-- * `expires_at` — `created_at + 30 days`. A row past this timestamp
|
|
-- is dead; the lookup path refuses it without further checks.
|
|
-- * `user_agent` — the User-Agent header captured at issuance.
|
|
-- Stored verbatim (truncated to 1024 chars at the application
|
|
-- layer) so the revoke-device UI can show a rough device label.
|
|
-- Not used for any auth decision — purely a hint to the user
|
|
-- reviewing their device list.
|
|
-- * `last_seen_at` — refreshed every time the row authenticates a
|
|
-- request. Lets the revoke-device UI surface "last used 3 days
|
|
-- ago" so the user can tell which row corresponds to which
|
|
-- device.
|
|
-- * `revoked_at` — NULL means active; non-NULL stamps when the user
|
|
-- (or admin) revoked the row. Lookups treat any non-NULL value
|
|
-- as "this row is dead" without consulting the expiry; the
|
|
-- revoke gesture is intentionally one-way (a revoked device must
|
|
-- re-trust to come back online).
|
|
--
|
|
-- Indexing: a unique index on `device_token_hash` so collisions are
|
|
-- detectable at insert time (the token space is 256 bits of CSPRNG
|
|
-- entropy, so a collision is structurally impossible, but the
|
|
-- declaration documents the invariant). A separate index on
|
|
-- `(user_id, revoked_at)` so the revoke-device UI's list query is
|
|
-- a covering walk.
|
|
--
|
|
-- The bcrypt dependency reused here was added in v0.7.0 for OTC and
|
|
-- extended in v0.10.0 for passcodes; v0.11.0 needs no new dep.
|
|
--
|
|
-- The cookie shape: `rfc_device_trust` carries the raw token,
|
|
-- HttpOnly, Secure, SameSite=Lax, Max-Age=2592000 (30 days). It is
|
|
-- "essential" per the v0.13.0 cookie-consent banner (it is part of
|
|
-- authentication, not analytics), so it is set regardless of the
|
|
-- user's analytics / other-cookies choices.
|
|
|
|
CREATE TABLE device_trust (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
device_token_hash TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
expires_at TEXT NOT NULL,
|
|
user_agent TEXT NOT NULL DEFAULT '',
|
|
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
revoked_at TEXT
|
|
);
|
|
|
|
CREATE UNIQUE INDEX idx_device_trust_token_hash ON device_trust (device_token_hash);
|
|
CREATE INDEX idx_device_trust_user ON device_trust (user_id, revoked_at);
|