phase A: user accounts + session-cookie auth
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>
This commit is contained in:
parent
8a155ef157
commit
480fd311c5
12 changed files with 644 additions and 21 deletions
131
app/routers/auth.py
Normal file
131
app/routers/auth.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue