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:
+113
-3
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user