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.
253 lines
11 KiB
Python
253 lines
11 KiB
Python
"""
|
|
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))))
|