352 lines
12 KiB
Python
352 lines
12 KiB
Python
"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
|
|
|
|
After a successful OTC or passcode sign-in, a contributor may check
|
|
"trust this device for 30 days." The framework then issues a
|
|
server-issued opaque token, hashes it (bcrypt) for storage in the
|
|
`device_trust` table, and sets a long-lived cookie carrying the raw
|
|
token. On a subsequent visit, the cookie is presented at
|
|
`/auth/device-trust/start`; if a non-expired, non-revoked row matches,
|
|
the session is re-established without another OTC / passcode round
|
|
trip.
|
|
|
|
The shape:
|
|
|
|
* `issue(user_id, user_agent)` — mint a fresh CSPRNG token, hash it,
|
|
insert a row, and return the raw token + row id so the endpoint
|
|
can set the cookie. The 30-day expiry is the only knob; the
|
|
`revoked_at` column stays NULL.
|
|
* `lookup(raw_token)` — walk the user's active rows (the unique
|
|
index keys on the hash, so we read a small candidate set), check
|
|
the bcrypt hash in constant time, drop any row whose `expires_at`
|
|
has passed or whose `revoked_at` is non-NULL, and return the
|
|
matched row or None. On a hit, refresh `last_seen_at`.
|
|
* `list_for_user(user_id)` — return the active rows for the
|
|
/settings/devices surface. Revoked + expired rows are filtered out
|
|
so the surface only shows live trust grants.
|
|
* `revoke(user_id, row_id)` — stamp `revoked_at` on the row. The
|
|
next lookup refuses the cookie token (the row is dead).
|
|
* `revoke_all(user_id)` — bulk-revoke every active row for the user.
|
|
The /settings/devices surface's "revoke all" button calls this.
|
|
|
|
Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax,
|
|
Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token;
|
|
server-side storage is the hash. The cookie is "essential" per the
|
|
v0.13.0 cookie-consent banner (it is part of authentication, not
|
|
analytics), so the framework sets it regardless of analytics /
|
|
other-cookies choices.
|
|
|
|
Constant-time comparison: bcrypt's `checkpw` is already constant-time
|
|
over the hash bytes. We walk the candidate set linearly with `_check`
|
|
which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks
|
|
which row was the match.
|
|
|
|
The raw token never appears in a log line or an exception message;
|
|
the helpers carry the token only as a parameter and forget it after
|
|
hashing.
|
|
|
|
The cookie sits orthogonal to the §6.1 `permission_state` gate: a
|
|
revoked or pending user with a valid device-trust cookie still
|
|
re-establishes their session (the cookie identifies the user, not
|
|
their admission state), and the existing `require_contributor` /
|
|
`require_admin` dependencies in `auth.py` continue to refuse the
|
|
unrelated write surfaces.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
|
|
import bcrypt
|
|
|
|
from . import db
|
|
from .auth import SessionUser
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tunables — hard-coded in v0.11.0 (§19.2 candidate to env-ify later).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
TRUST_DURATION_DAYS = 30
|
|
COOKIE_NAME = "rfc_device_trust"
|
|
COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60
|
|
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
|
|
# URL-safe characters; the bcrypt hash is what's stored, so the raw
|
|
# token only ever lives in the cookie.
|
|
TOKEN_BYTES = 32
|
|
# User-Agent header values seen in the wild can be unbounded; clamp
|
|
# to a reasonable ceiling so a hostile UA doesn't bloat the row.
|
|
USER_AGENT_MAX_LENGTH = 1024
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Issue
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class IssueOutcome:
|
|
"""The shape returned from `issue`.
|
|
|
|
`raw_token` is the cookie value to send to the client; it never
|
|
appears in storage. `row_id` is the surrogate key for the
|
|
/settings/devices UI to address the row by id.
|
|
"""
|
|
raw_token: str
|
|
row_id: int
|
|
|
|
|
|
def _new_token() -> str:
|
|
return secrets.token_urlsafe(TOKEN_BYTES)
|
|
|
|
|
|
def _hash(token: str) -> str:
|
|
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
|
|
|
|
|
def _check(token: str, token_hash: str) -> bool:
|
|
try:
|
|
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
def _trim_user_agent(ua: str) -> str:
|
|
ua = (ua or "").strip()
|
|
if len(ua) > USER_AGENT_MAX_LENGTH:
|
|
return ua[:USER_AGENT_MAX_LENGTH]
|
|
return ua
|
|
|
|
|
|
def issue(user_id: int, user_agent: str) -> IssueOutcome:
|
|
"""Mint a fresh device-trust token + row for `user_id`.
|
|
|
|
The row's expiry is set 30 days in the future. The hash, not the
|
|
raw token, lands in the database. The caller (the endpoint) sets
|
|
the cookie with the raw token returned here.
|
|
"""
|
|
raw = _new_token()
|
|
h = _hash(raw)
|
|
ua = _trim_user_agent(user_agent)
|
|
cur = db.conn().execute(
|
|
f"""
|
|
INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent)
|
|
VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?)
|
|
""",
|
|
(user_id, h, ua),
|
|
)
|
|
row_id = cur.lastrowid
|
|
return IssueOutcome(raw_token=raw, row_id=row_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Lookup
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class LookupOutcome:
|
|
"""The result of `lookup`.
|
|
|
|
`user` is populated only on a hit. `reason` distinguishes the
|
|
failure modes so the endpoint can decide whether to clear the
|
|
cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid').
|
|
"""
|
|
ok: bool
|
|
user: SessionUser | None
|
|
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked'
|
|
row_id: int | None = None
|
|
|
|
|
|
def lookup(raw_token: str) -> LookupOutcome:
|
|
"""Resolve a presented cookie token to a user.
|
|
|
|
A hit refreshes `last_seen_at` on the matched row. A miss returns
|
|
a reason so the endpoint can clear the stale cookie if the row
|
|
was revoked or expired (vs. simply unknown, which probably means
|
|
the cookie was forged or the row was wiped by a /settings/devices
|
|
revoke from another browser).
|
|
"""
|
|
raw = (raw_token or "").strip()
|
|
if not raw:
|
|
return LookupOutcome(ok=False, user=None, reason="invalid")
|
|
|
|
# The unique index on `device_token_hash` would let us SELECT by
|
|
# hash if bcrypt were a stable hash, but bcrypt incorporates a
|
|
# per-row salt — equal tokens produce different hashes. We walk
|
|
# the candidate set instead. In practice the set is small (a
|
|
# human has a handful of trusted devices) and bcrypt is cheap on
|
|
# the order of milliseconds; the walk is bounded by the user's
|
|
# active device count.
|
|
#
|
|
# We don't pre-filter by `revoked_at IS NULL` here so that a
|
|
# token presented for a recently-revoked row produces a
|
|
# 'revoked' outcome (the endpoint surfaces a different shape).
|
|
# Same for expired: we let the walk hit and classify after.
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT id, user_id, device_token_hash, expires_at, revoked_at
|
|
FROM device_trust
|
|
ORDER BY id DESC
|
|
""",
|
|
).fetchall()
|
|
|
|
matched = None
|
|
for row in rows:
|
|
if _check(raw, row["device_token_hash"]):
|
|
matched = row
|
|
break
|
|
|
|
if matched is None:
|
|
return LookupOutcome(ok=False, user=None, reason="unknown")
|
|
|
|
if matched["revoked_at"] is not None:
|
|
return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"])
|
|
|
|
expired = db.conn().execute(
|
|
"SELECT datetime(?) < datetime('now') AS expired",
|
|
(matched["expires_at"],),
|
|
).fetchone()["expired"]
|
|
if expired:
|
|
return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"])
|
|
|
|
# Refresh last-seen so the /settings/devices surface can show the
|
|
# user when each device was last active. This is the only write
|
|
# the lookup path does on the hot read.
|
|
db.conn().execute(
|
|
"UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?",
|
|
(matched["id"],),
|
|
)
|
|
user_row = db.conn().execute(
|
|
"""
|
|
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state
|
|
FROM users
|
|
WHERE id = ?
|
|
""",
|
|
(matched["user_id"],),
|
|
).fetchone()
|
|
if user_row is None:
|
|
# The user row was deleted but the device_trust row hadn't
|
|
# cascaded yet (shouldn't happen under the FK ON DELETE
|
|
# CASCADE — be defensive anyway). Treat as 'unknown' so the
|
|
# endpoint clears the cookie.
|
|
return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"])
|
|
|
|
# Also stamp last_seen_at on the user row so the user's overall
|
|
# activity stamp keeps pace with cookie-only sign-ins.
|
|
db.conn().execute(
|
|
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
|
(matched["user_id"],),
|
|
)
|
|
|
|
return LookupOutcome(
|
|
ok=True,
|
|
user=SessionUser(
|
|
user_id=user_row["id"],
|
|
gitea_id=user_row["gitea_id"] or 0,
|
|
gitea_login=user_row["gitea_login"] or "",
|
|
display_name=user_row["display_name"],
|
|
email=user_row["email"] or "",
|
|
avatar_url=user_row["avatar_url"] or "",
|
|
role=user_row["role"],
|
|
permission_state=user_row["permission_state"] or "granted",
|
|
),
|
|
reason="ok",
|
|
row_id=matched["id"],
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# List / revoke (for the /settings/devices surface)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class DeviceRow:
|
|
"""The shape the /settings/devices endpoint returns.
|
|
|
|
Note the absence of `device_token_hash` — the hash is structurally
|
|
private, and the surface has no use for it.
|
|
"""
|
|
id: int
|
|
created_at: str
|
|
expires_at: str
|
|
last_seen_at: str
|
|
user_agent: str
|
|
|
|
|
|
def list_for_user(user_id: int) -> list[DeviceRow]:
|
|
"""Active device-trust rows for the user, freshest first.
|
|
|
|
Filters out revoked rows and rows whose expiry has passed; the
|
|
surface only shows live trust grants. A user wondering "which
|
|
devices are signed in" gets the answer that matches what the
|
|
framework would actually accept on a presented cookie.
|
|
"""
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT id, created_at, expires_at, last_seen_at, user_agent
|
|
FROM device_trust
|
|
WHERE user_id = ?
|
|
AND revoked_at IS NULL
|
|
AND datetime(expires_at) > datetime('now')
|
|
ORDER BY last_seen_at DESC, id DESC
|
|
""",
|
|
(user_id,),
|
|
).fetchall()
|
|
return [
|
|
DeviceRow(
|
|
id=row["id"],
|
|
created_at=row["created_at"],
|
|
expires_at=row["expires_at"],
|
|
last_seen_at=row["last_seen_at"],
|
|
user_agent=row["user_agent"] or "",
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
|
|
def revoke(user_id: int, row_id: int) -> bool:
|
|
"""Revoke a single device-trust row for the given user.
|
|
|
|
Returns True iff a row was matched (still active, belongs to the
|
|
user). The user-id scope is enforced in SQL so a hostile client
|
|
cannot revoke another user's row by guessing ids.
|
|
"""
|
|
cur = db.conn().execute(
|
|
"""
|
|
UPDATE device_trust
|
|
SET revoked_at = datetime('now')
|
|
WHERE id = ?
|
|
AND user_id = ?
|
|
AND revoked_at IS NULL
|
|
""",
|
|
(row_id, user_id),
|
|
)
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def revoke_all(user_id: int) -> int:
|
|
"""Revoke every active device-trust row for the user. Returns the
|
|
count of rows touched.
|
|
|
|
The /settings/devices "revoke all" button calls this. The user's
|
|
current request stays authenticated via its session cookie; the
|
|
device-trust cookie on the current device is also revoked, but
|
|
the session middleware's `rfc_session` cookie keeps the request
|
|
flow alive until the user signs out or the session cookie
|
|
expires.
|
|
"""
|
|
cur = db.conn().execute(
|
|
"""
|
|
UPDATE device_trust
|
|
SET revoked_at = datetime('now')
|
|
WHERE user_id = ?
|
|
AND revoked_at IS NULL
|
|
""",
|
|
(user_id,),
|
|
)
|
|
return cur.rowcount
|