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.
402 lines
19 KiB
Python
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
|