P0: identity layer - units, people, roles, invites, sessions

Adds identity.py (schema, capability map, scrypt passwords, invites,
sessions, login throttle, boot seed) and auth.py (login, invite
acceptance, account page). app.py gains two imports and one wiring
block at EOF; no existing behaviour changes.

Units are a table seeded from the site constants. Roles split: leader
and member per unit in memberships, owner and admin site-wide in
people.global_role, so a unit added later cannot under-grant an admin.

tests/smoke_identity.py covers the rules that are invisible when wrong:
single-use invites, reissue revoking the prior link, expiry, idle and
absolute session bounds, throttling, and the capability split. 49 checks.
This commit is contained in:
2026-09-04 11:29:32 -04:00
parent d6e594b1b6
commit bed93072cb
4 changed files with 1181 additions and 0 deletions
+45
View File
@@ -10,6 +10,8 @@ import store
import admin_api
import nearby
import documents
import identity
import auth
# ---------------------------------------------------------------------------
# Pack & Troop 73 - greenlanescouts73.org
@@ -1037,3 +1039,46 @@ def join_post(parent_name: str = Form(...), email: str = Form(...), phone: str =
except Exception:
pass
return RedirectResponse(url="/join?sent=1", status_code=303)
# ---------------------------------------------------------------------------
# Identity (P0)
# ---------------------------------------------------------------------------
# Wired at the END of this module on purpose: auth.PAGE needs page(), which is
# defined above, and keeping the whole attachment in one block means the P0
# footprint inside this 1000-line file is one place to read and one place to
# revert.
def bootstrap_notify(url):
"""Push the first owner invite to ntfy. Logged either way - the container
log is the copy that survives a missed notification."""
if not NTFY_BASE:
return False
headers = {"Content-Type": "application/json"}
if NTFY_TOKEN:
headers["Authorization"] = "Bearer " + NTFY_TOKEN
try:
body = json.dumps({
"topic": NTFY_TOPIC,
"title": "greenlanescouts73.org owner invite",
"message": "First admin account. Single use, expires in %d days.\n%s"
% (identity.INVITE_TTL_DAYS, url),
"tags": ["key"],
"priority": 4,
}).encode()
rq = urllib.request.Request(NTFY_BASE, data=body, headers=headers)
with urllib.request.urlopen(rq, timeout=5) as resp:
return resp.status < 400
except Exception as e:
print("identity: bootstrap ntfy push failed: %s" % e, flush=True)
return False
identity.init()
auth.PAGE = page
app.include_router(auth.router)
_boot_url, _boot_minted = identity.bootstrap()
if _boot_minted and _boot_url:
bootstrap_notify(_boot_url)
+252
View File
@@ -0,0 +1,252 @@
"""
auth.py - the HTTP surface over identity.py: login, invite acceptance, account.
Deliberately thin. Every rule (single-use invites, throttling, session bounds,
capabilities) lives in identity.py so the leader console and any future API
client inherit them rather than reimplementing them. This module only turns
those rules into pages and cookies.
It does not import app.py. The page shell is injected at include time
(`auth.PAGE = page`), because app.py imports this module and the reverse would
be circular. If PAGE is unset the pages still render, just unstyled - an
identity layer that cannot be signed into because a renderer is missing would
be a worse failure than a plain page.
"""
import html
import os
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
import identity
router = APIRouter(tags=["auth"])
COOKIE = "s73_session"
COOKIE_SECURE = identity.SITE_BASE_URL.startswith("https://")
# Set by app.py after page() is defined.
PAGE = None
def _esc(s):
return html.escape(str(s)) if s else ""
def _render(title, body):
if PAGE:
return PAGE(title, body)
return "<!doctype html><meta charset=utf-8><title>%s</title>%s" % (_esc(title), body)
def _client_ip(request):
"""Real client address. The app sits behind NPM, so request.client.host is
the proxy on every hit and would throttle the whole site as one address."""
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else None
def current_person(request):
"""The signed-in person, or None. This is the single seam other modules
use; documents.visible() attaches here in P1."""
return identity.session_person(request.cookies.get(COOKIE))
FORM_CSS = """<style>
.authwrap{max-width:420px;margin:0 auto}
.authwrap label{display:block;font-size:.86rem;font-weight:600;color:#3C4453;margin:14px 0 5px}
.authwrap input{width:100%;padding:11px 12px;border:1px solid #CBD2DC;border-radius:9px;
font-size:1rem;font-family:inherit;background:#fff;box-sizing:border-box}
.authwrap input:focus{outline:2px solid #1E3A6E;outline-offset:1px;border-color:#1E3A6E}
.authwrap button{margin-top:20px;width:100%;cursor:pointer;border:0;font-family:inherit}
.autherr{background:#FDEBEB;border:1px solid #E4A3A3;color:#8C2020;padding:11px 13px;
border-radius:9px;margin:0 0 4px;font-size:.93rem}
.authhint{color:#6B7280;font-size:.85rem;margin:6px 0 0}
</style>"""
def _shell(heading, intro, inner, error=None):
err = '<div class="autherr">%s</div>' % _esc(error) if error else ""
return f"""{FORM_CSS}
<section style="padding:64px 0 72px"><div class="wrap"><div class="authwrap">
<h1 class="sec" style="margin:0 0 6px">{heading}</h1>
<p class="authhint" style="margin:0 0 18px">{intro}</p>
{err}{inner}
</div></div></section>"""
# ---------------------------------------------------------------------------
# Login
# ---------------------------------------------------------------------------
def _login_form(email="", error=None):
return _shell(
"Sign in", "For Pack 73 and Troop 73 leaders and families.",
f"""<form method="post" action="/login">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="username" required value="{_esc(email)}">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button class="cta" type="submit">Sign in</button>
</form>
<p class="authhint">Accounts are created by invitation. If you need one, ask a leader.</p>""",
error)
@router.get("/login", response_class=HTMLResponse)
def login_form(request: Request):
if current_person(request):
return RedirectResponse(url="/account", status_code=303)
return HTMLResponse(_render("Sign in", _login_form()))
@router.post("/login")
def login(request: Request, email: str = Form(""), password: str = Form("")):
try:
person, token = identity.authenticate(
email, password, ip=_client_ip(request),
user_agent=request.headers.get("user-agent"))
except identity.IdentityError as e:
return HTMLResponse(_render("Sign in", _login_form(email, e.detail)),
status_code=e.status)
resp = RedirectResponse(url="/account", status_code=303)
resp.set_cookie(COOKIE, token, max_age=identity.SESSION_ABSOLUTE_DAYS * 86400,
httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/")
return resp
@router.post("/logout")
def logout(request: Request):
tok = request.cookies.get(COOKIE)
if tok:
person = identity.session_person(tok)
identity.end_session(tok)
identity.log_event("logout", person_id=person["id"] if person else None,
ip=_client_ip(request))
resp = RedirectResponse(url="/", status_code=303)
resp.delete_cookie(COOKIE, path="/")
return resp
# ---------------------------------------------------------------------------
# Invitations
# ---------------------------------------------------------------------------
#
# Expired, revoked, consumed and never-existed all render the same page. The
# difference is not the visitor's business, and telling them would confirm
# which addresses belong to real families.
DEAD_INVITE = ("This invitation link is no longer valid. It may have been used "
"already, replaced by a newer one, or expired. Ask whoever invited "
"you to send a fresh link.")
def _invite_form(token, invite, values=None, error=None):
v = values or {}
return _shell(
"Finish setting up your account",
"Invitation for <strong>%s</strong>." % _esc(invite["email"]),
f"""<form method="post" action="/invite/{_esc(token)}">
<label for="full_name">Full name</label>
<input id="full_name" name="full_name" required value="{_esc(v.get('full_name'))}">
<label for="preferred_name">Preferred name <span style="font-weight:400;color:#6B7280">(optional)</span></label>
<input id="preferred_name" name="preferred_name" value="{_esc(v.get('preferred_name'))}">
<label for="phone">Mobile <span style="font-weight:400;color:#6B7280">(optional)</span></label>
<input id="phone" name="phone" type="tel" value="{_esc(v.get('phone'))}">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password" required minlength="12">
<label for="confirm">Confirm password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
<button class="cta" type="submit">Create account</button>
</form>
<p class="authhint">At least 12 characters. A short phrase you will remember beats
a short password you will not.</p>""",
error)
@router.get("/invite/{token}", response_class=HTMLResponse)
def invite_form(request: Request, token: str):
invite = identity.peek_invite(token)
if not invite:
return HTMLResponse(_render("Invitation", _shell("Invitation", "", "",
DEAD_INVITE)), status_code=410)
return HTMLResponse(_render("Finish setting up your account",
_invite_form(token, invite)))
@router.post("/invite/{token}")
def invite_accept(request: Request, token: str, full_name: str = Form(""),
preferred_name: str = Form(""), phone: str = Form(""),
password: str = Form(""), confirm: str = Form("")):
invite = identity.peek_invite(token)
if not invite:
return HTMLResponse(_render("Invitation", _shell("Invitation", "", "",
DEAD_INVITE)), status_code=410)
vals = dict(full_name=full_name, preferred_name=preferred_name, phone=phone)
if password != confirm:
return HTMLResponse(_render("Finish setting up your account",
_invite_form(token, invite, vals,
"Those two passwords do not match.")),
status_code=422)
try:
person = identity.consume_invite(token, full_name, password,
preferred_name=preferred_name, phone=phone,
ip=_client_ip(request))
except identity.IdentityError as e:
if e.status == 410:
return HTMLResponse(_render("Invitation", _shell("Invitation", "", "",
DEAD_INVITE)), status_code=410)
return HTMLResponse(_render("Finish setting up your account",
_invite_form(token, invite, vals, e.detail)),
status_code=e.status)
tok = identity.start_session(person["id"], ip=_client_ip(request),
user_agent=request.headers.get("user-agent"))
resp = RedirectResponse(url="/account", status_code=303)
resp.set_cookie(COOKIE, tok, max_age=identity.SESSION_ABSOLUTE_DAYS * 86400,
httponly=True, secure=COOKIE_SECURE, samesite="lax", path="/")
return resp
# ---------------------------------------------------------------------------
# Account
# ---------------------------------------------------------------------------
ROLE_LABEL = {"owner": "Site owner", "admin": "Administrator",
"leader": "Leader", "member": "Member"}
@router.get("/account", response_class=HTMLResponse)
def account(request: Request):
person = current_person(request)
if not person:
return RedirectResponse(url="/login", status_code=303)
rows = []
if person.get("global_role"):
rows.append('<div class="rrow"><span class="k" style="min-width:110px">Site-wide</span>'
'<span class="v">%s</span></div>'
% _esc(ROLE_LABEL.get(person["global_role"], person["global_role"])))
for m in person["memberships"]:
title = " · %s" % _esc(m["title"]) if m["title"] else ""
rows.append('<div class="rrow"><span class="k" style="min-width:110px">%s</span>'
'<span class="v">%s%s</span></div>'
% (_esc(m["short_name"]),
_esc(ROLE_LABEL.get(m["role"], m["role"])), title))
if not rows:
rows.append('<div class="rrow"><span class="v">No roles assigned yet.</span></div>')
name = person.get("preferred_name") or person.get("full_name") or person["email"]
body = _shell(
"Your account", _esc(person["email"]),
f"""<div class="mcard" style="padding:18px 20px;margin:0 0 18px">
{''.join(rows)}
</div>
<form method="post" action="/logout"><button class="cta" type="submit">Sign out</button></form>
<p class="authhint">Changing your own details is not built yet. Ask an administrator.</p>""")
return HTMLResponse(_render("Your account", body.replace(
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Your account</h1>",
"<h1 class=\"sec\" style=\"margin:0 0 6px\">Hello, %s</h1>" % _esc(name))))
+712
View File
@@ -0,0 +1,712 @@
"""
identity.py - units, people, roles, invites and sessions for greenlanescouts73.org
This is the identity layer the leader console (scout-control) and the member
document gate both sit on. It owns tables in scout73.db and, like store.py,
this app remains the only writer to them.
Three decisions are load-bearing and should not be quietly undone.
UNITS ARE A TABLE, NOT A STRING. A slug typed into six places is a slug that
will be typed wrong in one of them. Meeting nights live on the unit row rather
than in settings, because they are facts about a unit, and they are stored
structured (weekday + 24h time) rather than as display sentences, because the
site composes them five different ways.
ROLES SPLIT TWO WAYS. `leader` and `member` are per unit, in memberships.
`owner` and `admin` are site-wide, in people.global_role. If admin were a
membership row, granting it would mean one insert per unit, and the day a third
unit is added every existing admin would silently lose sight of it. Effective
capability is the union of the global set and the per-unit sets.
NOTHING IS HARD DELETED. People are disabled, invites are revoked or consumed,
sessions are revoked. Who had access, and when, has to stay answerable.
Stdlib only, as with store.py - scrypt ships with Python, so this adds no
image dependencies.
"""
import base64
import datetime
import hashlib
import hmac
import json
import os
import secrets
import sqlite3
import uuid
from pathlib import Path
DB_PATH = Path(os.environ.get("STORE_DB", "/data/scout73.db"))
SITE_BASE_URL = os.environ.get("SITE_BASE_URL", "https://greenlanescouts73.org").rstrip("/")
ADMIN_BOOTSTRAP_EMAIL = os.environ.get("ADMIN_BOOTSTRAP_EMAIL", "").strip().lower()
SESSION_ABSOLUTE_DAYS = 30
SESSION_IDLE_HOURS = 12
INVITE_TTL_DAYS = 14
# Login throttle. Counted from auth_events, so it survives a restart - an
# in-memory counter resets to zero on every deploy, which is not a throttle.
LOGIN_WINDOW_MINUTES = 15
LOGIN_MAX_FAILURES = 8
GLOBAL_ROLES = ("owner", "admin")
UNIT_ROLES = ("leader", "member")
# ---------------------------------------------------------------------------
# Capabilities
# ---------------------------------------------------------------------------
# One dictionary, consulted by one function. Scattered `if role == "admin"`
# checks are how a permission model rots: there has to be a single place to
# read to know who can do what.
#
# email:* are reserved and unused. The mail server is not connected yet, and
# keys minted before it lands should not need re-scoping afterwards.
CAPS = {
"member": {
"account:self",
"documents:read_members",
},
"leader": {
"account:self",
"documents:read_members",
"announcements:write",
"leads:read",
"nearby:write",
"calendar:write",
"unit:write_own",
"apikeys:own",
"email:draft",
},
"admin": {
"account:self",
"documents:read_members",
"announcements:write",
"leads:read",
"nearby:write",
"calendar:write",
"unit:write_own",
"units:write",
"settings:write",
"apikeys:own",
"people:invite_leader",
"people:invite_admin",
"email:draft",
"email:send",
},
}
CAPS["owner"] = CAPS["admin"] | {"people:manage", "secrets:rotate"}
SCHEMA = """
CREATE TABLE IF NOT EXISTS units (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
short_name TEXT NOT NULL,
unit_type TEXT NOT NULL,
unit_number TEXT NOT NULL,
meets_weekday INTEGER,
meets_time TEXT,
meets_at TEXT,
active INTEGER NOT NULL DEFAULT 1,
sort_order INTEGER NOT NULL DEFAULT 100,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS people (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
full_name TEXT,
preferred_name TEXT,
phone TEXT,
password_hash TEXT,
global_role TEXT,
bsa_member_id TEXT,
ypt_completed_on TEXT,
registered_adult INTEGER NOT NULL DEFAULT 0,
contact_pref TEXT,
created_at TEXT NOT NULL,
created_by TEXT,
last_login_at TEXT,
disabled_at TEXT,
disabled_reason TEXT
);
CREATE TABLE IF NOT EXISTS memberships (
person_id TEXT NOT NULL REFERENCES people(id),
unit_id TEXT NOT NULL REFERENCES units(id),
role TEXT NOT NULL,
title TEXT,
created_at TEXT NOT NULL,
created_by TEXT,
PRIMARY KEY (person_id, unit_id)
);
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
email TEXT NOT NULL COLLATE NOCASE,
global_role TEXT,
units TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
created_by TEXT,
expires_at TEXT NOT NULL,
consumed_at TEXT,
person_id TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_invites_email ON invites(email, consumed_at, revoked_at);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
token_hash TEXT NOT NULL UNIQUE,
person_id TEXT NOT NULL REFERENCES people(id),
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
ip TEXT,
user_agent TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_person ON sessions(person_id, revoked_at);
CREATE TABLE IF NOT EXISTS auth_events (
id TEXT PRIMARY KEY,
at TEXT NOT NULL,
kind TEXT NOT NULL,
person_id TEXT,
actor_id TEXT,
email TEXT,
detail TEXT,
ip TEXT
);
CREATE INDEX IF NOT EXISTS idx_auth_events_at ON auth_events(at DESC);
CREATE INDEX IF NOT EXISTS idx_auth_events_kind ON auth_events(kind, email, at DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT
);
"""
# Seeded at boot, idempotent by slug. Values lifted from the site constants in
# app.py so the two cannot disagree on day one. app.py keeps its constants
# until P2 moves the rendering over.
SEED_UNITS = [
dict(slug="pack73", display_name="Cub Scout Pack 73", short_name="Pack 73",
unit_type="pack", unit_number="73", meets_weekday=2, meets_time="18:00",
meets_at="St. Luke's Lutheran Church, Zieglerville, PA", sort_order=10),
dict(slug="troop73", display_name="Scouts BSA Troop 73", short_name="Troop 73",
unit_type="troop", unit_number="73", meets_weekday=2, meets_time="19:30",
meets_at="St. Luke's Lutheran Church, Zieglerville, PA", sort_order=20),
]
class IdentityError(Exception):
"""Carries the HTTP status the route should return, so rules live here."""
def __init__(self, status, detail):
super().__init__(detail)
self.status = status
self.detail = detail
def _now():
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
def _plus(**kw):
return (datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(**kw)).isoformat(timespec="seconds")
def connect():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH, timeout=10)
con.row_factory = sqlite3.Row
con.execute("PRAGMA foreign_keys=ON")
return con
def init():
"""Create the schema and seed units. Safe on every boot."""
con = connect()
try:
con.executescript(SCHEMA)
for u in SEED_UNITS:
con.execute(
"INSERT INTO units (id, slug, display_name, short_name, unit_type,"
" unit_number, meets_weekday, meets_time, meets_at, active, sort_order, updated_at)"
" VALUES (?,?,?,?,?,?,?,?,?,1,?,?)"
" ON CONFLICT(slug) DO NOTHING",
(str(uuid.uuid4()), u["slug"], u["display_name"], u["short_name"],
u["unit_type"], u["unit_number"], u["meets_weekday"], u["meets_time"],
u["meets_at"], u["sort_order"], _now()))
con.commit()
finally:
con.close()
# ---------------------------------------------------------------------------
# Passwords
# ---------------------------------------------------------------------------
SCRYPT_N, SCRYPT_R, SCRYPT_P = 2 ** 14, 8, 1
def hash_password(password):
if not password or len(password) < 12:
raise IdentityError(422, "password must be at least 12 characters")
salt = secrets.token_bytes(16)
dk = hashlib.scrypt(password.encode(), salt=salt, n=SCRYPT_N, r=SCRYPT_R,
p=SCRYPT_P, dklen=32)
return "scrypt$%d$%d$%d$%s$%s" % (
SCRYPT_N, SCRYPT_R, SCRYPT_P,
base64.b64encode(salt).decode(), base64.b64encode(dk).decode())
def verify_password(password, stored):
"""Constant-time check. False on anything malformed rather than raising -
a corrupt hash must read as a failed login, never as a pass."""
try:
scheme, n, r, p, salt_b64, dk_b64 = stored.split("$")
if scheme != "scrypt":
return False
dk = hashlib.scrypt(password.encode(), salt=base64.b64decode(salt_b64),
n=int(n), r=int(r), p=int(p), dklen=32)
return hmac.compare_digest(dk, base64.b64decode(dk_b64))
except Exception:
return False
def _hash_token(tok):
return hashlib.sha256(tok.encode()).hexdigest()
# ---------------------------------------------------------------------------
# Audit
# ---------------------------------------------------------------------------
def log_event(kind, person_id=None, actor_id=None, email=None, detail=None, ip=None, con=None):
own = con is None
con = con or connect()
try:
con.execute(
"INSERT INTO auth_events (id, at, kind, person_id, actor_id, email, detail, ip)"
" VALUES (?,?,?,?,?,?,?,?)",
(str(uuid.uuid4()), _now(), kind, person_id, actor_id,
(email or "").lower() or None, detail, ip))
if own:
con.commit()
finally:
if own:
con.close()
# ---------------------------------------------------------------------------
# Units and people
# ---------------------------------------------------------------------------
def list_units(include_inactive=False):
con = connect()
try:
sql = "SELECT * FROM units"
if not include_inactive:
sql += " WHERE active = 1"
sql += " ORDER BY sort_order, slug"
return [dict(r) for r in con.execute(sql)]
finally:
con.close()
def get_unit(slug_or_id):
con = connect()
try:
r = con.execute("SELECT * FROM units WHERE slug=? OR id=?",
(slug_or_id, slug_or_id)).fetchone()
return dict(r) if r else None
finally:
con.close()
def _person_row(con, r):
if not r:
return None
p = dict(r)
p.pop("password_hash", None)
p["memberships"] = [dict(m) for m in con.execute(
"SELECT m.unit_id, m.role, m.title, u.slug, u.short_name, u.display_name"
" FROM memberships m JOIN units u ON u.id = m.unit_id"
" WHERE m.person_id = ? ORDER BY u.sort_order", (p["id"],))]
p["capabilities"] = sorted(effective_caps(p))
return p
def get_person(person_id):
con = connect()
try:
return _person_row(con, con.execute(
"SELECT * FROM people WHERE id=?", (person_id,)).fetchone())
finally:
con.close()
def get_person_by_email(email):
con = connect()
try:
return _person_row(con, con.execute(
"SELECT * FROM people WHERE email=?", ((email or "").lower(),)).fetchone())
finally:
con.close()
def people_count():
con = connect()
try:
return con.execute("SELECT COUNT(*) c FROM people").fetchone()["c"]
finally:
con.close()
def effective_caps(person):
"""Union of the global role's capabilities and every membership's.
Union, not precedence: an admin who is also a den leader should not lose
anything by holding both, and a leader in one unit is not thereby a leader
in another - that part is answered by can(), which takes a unit.
"""
caps = set()
if person.get("disabled_at"):
return caps
if person.get("global_role"):
caps |= CAPS.get(person["global_role"], set())
for m in person.get("memberships", []):
caps |= CAPS.get(m["role"], set())
return caps
def can(person, capability, unit_id=None):
"""Does this person hold `capability`, optionally within a specific unit?
A global role satisfies a unit-scoped check for EVERY unit, including units
created after the role was granted. That is the entire reason global_role
is a column rather than a membership row.
"""
if not person or person.get("disabled_at"):
return False
if person.get("global_role") and capability in CAPS.get(person["global_role"], set()):
return True
for m in person.get("memberships", []):
if unit_id and m["unit_id"] != unit_id:
continue
if capability in CAPS.get(m["role"], set()):
return True
return False
# ---------------------------------------------------------------------------
# Invites
# ---------------------------------------------------------------------------
#
# Only the sha256 of the token is stored. A database read, a backup on the NAS
# or a stray SELECT must not hand over live invitations.
#
# Issuing a new invite for an address revokes the prior unconsumed one, which
# is what "the URL can be recreated until it is used" means in practice. Single
# use is enforced by setting consumed_at in the SAME transaction that creates
# the person, so a double submit cannot mint two accounts.
def create_invite(email, global_role=None, units=None, created_by=None, ttl_days=INVITE_TTL_DAYS):
"""Revoke any live invite for this address, mint a new one, return (row, token).
The raw token is returned exactly once and never stored.
"""
email = (email or "").strip().lower()
if "@" not in email:
raise IdentityError(422, "a valid email address is required")
if global_role and global_role not in GLOBAL_ROLES:
raise IdentityError(422, "global_role must be one of %s" % (GLOBAL_ROLES,))
units = units or []
for u in units:
if u.get("role") not in UNIT_ROLES:
raise IdentityError(422, "unit role must be one of %s" % (UNIT_ROLES,))
if not get_unit(u.get("unit_id") or ""):
raise IdentityError(422, "unknown unit %r" % (u.get("unit_id"),))
if not global_role and not units:
raise IdentityError(422, "an invite needs a global role, a unit membership, or both")
tok = secrets.token_urlsafe(32)
iid = str(uuid.uuid4())
con = connect()
try:
con.execute("UPDATE invites SET revoked_at=? WHERE email=? AND consumed_at IS NULL"
" AND revoked_at IS NULL", (_now(), email))
con.execute(
"INSERT INTO invites (id, token_hash, email, global_role, units, created_at,"
" created_by, expires_at, consumed_at, person_id, revoked_at)"
" VALUES (?,?,?,?,?,?,?,?,NULL,NULL,NULL)",
(iid, _hash_token(tok), email, global_role,
json.dumps(units, ensure_ascii=False), _now(), created_by,
_plus(days=ttl_days)))
log_event("invite.created", actor_id=created_by, email=email,
detail="role=%s units=%d" % (global_role or "-", len(units)), con=con)
con.commit()
row = dict(con.execute("SELECT * FROM invites WHERE id=?", (iid,)).fetchone())
finally:
con.close()
return row, tok
def invite_url(token):
return "%s/invite/%s" % (SITE_BASE_URL, token)
def live_invite_for(email):
"""The current unconsumed, unrevoked, unexpired invite for an address, if any.
Used by bootstrap so a container restart does not invalidate a link somebody
is already holding.
"""
con = connect()
try:
r = con.execute(
"SELECT * FROM invites WHERE email=? AND consumed_at IS NULL"
" AND revoked_at IS NULL AND expires_at > ?"
" ORDER BY created_at DESC LIMIT 1", ((email or "").lower(), _now())).fetchone()
return dict(r) if r else None
finally:
con.close()
def peek_invite(token):
"""Read an invite by raw token without consuming it. None if unusable.
Expired, revoked and consumed all return None on purpose. Telling the
difference tells a stranger which addresses are real.
"""
con = connect()
try:
r = con.execute(
"SELECT * FROM invites WHERE token_hash=? AND consumed_at IS NULL"
" AND revoked_at IS NULL AND expires_at > ?",
(_hash_token(token), _now())).fetchone()
return dict(r) if r else None
finally:
con.close()
def consume_invite(token, full_name, password, preferred_name=None, phone=None, ip=None):
"""Create the person and burn the invite in one transaction.
Everything below happens or nothing does. A half-applied invite would leave
an account with no memberships and a token that still looks live.
"""
full_name = (full_name or "").strip()
if not full_name:
raise IdentityError(422, "name is required")
pw_hash = hash_password(password)
con = connect()
try:
con.execute("BEGIN IMMEDIATE")
r = con.execute(
"SELECT * FROM invites WHERE token_hash=? AND consumed_at IS NULL"
" AND revoked_at IS NULL AND expires_at > ?",
(_hash_token(token), _now())).fetchone()
if not r:
con.rollback()
raise IdentityError(410, "this invitation is no longer valid")
inv = dict(r)
if con.execute("SELECT 1 FROM people WHERE email=?", (inv["email"],)).fetchone():
con.rollback()
raise IdentityError(409, "an account already exists for this address")
pid = str(uuid.uuid4())
con.execute(
"INSERT INTO people (id, email, full_name, preferred_name, phone, password_hash,"
" global_role, registered_adult, created_at, created_by)"
" VALUES (?,?,?,?,?,?,?,0,?,?)",
(pid, inv["email"], full_name, (preferred_name or "").strip() or None,
(phone or "").strip() or None, pw_hash, inv["global_role"], _now(),
inv["created_by"]))
for u in json.loads(inv["units"] or "[]"):
con.execute(
"INSERT INTO memberships (person_id, unit_id, role, title, created_at, created_by)"
" VALUES (?,?,?,?,?,?)",
(pid, u["unit_id"], u["role"], u.get("title"), _now(), inv["created_by"]))
con.execute("UPDATE invites SET consumed_at=?, person_id=? WHERE id=?",
(_now(), pid, inv["id"]))
log_event("invite.consumed", person_id=pid, email=inv["email"], ip=ip, con=con)
con.commit()
finally:
con.close()
return get_person(pid)
# ---------------------------------------------------------------------------
# Sessions
# ---------------------------------------------------------------------------
#
# Rows, not signed cookies. A leader stepping down has to be revocable now
# rather than at token expiry, and "sign out everywhere" has to be possible.
def start_session(person_id, ip=None, user_agent=None):
tok = secrets.token_urlsafe(32)
con = connect()
try:
con.execute(
"INSERT INTO sessions (id, token_hash, person_id, created_at, expires_at,"
" last_seen_at, ip, user_agent, revoked_at) VALUES (?,?,?,?,?,?,?,?,NULL)",
(str(uuid.uuid4()), _hash_token(tok), person_id, _now(),
_plus(days=SESSION_ABSOLUTE_DAYS), _now(), ip, (user_agent or "")[:200]))
con.execute("UPDATE people SET last_login_at=? WHERE id=?", (_now(), person_id))
con.commit()
finally:
con.close()
return tok
def session_person(token):
"""The person behind a session cookie, or None.
Enforces both bounds: an absolute expiry and an idle timeout. Touches
last_seen_at on success, so the idle clock tracks use rather than login.
"""
if not token:
return None
con = connect()
try:
r = con.execute(
"SELECT * FROM sessions WHERE token_hash=? AND revoked_at IS NULL",
(_hash_token(token),)).fetchone()
if not r:
return None
now = _now()
if r["expires_at"] <= now:
return None
idle_cutoff = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(hours=SESSION_IDLE_HOURS)).isoformat(timespec="seconds")
if r["last_seen_at"] <= idle_cutoff:
return None
p = _person_row(con, con.execute(
"SELECT * FROM people WHERE id=?", (r["person_id"],)).fetchone())
if not p or p.get("disabled_at"):
return None
con.execute("UPDATE sessions SET last_seen_at=? WHERE id=?", (now, r["id"]))
con.commit()
return p
finally:
con.close()
def end_session(token):
con = connect()
try:
con.execute("UPDATE sessions SET revoked_at=? WHERE token_hash=? AND revoked_at IS NULL",
(_now(), _hash_token(token)))
con.commit()
finally:
con.close()
def end_all_sessions(person_id):
con = connect()
try:
cur = con.execute("UPDATE sessions SET revoked_at=? WHERE person_id=? AND revoked_at IS NULL",
(_now(), person_id))
con.commit()
return cur.rowcount
finally:
con.close()
# ---------------------------------------------------------------------------
# Login
# ---------------------------------------------------------------------------
def recent_failures(email):
cutoff = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(minutes=LOGIN_WINDOW_MINUTES)).isoformat(timespec="seconds")
con = connect()
try:
return con.execute(
"SELECT COUNT(*) c FROM auth_events WHERE kind='login.failed' AND email=? AND at > ?",
((email or "").lower(), cutoff)).fetchone()["c"]
finally:
con.close()
def authenticate(email, password, ip=None, user_agent=None):
"""Returns (person, session_token). Raises IdentityError on any failure.
One message for every failure mode. Distinguishing "no such account" from
"wrong password" enumerates the address list of a volunteer organisation.
"""
email = (email or "").strip().lower()
if recent_failures(email) >= LOGIN_MAX_FAILURES:
log_event("login.throttled", email=email, ip=ip)
raise IdentityError(429, "too many attempts. Wait %d minutes and try again."
% LOGIN_WINDOW_MINUTES)
con = connect()
try:
r = con.execute("SELECT * FROM people WHERE email=?", (email,)).fetchone()
stored = r["password_hash"] if r else None
finally:
con.close()
ok = bool(stored) and verify_password(password or "", stored)
if not ok or (r and r["disabled_at"]):
log_event("login.failed", person_id=(r["id"] if r else None), email=email, ip=ip,
detail="disabled" if (r and r["disabled_at"]) else "bad credentials")
raise IdentityError(401, "that email and password do not match an account")
tok = start_session(r["id"], ip=ip, user_agent=user_agent)
log_event("login.ok", person_id=r["id"], email=email, ip=ip)
return get_person(r["id"]), tok
# ---------------------------------------------------------------------------
# Bootstrap
# ---------------------------------------------------------------------------
#
# The first owner has nobody to invite them, so the seed is a boot action and
# never a UI one. It goes inert the moment any person exists, and stays in the
# code as a disaster path rather than being deleted.
#
# A live invite is REUSED across restarts. Minting a fresh token on every boot
# would invalidate the link the recipient is already holding, every deploy.
def bootstrap():
"""Returns (url, minted) or (None, False). Never raises: a boot path that
can take the site down over a misconfigured email address is worse than
one that logs and carries on."""
try:
if people_count() > 0:
return None, False
if not ADMIN_BOOTSTRAP_EMAIL:
print("identity: no people and ADMIN_BOOTSTRAP_EMAIL is unset, "
"nobody can sign in", flush=True)
return None, False
live = live_invite_for(ADMIN_BOOTSTRAP_EMAIL)
if live:
print("identity: owner invite already outstanding for %s, expires %s"
% (ADMIN_BOOTSTRAP_EMAIL, live["expires_at"]), flush=True)
return None, False
_, tok = create_invite(ADMIN_BOOTSTRAP_EMAIL, global_role="owner",
created_by="bootstrap")
url = invite_url(tok)
print("identity: BOOTSTRAP OWNER INVITE for %s -> %s"
% (ADMIN_BOOTSTRAP_EMAIL, url), flush=True)
return url, True
except Exception as e:
print("identity: bootstrap failed: %s" % e, flush=True)
return None, False
+172
View File
@@ -0,0 +1,172 @@
"""
smoke_identity.py - end-to-end check of the identity layer against a throwaway DB.
Runs in-process with no container, no network and no dependencies beyond the
stdlib, so it can be run before anything is committed.
STORE_DB=/tmp/x.db python3 tests/smoke_identity.py
It covers the rules that are expensive to get wrong and invisible when they
are: single-use invites, reissue revoking the previous link, the idle and
absolute session bounds, login throttling, and the global-versus-unit
capability split.
"""
import datetime, os, sys, tempfile, uuid
DB = os.environ.get("STORE_DB") or os.path.join(tempfile.mkdtemp(), "smoke.db")
os.environ["STORE_DB"] = DB
os.environ["ADMIN_BOOTSTRAP_EMAIL"] = "owner@example.test"
os.environ["SITE_BASE_URL"] = "https://greenlanescouts73.org"
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app"))
import identity as I
PASS = FAIL = 0
def check(label, cond):
global PASS, FAIL
if cond:
PASS += 1
print(" ok %s" % label)
else:
FAIL += 1
print(" FAIL %s" % label)
def raises(label, status, fn, *a, **kw):
try:
fn(*a, **kw)
except I.IdentityError as e:
check("%s -> %d" % (label, status), e.status == status)
return
except Exception as e:
check("%s -> %d (got %r)" % (label, status, e), False)
return
check("%s -> %d (no error raised)" % (label, status), False)
print("db: %s\n" % DB)
print("schema and unit seed")
I.init()
I.init()
units = I.list_units()
check("two units seeded", len(units) == 2)
check("init is idempotent", len(I.list_units()) == 2)
pack = I.get_unit("pack73"); troop = I.get_unit("troop73")
check("pack73 by slug", pack and pack["short_name"] == "Pack 73")
check("pack meets Tuesday 18:00", pack["meets_weekday"] == 2 and pack["meets_time"] == "18:00")
check("troop meets 19:30", troop["meets_time"] == "19:30")
print("\npasswords")
h = I.hash_password("correct horse battery staple")
check("verify accepts", I.verify_password("correct horse battery staple", h))
check("verify rejects", not I.verify_password("wrong", h))
check("verify rejects corrupt hash", not I.verify_password("x", "garbage"))
raises("short password", 422, I.hash_password, "short")
print("\nbootstrap")
url, minted = I.bootstrap()
check("mints on empty db", minted and url)
tok = url.rsplit("/", 1)[-1]
url2, minted2 = I.bootstrap()
check("reuses live invite on restart", not minted2 and url2 is None)
check("original token still live", I.peek_invite(tok) is not None)
print("\ninvite consumption")
check("peek does not consume", I.peek_invite(tok)["email"] == "owner@example.test")
owner = I.consume_invite(tok, "Test Owner", "a-long-enough-password", phone="555")
check("person created", owner["email"] == "owner@example.test")
check("global_role owner", owner["global_role"] == "owner")
check("password not returned", "password_hash" not in owner)
raises("second use of same token", 410, I.consume_invite, tok, "Impostor", "a-long-enough-password")
check("bootstrap now inert", I.bootstrap() == (None, False))
print("\nreissue revokes the previous link")
_, t1 = I.create_invite("leader@example.test", units=[{"unit_id": pack["id"], "role": "leader"}])
_, t2 = I.create_invite("leader@example.test", units=[{"unit_id": pack["id"], "role": "leader"}])
check("old token dead", I.peek_invite(t1) is None)
check("new token live", I.peek_invite(t2) is not None)
raises("invite with no role at all", 422, I.create_invite, "x@example.test")
raises("invite to unknown unit", 422, I.create_invite, "x@example.test",
None, [{"unit_id": "nope", "role": "leader"}])
raises("invite with bad unit role", 422, I.create_invite, "x@example.test",
None, [{"unit_id": pack["id"], "role": "wizard"}])
print("\nexpiry")
_, t3 = I.create_invite("expired@example.test", global_role="admin", ttl_days=-1)
check("expired invite unusable", I.peek_invite(t3) is None)
raises("expired invite cannot be consumed", 410, I.consume_invite, t3, "N", "a-long-enough-password")
leader = I.consume_invite(t2, "Den Leader", "another-long-password")
print("\ncapabilities")
check("leader can write calendar in own unit", I.can(leader, "calendar:write", pack["id"]))
check("leader cannot in the other unit", not I.can(leader, "calendar:write", troop["id"]))
check("leader cannot invite", not I.can(leader, "people:invite_leader"))
check("owner can manage people", I.can(owner, "people:manage"))
check("owner spans both units", I.can(owner, "calendar:write", pack["id"])
and I.can(owner, "calendar:write", troop["id"]))
con = I.connect()
con.execute("INSERT INTO units (id, slug, display_name, short_name, unit_type, unit_number,"
" active, sort_order, updated_at) VALUES (?,?,?,?,?,?,1,30,?)",
(str(uuid.uuid4()), "crew73", "Venturing Crew 73", "Crew 73", "crew", "73", I._now()))
con.commit(); con.close()
crew = I.get_unit("crew73")
check("owner reaches a unit created after the grant", I.can(I.get_person(owner["id"]),
"calendar:write", crew["id"]))
check("leader does not", not I.can(I.get_person(leader["id"]), "calendar:write", crew["id"]))
print("\nlogin and sessions")
raises("wrong password", 401, I.authenticate, "owner@example.test", "nope")
raises("unknown account", 401, I.authenticate, "ghost@example.test", "whatever")
p, stok = I.authenticate("owner@example.test", "a-long-enough-password")
check("authenticates", p["id"] == owner["id"])
check("session resolves", I.session_person(stok)["id"] == owner["id"])
check("junk cookie resolves to nobody", I.session_person("junk") is None)
I.end_session(stok)
check("revoked session is dead", I.session_person(stok) is None)
_, stok2 = I.authenticate("owner@example.test", "a-long-enough-password")
con = I.connect()
stale = (datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(hours=I.SESSION_IDLE_HOURS + 1)).isoformat(timespec="seconds")
con.execute("UPDATE sessions SET last_seen_at=? WHERE token_hash=?",
(stale, I._hash_token(stok2)))
con.commit(); con.close()
check("idle timeout enforced", I.session_person(stok2) is None)
_, stok3 = I.authenticate("owner@example.test", "a-long-enough-password")
con = I.connect()
con.execute("UPDATE people SET disabled_at=? WHERE id=?", (I._now(), owner["id"]))
con.commit(); con.close()
check("disabled person has no session", I.session_person(stok3) is None)
check("disabled person holds no capabilities", I.effective_caps(I.get_person(owner["id"])) == set())
raises("disabled person cannot log in", 401, I.authenticate,
"owner@example.test", "a-long-enough-password")
con = I.connect(); con.execute("UPDATE people SET disabled_at=NULL WHERE id=?", (owner["id"],))
con.commit(); con.close()
print("\nthrottle")
for _ in range(I.LOGIN_MAX_FAILURES):
try:
I.authenticate("throttle@example.test", "bad")
except I.IdentityError:
pass
raises("locks out after %d failures" % I.LOGIN_MAX_FAILURES, 429,
I.authenticate, "throttle@example.test", "bad")
check("other accounts unaffected", I.authenticate("leader@example.test",
"another-long-password")[0] is not None)
print("\naudit")
con = I.connect()
kinds = {r["kind"] for r in con.execute("SELECT DISTINCT kind FROM auth_events")}
con.close()
for k in ("invite.created", "invite.consumed", "login.ok", "login.failed", "login.throttled"):
check("auth_events records %s" % k, k in kinds)
print("\n%d passed, %d failed" % (PASS, FAIL))
sys.exit(1 if FAIL else 0)