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)