diff --git a/app/app.py b/app/app.py index 00bbae7..3d6028e 100644 --- a/app/app.py +++ b/app/app.py @@ -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) diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..9894532 --- /dev/null +++ b/app/auth.py @@ -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 "%s%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 = """""" + + +def _shell(heading, intro, inner, error=None): + err = '
%s
' % _esc(error) if error else "" + return f"""{FORM_CSS} +
+

{heading}

+

{intro}

+{err}{inner} +
""" + + +# --------------------------------------------------------------------------- +# Login +# --------------------------------------------------------------------------- + +def _login_form(email="", error=None): + return _shell( + "Sign in", "For Pack 73 and Troop 73 leaders and families.", + f"""
+ + + + + +
+

Accounts are created by invitation. If you need one, ask a leader.

""", + 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 %s." % _esc(invite["email"]), + f"""
+ + + + + + + + + + + +
+

At least 12 characters. A short phrase you will remember beats +a short password you will not.

""", + 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('
Site-wide' + '%s
' + % _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('
%s' + '%s%s
' + % (_esc(m["short_name"]), + _esc(ROLE_LABEL.get(m["role"], m["role"])), title)) + if not rows: + rows.append('
No roles assigned yet.
') + + name = person.get("preferred_name") or person.get("full_name") or person["email"] + body = _shell( + "Your account", _esc(person["email"]), + f"""
+{''.join(rows)} +
+
+

Changing your own details is not built yet. Ask an administrator.

""") + return HTMLResponse(_render("Your account", body.replace( + "

Your account

", + "

Hello, %s

" % _esc(name)))) diff --git a/app/identity.py b/app/identity.py new file mode 100644 index 0000000..7e7e007 --- /dev/null +++ b/app/identity.py @@ -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 diff --git a/tests/smoke_identity.py b/tests/smoke_identity.py new file mode 100644 index 0000000..ed9392e --- /dev/null +++ b/tests/smoke_identity.py @@ -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)