The console's 401 redirect carries next=/leaders/ and /login dropped it, so a leader arriving cold landed on /account. next now rides the form as a hidden field and is applied after a successful POST and when an already-signed-in person hits /login. identity.safe_next() admits only a relative path with a single leading slash - no scheme, no //, no backslash, no control characters - so the login page cannot become an open redirect. Ten checks added to tests/smoke_identity.py; the suite is 59 + 24 + 53, all green.
259 lines
11 KiB
Python
259 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, 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="/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))))
|