55beba5c0a
After a successful OTC sign-in, a contributor can set a passcode (4-20 characters, bcrypt-hashed) and use email + passcode for subsequent sign-ins. OTC remains the structural fallback: five consecutive failed verifies lock the passcode path for 15 minutes (HTTP 423), and a forgotten passcode is recovered by requesting a fresh code. Migration 015_passcode.sql adds four nullable columns to the users table; existing rows pass through as OTC-only and can opt into a passcode from a new Sign-in tab in /settings/notifications. The /login surface is extended to a five-step flow (email → either passcode or OTC code → optional post-OTC passcode offer → optional set-passcode). SPEC corrections per §19.3 rule 2: §6 names the three auth paths, §14.1 documents the stepped login flow, §17 lists the four new /auth/passcode/* endpoints, §19.2 surfaces four new candidates and refreshes the cross-refs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
368 lines
12 KiB
Python
368 lines
12 KiB
Python
"""§6.2 / v0.10.0: user-set passcodes after OTC (roadmap item #8).
|
|
|
|
After a successful OTC sign-in, a contributor may set a passcode and
|
|
use email + passcode for subsequent sign-ins. OTC remains the fallback
|
|
— a forgotten passcode is recovered by requesting a fresh OTC.
|
|
|
|
This module is the state machine behind the four `/auth/passcode/*`
|
|
endpoints (`set`, `clear`, `verify`, `check`). The endpoints in
|
|
`main.py` thin-wrap these helpers in the same shape the OTC module
|
|
uses (see `otc.py`).
|
|
|
|
Shape:
|
|
|
|
* `set_passcode(user_id, passcode)` — bcrypt-hash the passcode and
|
|
write it to `users.passcode_hash` + `users.passcode_set_at`.
|
|
Validation (length, denylist) happens here, not at the endpoint,
|
|
so the rule lives in one place. Replaces any prior passcode.
|
|
* `clear_passcode(user_id)` — null out `passcode_hash` and
|
|
`passcode_set_at`. The user is back to OTC-only.
|
|
* `verify_passcode(email, passcode)` — locate the user by email,
|
|
check lockout, compare via bcrypt, manage the failure counter,
|
|
and return a populated `SessionUser` on success.
|
|
* `passcode_status(email)` — does this email have a passcode set?
|
|
Used by the `/auth/passcode/check` endpoint that the Login.jsx
|
|
flow consults after the user types their email.
|
|
|
|
Lockout is a v1 shape: 5 consecutive failures sets
|
|
`passcode_locked_until` to `now + 15 minutes`, after which a verify
|
|
attempt that lands inside the window returns HTTP 423. The OTC path
|
|
is unaffected by the lockout — a user can request and verify a fresh
|
|
OTC to sign in while their passcode is locked out, and `verify_code`
|
|
in `otc.py` does not consult these columns.
|
|
|
|
The lockout window and the failure threshold are hard-coded here.
|
|
Tuning them via env vars (or moving to per-IP rate-limiting) is a
|
|
§19.2 candidate; see SPEC §19.2.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
|
|
import bcrypt
|
|
|
|
from . import db
|
|
from .auth import SessionUser
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tunables — intentionally hard-coded in v0.10.0 (see module docstring).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
|
|
LOCKOUT_DURATION_MINUTES = 15
|
|
|
|
PASSCODE_MIN_LENGTH = 4
|
|
PASSCODE_MAX_LENGTH = 20
|
|
|
|
# A small denylist of patterns we never want a passcode to be. The
|
|
# rule is "no obvious patterns"; the list is deliberately small —
|
|
# every entry here is a verbatim string match. A heavier check
|
|
# (sequential digits, single-character runs of length >= N, etc.)
|
|
# is a §19.2 candidate.
|
|
PASSCODE_DENYLIST: frozenset[str] = frozenset(
|
|
{
|
|
"0000",
|
|
"1111",
|
|
"2222",
|
|
"3333",
|
|
"4444",
|
|
"5555",
|
|
"6666",
|
|
"7777",
|
|
"8888",
|
|
"9999",
|
|
"1234",
|
|
"12345",
|
|
"123456",
|
|
"1234567",
|
|
"12345678",
|
|
"123456789",
|
|
"1234567890",
|
|
"0123",
|
|
"01234",
|
|
"012345",
|
|
"0123456",
|
|
"01234567",
|
|
"012345678",
|
|
"0123456789",
|
|
"abcd",
|
|
"abcde",
|
|
"abcdef",
|
|
"qwer",
|
|
"qwerty",
|
|
"asdf",
|
|
"asdfg",
|
|
"asdfgh",
|
|
"aaaa",
|
|
"bbbb",
|
|
"cccc",
|
|
"password",
|
|
"letmein",
|
|
}
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class PasscodeValidationError(Exception):
|
|
"""The proposed passcode failed validation. The endpoint surface
|
|
maps this to HTTP 422 with the message intact."""
|
|
|
|
|
|
def _validate(passcode: str) -> str:
|
|
"""Return the normalized passcode (stripped) or raise.
|
|
|
|
Rules:
|
|
* 4-20 characters after stripping leading/trailing whitespace.
|
|
* Not on the small denylist of obvious patterns.
|
|
|
|
No character-class restriction beyond that — the spec says
|
|
"numeric PIN or short alphanumeric"; we don't refuse other
|
|
characters because the entropy isn't load-bearing (the per-account
|
|
lockout is what carries the security weight, mirroring the OTC
|
|
shape from v0.7.0).
|
|
"""
|
|
pc = (passcode or "").strip()
|
|
if not pc:
|
|
raise PasscodeValidationError("Passcode is required")
|
|
if len(pc) < PASSCODE_MIN_LENGTH:
|
|
raise PasscodeValidationError(
|
|
f"Passcode must be at least {PASSCODE_MIN_LENGTH} characters"
|
|
)
|
|
if len(pc) > PASSCODE_MAX_LENGTH:
|
|
raise PasscodeValidationError(
|
|
f"Passcode must be at most {PASSCODE_MAX_LENGTH} characters"
|
|
)
|
|
if pc.lower() in PASSCODE_DENYLIST:
|
|
raise PasscodeValidationError("Passcode is too common; pick something less obvious")
|
|
return pc
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hashing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _hash(passcode: str) -> str:
|
|
return bcrypt.hashpw(passcode.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
|
|
|
|
|
def _check(passcode: str, passcode_hash: str) -> bool:
|
|
try:
|
|
return bcrypt.checkpw(passcode.encode("utf-8"), passcode_hash.encode("ascii"))
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Set / clear
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def set_passcode(user_id: int, passcode: str) -> None:
|
|
"""Hash and store the passcode. Replaces any prior passcode on the
|
|
same row; clears the failure counter and lockout (a user setting a
|
|
fresh passcode is implicitly re-authenticating their account)."""
|
|
pc = _validate(passcode)
|
|
h = _hash(pc)
|
|
db.conn().execute(
|
|
"""
|
|
UPDATE users
|
|
SET passcode_hash = ?,
|
|
passcode_set_at = datetime('now'),
|
|
passcode_failed_attempts = 0,
|
|
passcode_locked_until = NULL
|
|
WHERE id = ?
|
|
""",
|
|
(h, user_id),
|
|
)
|
|
|
|
|
|
def clear_passcode(user_id: int) -> None:
|
|
"""Remove the passcode. The user is back to OTC-only on next sign-in."""
|
|
db.conn().execute(
|
|
"""
|
|
UPDATE users
|
|
SET passcode_hash = NULL,
|
|
passcode_set_at = NULL,
|
|
passcode_failed_attempts = 0,
|
|
passcode_locked_until = NULL
|
|
WHERE id = ?
|
|
""",
|
|
(user_id,),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Check (status surface for the Login.jsx flow)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class PasscodeStatus:
|
|
"""The shape `/auth/passcode/check` returns.
|
|
|
|
`has_passcode` is the only signal the frontend needs to decide
|
|
whether to show a passcode input or an OTC request step. We do
|
|
not leak the hash, the set-at timestamp, or the lockout state —
|
|
a probing client that wants to know "is this account locked
|
|
out" can attempt a verify and read the 423.
|
|
"""
|
|
has_passcode: bool
|
|
|
|
|
|
def passcode_status(email: str) -> PasscodeStatus:
|
|
email = (email or "").strip()
|
|
if not email or "@" not in email:
|
|
return PasscodeStatus(has_passcode=False)
|
|
row = db.conn().execute(
|
|
"SELECT passcode_hash FROM users WHERE email = ? COLLATE NOCASE",
|
|
(email,),
|
|
).fetchone()
|
|
if row is None:
|
|
return PasscodeStatus(has_passcode=False)
|
|
return PasscodeStatus(has_passcode=bool(row["passcode_hash"]))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Verify
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class VerifyOutcome:
|
|
"""Result of a `verify_passcode` call.
|
|
|
|
`reason` distinguishes the failure modes the endpoint surfaces as
|
|
distinct HTTP shapes:
|
|
* 'ok' — populated `user`, HTTP 200.
|
|
* 'unknown' — no user with this email, HTTP 400 (generic).
|
|
* 'no_passcode' — user exists but never set a passcode, HTTP 400
|
|
(the frontend should fall back to OTC).
|
|
* 'locked' — user is currently in the lockout window, HTTP
|
|
423. `locked_until` carries the ISO-8601 stamp for the client.
|
|
* 'wrong' — passcode didn't match. HTTP 400. If the failure
|
|
crossed the lockout threshold the row is now locked; the
|
|
endpoint surfaces this as a fresh `locked` response on the
|
|
next attempt rather than collapsing the two states here.
|
|
"""
|
|
ok: bool
|
|
user: SessionUser | None
|
|
reason: str
|
|
locked_until: str | None = None
|
|
|
|
|
|
def verify_passcode(email: str, passcode: str) -> VerifyOutcome:
|
|
email = (email or "").strip()
|
|
passcode = (passcode or "").strip()
|
|
if not email or not passcode:
|
|
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
|
|
|
row = db.conn().execute(
|
|
"""
|
|
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
|
passcode_hash, passcode_failed_attempts, passcode_locked_until
|
|
FROM users
|
|
WHERE email = ? COLLATE NOCASE
|
|
""",
|
|
(email,),
|
|
).fetchone()
|
|
if row is None:
|
|
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
|
if not row["passcode_hash"]:
|
|
return VerifyOutcome(ok=False, user=None, reason="no_passcode")
|
|
|
|
# Lockout check: if `passcode_locked_until` is populated and in the
|
|
# future, the verify is refused without touching the hash. Once the
|
|
# window has elapsed we let the verify proceed; the failed-attempts
|
|
# counter is also reset so the user gets a fresh 5-attempt budget.
|
|
locked_until = row["passcode_locked_until"]
|
|
if locked_until:
|
|
still_locked = db.conn().execute(
|
|
"SELECT datetime(?) > datetime('now') AS still_locked",
|
|
(locked_until,),
|
|
).fetchone()["still_locked"]
|
|
if still_locked:
|
|
return VerifyOutcome(
|
|
ok=False,
|
|
user=None,
|
|
reason="locked",
|
|
locked_until=locked_until,
|
|
)
|
|
# Lockout expired — clear the counter so the next failure starts
|
|
# from zero, and continue with the verify.
|
|
db.conn().execute(
|
|
"""
|
|
UPDATE users
|
|
SET passcode_failed_attempts = 0,
|
|
passcode_locked_until = NULL
|
|
WHERE id = ?
|
|
""",
|
|
(row["id"],),
|
|
)
|
|
|
|
if _check(passcode, row["passcode_hash"]):
|
|
# Success: clear the counter (a single success wipes the
|
|
# accumulated failures — the threshold tracks *consecutive*
|
|
# failures).
|
|
db.conn().execute(
|
|
"""
|
|
UPDATE users
|
|
SET passcode_failed_attempts = 0,
|
|
passcode_locked_until = NULL,
|
|
last_seen_at = datetime('now')
|
|
WHERE id = ?
|
|
""",
|
|
(row["id"],),
|
|
)
|
|
return VerifyOutcome(
|
|
ok=True,
|
|
user=SessionUser(
|
|
user_id=row["id"],
|
|
gitea_id=row["gitea_id"] or 0,
|
|
gitea_login=row["gitea_login"] or "",
|
|
display_name=row["display_name"],
|
|
email=row["email"] or email,
|
|
avatar_url=row["avatar_url"] or "",
|
|
role=row["role"],
|
|
),
|
|
reason="ok",
|
|
)
|
|
|
|
# Failure: increment the counter. If this push crosses the
|
|
# threshold, stamp the lockout. The next verify attempt against
|
|
# the same row returns 423 with the `locked_until` stamp.
|
|
next_count = (row["passcode_failed_attempts"] or 0) + 1
|
|
if next_count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
|
|
db.conn().execute(
|
|
f"""
|
|
UPDATE users
|
|
SET passcode_failed_attempts = ?,
|
|
passcode_locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
|
|
WHERE id = ?
|
|
""",
|
|
(next_count, row["id"]),
|
|
)
|
|
new_locked_until = db.conn().execute(
|
|
"SELECT passcode_locked_until FROM users WHERE id = ?",
|
|
(row["id"],),
|
|
).fetchone()["passcode_locked_until"]
|
|
return VerifyOutcome(
|
|
ok=False,
|
|
user=None,
|
|
reason="locked",
|
|
locked_until=new_locked_until,
|
|
)
|
|
db.conn().execute(
|
|
"UPDATE users SET passcode_failed_attempts = ? WHERE id = ?",
|
|
(next_count, row["id"]),
|
|
)
|
|
return VerifyOutcome(ok=False, user=None, reason="wrong")
|