Replaces the static bearer-token gate with a real auth boundary. The existing CASSANDRA_TOKEN path is retained as an admin / scripting escape hatch — kept compatible by aliasing require_token to require_auth. - New users table (migration 0007): email, argon2 password_hash, tier, email_verified (declared but not enforced until phase E), settings_json for the tone/analysis/anchor knobs we'll wire in phase D. - app/services/auth_service.py: argon2-cffi password hashing with timing- attack-resistant authenticate() (always runs a hash verify even on unknown-email to deny a username-enumeration oracle). - app/auth.py rewritten: require_auth returns a CurrentUser with either is_admin=True (bearer path) or a User object (session path). Failing requests get 303 → /login for HTML, 401 for API. Sessions signed with itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL. - app/routers/auth.py: /login, /signup, /logout. Login form preserves the ?next=… param for redirect-after-login. Signup respects a new CASSANDRA_SIGNUP_ENABLED flag. - Standalone /login + /signup templates (no app chrome). base.html grows a user chip + logout link in the header (reads request.state.current_user). Phase A's main known limitations are documented in the plan: email verification is declared but not enforced; session revocation is best-effort (cookie-only, not DB-backed). Both land in phase E. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.1 KiB
Python
131 lines
4.1 KiB
Python
"""Authentication routes: /login, /signup, /logout.
|
|
|
|
These do NOT depend on require_auth (they're how you become authenticated).
|
|
The router is included separately in app/main.py without a router-level
|
|
auth dependency.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import APIRouter, Depends, Form, Request
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import SESSION_COOKIE_NAME, SESSION_TTL_SECONDS, sign_session
|
|
from app.config import get_settings
|
|
from app.db import get_session
|
|
from app.services.auth_service import AuthError, authenticate, create_user
|
|
from app.templates_env import templates
|
|
|
|
|
|
router = APIRouter(tags=["auth"])
|
|
|
|
|
|
def _safe_next(next_value: str | None) -> str:
|
|
"""Only allow same-origin relative paths to prevent open-redirect."""
|
|
if not next_value or not next_value.startswith("/") or next_value.startswith("//"):
|
|
return "/"
|
|
# Block any embedded scheme or host.
|
|
if urlparse(next_value).netloc:
|
|
return "/"
|
|
return next_value
|
|
|
|
|
|
def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
|
|
response.set_cookie(
|
|
key=SESSION_COOKIE_NAME,
|
|
value=sign_session(user_id),
|
|
max_age=SESSION_TTL_SECONDS,
|
|
httponly=True,
|
|
samesite="lax",
|
|
# `secure=True` requires HTTPS; the operator should enable this in
|
|
# production via a reverse proxy. Off for local-dev convenience.
|
|
secure=False,
|
|
path="/",
|
|
)
|
|
|
|
|
|
@router.get("/login", response_class=HTMLResponse)
|
|
async def login_page(request: Request, next: str | None = None, error: str | None = None):
|
|
return templates.TemplateResponse(
|
|
request, "login.html",
|
|
{"next_path": _safe_next(next), "error": error,
|
|
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
|
)
|
|
|
|
|
|
@router.post("/login")
|
|
async def login_submit(
|
|
request: Request,
|
|
email: str = Form(...),
|
|
password: str = Form(...),
|
|
next: str | None = Form(default=None),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
try:
|
|
user = await authenticate(session, email, password)
|
|
except AuthError as e:
|
|
return templates.TemplateResponse(
|
|
request, "login.html",
|
|
{"next_path": _safe_next(next), "error": str(e),
|
|
"email": email,
|
|
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
|
status_code=400,
|
|
)
|
|
target = _safe_next(next)
|
|
resp = RedirectResponse(url=target, status_code=303)
|
|
_set_session_cookie(resp, user.id)
|
|
return resp
|
|
|
|
|
|
@router.get("/signup", response_class=HTMLResponse)
|
|
async def signup_page(request: Request, error: str | None = None):
|
|
s = get_settings()
|
|
if not s.CASSANDRA_SIGNUP_ENABLED:
|
|
return templates.TemplateResponse(
|
|
request, "login.html",
|
|
{"next_path": "/", "error": "Sign-ups are currently disabled. Ask the operator.",
|
|
"signup_enabled": False},
|
|
status_code=403,
|
|
)
|
|
return templates.TemplateResponse(
|
|
request, "signup.html",
|
|
{"error": error},
|
|
)
|
|
|
|
|
|
@router.post("/signup")
|
|
async def signup_submit(
|
|
request: Request,
|
|
email: str = Form(...),
|
|
password: str = Form(...),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
s = get_settings()
|
|
if not s.CASSANDRA_SIGNUP_ENABLED:
|
|
return RedirectResponse(url="/login", status_code=303)
|
|
try:
|
|
user = await create_user(session, email, password)
|
|
except AuthError as e:
|
|
return templates.TemplateResponse(
|
|
request, "signup.html",
|
|
{"error": str(e), "email": email},
|
|
status_code=400,
|
|
)
|
|
resp = RedirectResponse(url="/", status_code=303)
|
|
_set_session_cookie(resp, user.id)
|
|
return resp
|
|
|
|
|
|
@router.post("/logout")
|
|
async def logout(request: Request):
|
|
resp = RedirectResponse(url="/login", status_code=303)
|
|
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
|
return resp
|
|
|
|
|
|
@router.get("/logout")
|
|
async def logout_get(request: Request):
|
|
# Convenience for users who click a logout link rather than POSTing.
|
|
return await logout(request)
|