Accounts Phase 1b: magic-link auth endpoints + sessions

- POST /api/auth/email/start — validate email, rate-limit, email a single-use
  magic link (identical reply regardless, so no account enumeration).
- POST /api/auth/email/verify — consume token, find-or-create user, open a
  session, set an httpOnly cookie (web) and return a bearer token (app).
- GET /api/auth/me, POST /api/auth/logout.
- Session resolved from cookie OR Authorization: Bearer; cookie is Secure in
  prod (https), relaxed for http so tests round-trip. CORS now allows POST.

Live SMTP send verified against the DNSExit relay (587/STARTTLS). 108 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 01:08:33 +00:00
parent 6a514aa56b
commit d2ae56dc65
2 changed files with 182 additions and 3 deletions
+113 -3
View File
@@ -22,12 +22,12 @@ from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query
from fastapi import FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import feeds, queries
from . import auth, email_send, feeds, queries
from .db import connect
from .filters import filter_articles, prefs_from_json
from .hero import safe_to_lead
@@ -48,6 +48,39 @@ def db_path() -> Path:
return Path(os.environ.get("GOODNEWS_DB", str(DEFAULT_DB)))
# --- Auth helpers -----------------------------------------------------------
PUBLIC_BASE_URL = os.environ.get("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com").rstrip("/")
SESSION_COOKIE = "ub_session"
SESSION_MAX_AGE = int(auth.SESSION_TTL.total_seconds())
# Secure cookies in production (https); off for http (local/test) so they round-trip.
_COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https")
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def _session_token_from_request(request: Request) -> str | None:
"""Web sends the session as an httpOnly cookie; the app sends a bearer token."""
cookie = request.cookies.get(SESSION_COOKIE)
if cookie:
return cookie
authz = request.headers.get("Authorization", "")
return authz[7:].strip() if authz.startswith("Bearer ") else None
def _current_user(conn: sqlite3.Connection, request: Request) -> sqlite3.Row | None:
user = auth.resolve_session(conn, _session_token_from_request(request))
if user:
conn.commit() # persist the last_seen touch
return user
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
SESSION_COOKIE, token, max_age=SESSION_MAX_AGE,
httponly=True, secure=_COOKIE_SECURE, samesite="lax", path="/",
)
@contextmanager
def get_conn():
conn = connect(db_path())
@@ -201,6 +234,25 @@ class SourcePreview(BaseModel):
examples_rejected: list[RejectedExample]
class EmailStartRequest(BaseModel):
email: str
class TokenVerifyRequest(BaseModel):
token: str
class UserOut(BaseModel):
id: int
email: str
display_name: str | None = None
class SessionOut(BaseModel):
user: UserOut
token: str # for non-browser (app) clients; the web SPA uses the cookie
# --- App --------------------------------------------------------------------
@@ -215,7 +267,7 @@ def create_app() -> FastAPI:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
@@ -230,6 +282,64 @@ def create_app() -> FastAPI:
scored = 0
return {"status": "ok", "scored_articles": scored}
# --- Auth: passwordless magic link (Google added in Phase 2) ----------
@app.post("/api/auth/email/start")
def auth_email_start(body: EmailStartRequest) -> dict:
email = auth.normalize_email(body.email)
if not _EMAIL_RE.match(email):
raise HTTPException(status_code=422, detail="Please enter a valid email address.")
with get_conn() as conn:
# Light abuse guard: cap recent tokens per address (still reply OK).
recent = conn.execute(
"SELECT COUNT(*) FROM login_tokens WHERE email = ? "
"AND created_at > datetime('now', '-10 minutes')",
(email,),
).fetchone()[0]
if recent < 5:
raw = auth.create_login_token(conn, email)
conn.commit()
link = f"{PUBLIC_BASE_URL}/auth/verify?token={raw}"
try:
email_send.send_magic_link(email, link)
except Exception:
pass # never leak send failures or whether the address exists
# Always identical (no account enumeration).
return {"ok": True}
@app.post("/api/auth/email/verify", response_model=SessionOut)
def auth_email_verify(body: TokenVerifyRequest, request: Request, response: Response) -> SessionOut:
with get_conn() as conn:
email = auth.consume_login_token(conn, body.token)
if not email:
conn.commit()
raise HTTPException(status_code=400, detail="This sign-in link is invalid or has expired.")
user_id = auth.find_or_create_user(conn, email, "email", email)
token = auth.create_session(conn, user_id, user_agent=request.headers.get("User-Agent"))
conn.commit()
user = auth.get_user(conn, user_id)
_set_session_cookie(response, token)
return SessionOut(
user=UserOut(id=user["id"], email=user["email"], display_name=user["display_name"]),
token=token,
)
@app.get("/api/auth/me", response_model=UserOut | None)
def auth_me(request: Request) -> UserOut | None:
with get_conn() as conn:
user = _current_user(conn, request)
if not user:
return None
return UserOut(id=user["id"], email=user["email"], display_name=user["display_name"])
@app.post("/api/auth/logout")
def auth_logout(request: Request, response: Response) -> dict:
with get_conn() as conn:
auth.revoke_session(conn, _session_token_from_request(request))
conn.commit()
response.delete_cookie(SESSION_COOKIE, path="/")
return {"ok": True}
@app.get("/api/categories", response_model=CategoriesResponse)
def categories() -> CategoriesResponse:
return CategoriesResponse(
+69
View File
@@ -0,0 +1,69 @@
import os
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client(tmp_path, monkeypatch):
db = tmp_path / "t.sqlite3"
monkeypatch.setenv("GOODNEWS_DB", str(db))
# http base → cookies aren't Secure-only, so the TestClient round-trips them
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "http://testserver")
import importlib
import goodnews.api as api
importlib.reload(api)
from goodnews.db import connect, init_db
c = connect(str(db)); init_db(c); c.close()
return api.create_app(), api
def test_magic_link_end_to_end(client, monkeypatch):
app, api = client
sent = {}
# capture the link instead of sending real email
monkeypatch.setattr(api.email_send, "send_magic_link", lambda to, link: sent.update(to=to, link=link))
tc = TestClient(app)
# not signed in
assert tc.get("/api/auth/me").json() is None
# request a link
r = tc.post("/api/auth/email/start", json={"email": "Jay@Example.com"})
assert r.status_code == 200 and r.json() == {"ok": True}
assert sent["to"] == "jay@example.com"
token = sent["link"].split("token=")[1]
# verify → session established (cookie set), user created
r = tc.post("/api/auth/email/verify", json={"token": token})
assert r.status_code == 200
body = r.json()
assert body["user"]["email"] == "jay@example.com" and body["token"]
# cookie now authenticates /me
me = tc.get("/api/auth/me").json()
assert me and me["email"] == "jay@example.com"
# bearer token also works (app clients)
bare = TestClient(app)
me2 = bare.get("/api/auth/me", headers={"Authorization": f"Bearer {body['token']}"}).json()
assert me2["email"] == "jay@example.com"
# reusing the magic link fails (single use)
assert tc.post("/api/auth/email/verify", json={"token": token}).status_code == 400
# logout clears the session
assert tc.post("/api/auth/logout").json() == {"ok": True}
assert tc.get("/api/auth/me").json() is None
def test_start_rejects_bad_email(client):
app, _ = client
tc = TestClient(app)
assert tc.post("/api/auth/email/start", json={"email": "nope"}).status_code == 422
def test_verify_bad_token(client):
app, _ = client
tc = TestClient(app)
assert tc.post("/api/auth/email/verify", json={"token": "garbage"}).status_code == 400