""" 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 "
{intro}
{err}{inner}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('Changing your own details is not built yet. Ask an administrator.
""") return HTMLResponse(_render("Your account", body.replace( "