Files
scout-website/app/auth.py
T
thethreemagi 77dd250436 footer sign-in link; sign-in, invite and reset land on the console
One quiet 'Sign in · leaders & families' in the public footer, after the
site's own links and before Join, which stays the only call to action.
Nothing on the public site pointed at /login before; the only ways in
were typing the URL or an invite link. A fresh sign-in, an accepted
invite and a used reset link now land on /leaders/, which sends a
member on to Family and a leader to Summary. tests/smoke_identity.py
123 -> 125.
2026-09-04 20:39:45 -04:00

402 lines
19 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.
The LAST hop, not the first. NPM sets the header with
$proxy_add_x_forwarded_for, which APPENDS the connecting address to
whatever the client sent, so the first hop is client-controlled and the
last is the one NPM vouches for. With exactly one trusted proxy in front
(the app is published on 127.0.0.1 and the docker network only), the
rightmost address is the real client. Reading the first hop would let an
outsider claim a LAN address with one header - and that is exactly what
admin_api.client_is_lan decides on."""
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[-1].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, next_url=""):
return _shell(
"Sign in", "For Pack 73 and Troop 73 leaders and families.",
f"""<form method="post" action="/login">
<input type="hidden" name="next" value="{_esc(next_url)}">
<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):
# `next` arrives from the leader console's 401 redirect (next=/leaders/).
# identity.safe_next() keeps it same-origin; anything odd lands on /account.
nxt = identity.safe_next(request.query_params.get("next"))
if current_person(request):
return RedirectResponse(url=nxt, status_code=303)
return HTMLResponse(_render("Sign in", _login_form(next_url=nxt)))
@router.post("/login")
def login(request: Request, email: str = Form(""), password: str = Form(""),
next: str = Form("")):
nxt = identity.safe_next(next)
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, nxt)),
status_code=e.status)
resp = RedirectResponse(url=nxt, 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="/leaders/", 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>')
return HTMLResponse(_render("Your account", _account_page(person, request.query_params.get("done"))))
def _account_page(person, done=None, error=None, error_form=None):
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"]
flash = {"details": "Details saved.", "password": "Password changed. Your other devices were signed out."}.get(done or "")
err_d = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "details" else ""
err_p = '<div class="autherr">%s</div>' % _esc(error) if error and error_form == "password" else ""
body = _shell(
"Your account", _esc(person["email"]),
f"""{'<div class="success">%s</div>' % _esc(flash) if flash else ''}<div class="mcard" style="padding:18px 20px;margin:0 0 18px">
{''.join(rows)}
</div>
<form method="post" action="/account/details" class="mcard" style="padding:18px 20px;margin:0 0 18px">
<h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Your details</h2>{err_d}
<label for="full_name">Full name</label>
<input id="full_name" name="full_name" required value="{_esc(person.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(person.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(person.get('phone'))}">
<button class="cta" type="submit" style="margin-top:16px">Save details</button>
</form>
<form method="post" action="/account/password" class="mcard" style="padding:18px 20px;margin:0 0 18px">
<h2 class="sec" style="font-size:1.1rem;margin:0 0 4px">Change password</h2>{err_p}
<label for="current">Current password</label>
<input id="current" name="current" type="password" autocomplete="current-password" required>
<label for="new">New password</label>
<input id="new" name="new" type="password" autocomplete="new-password" required minlength="12">
<label for="confirm">Confirm new password</label>
<input id="confirm" name="confirm" type="password" autocomplete="new-password" required minlength="12">
<button class="cta" type="submit" style="margin-top:16px">Change password</button>
<p class="authhint">At least 12 characters. Changing it signs out your other devices.</p>
</form>
<form method="post" action="/logout"><button class="cta" type="submit" style="background:#1E2F52;color:#fff">Sign out</button></form>
<p class="authhint">Your email address and roles are set by an administrator.</p>""")
return 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))
@router.post("/account/details")
def account_details(request: Request, full_name: str = Form(""), preferred_name: str = Form(""),
phone: str = Form("")):
person = current_person(request)
if not person:
return RedirectResponse(url="/login", status_code=303)
try:
identity.update_own_details(person["id"], full_name, preferred_name, phone)
except identity.IdentityError as e:
return HTMLResponse(_render("Your account", _account_page(person, error=e.detail, error_form="details")),
status_code=e.status)
return RedirectResponse(url="/account?done=details", status_code=303)
@router.post("/account/password")
def account_password(request: Request, current: str = Form(""), new: str = Form(""), confirm: str = Form("")):
person = current_person(request)
if not person:
return RedirectResponse(url="/login", status_code=303)
if new != confirm:
return HTMLResponse(_render("Your account", _account_page(person, error="Those two passwords do not match.",
error_form="password")), status_code=422)
try:
identity.change_password(person["id"], current, new, ip=_client_ip(request))
except identity.IdentityError as e:
return HTMLResponse(_render("Your account", _account_page(person, error=e.detail, error_form="password")),
status_code=e.status)
# End the other sessions, keep this one: the person is still here.
tok = request.cookies.get(COOKIE)
con = identity.connect()
try:
con.execute("UPDATE sessions SET revoked_at=? WHERE person_id=? AND revoked_at IS NULL AND token_hash<>?",
(identity._now(), person["id"], identity._hash_token(tok or "")))
con.commit()
finally:
con.close()
return RedirectResponse(url="/account?done=password", status_code=303)
# ---------------------------------------------------------------------------
# Password reset by link. The link is minted by an admin on the console and
# handed over out of band; there is no outbound mail. Same posture as
# invites: expired, used, revoked and never-existed all read the same.
# ---------------------------------------------------------------------------
DEAD_RESET = ("This reset link is no longer valid. It may have been used already, replaced by "
"a newer one, or expired. Ask an administrator for a fresh link.")
def _reset_form(token, person, error=None):
return _shell(
"Choose a new password", "For <strong>%s</strong>." % _esc(person["email"]),
f"""<form method="post" action="/reset/{_esc(token)}">
<label for="password">New 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">Set password and sign in</button>
</form>
<p class="authhint">At least 12 characters. Every device signed in as you will be signed out.</p>""",
error)
@router.get("/reset/{token}", response_class=HTMLResponse)
def reset_form(request: Request, token: str):
person = identity.peek_reset(token)
if not person:
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
return HTMLResponse(_render("Choose a new password", _reset_form(token, person)))
@router.post("/reset/{token}")
def reset_accept(request: Request, token: str, password: str = Form(""), confirm: str = Form("")):
person = identity.peek_reset(token)
if not person:
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
if password != confirm:
return HTMLResponse(_render("Choose a new password",
_reset_form(token, person, "Those two passwords do not match.")), status_code=422)
try:
person = identity.consume_reset(token, password, ip=_client_ip(request))
except identity.IdentityError as e:
if e.status == 410:
return HTMLResponse(_render("Reset", _shell("Reset", "", "", DEAD_RESET)), status_code=410)
return HTMLResponse(_render("Choose a new password", _reset_form(token, person, 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="/leaders/", 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