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 datetime import datetime, timezone
|
||||||
from pathlib import Path
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from . import feeds, queries
|
from . import auth, email_send, feeds, queries
|
||||||
from .db import connect
|
from .db import connect
|
||||||
from .filters import filter_articles, prefs_from_json
|
from .filters import filter_articles, prefs_from_json
|
||||||
from .hero import safe_to_lead
|
from .hero import safe_to_lead
|
||||||
@@ -48,6 +48,39 @@ def db_path() -> Path:
|
|||||||
return Path(os.environ.get("GOODNEWS_DB", str(DEFAULT_DB)))
|
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
|
@contextmanager
|
||||||
def get_conn():
|
def get_conn():
|
||||||
conn = connect(db_path())
|
conn = connect(db_path())
|
||||||
@@ -201,6 +234,25 @@ class SourcePreview(BaseModel):
|
|||||||
examples_rejected: list[RejectedExample]
|
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 --------------------------------------------------------------------
|
# --- App --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +267,7 @@ def create_app() -> FastAPI:
|
|||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
allow_methods=["GET"],
|
allow_methods=["GET", "POST"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,6 +282,64 @@ def create_app() -> FastAPI:
|
|||||||
scored = 0
|
scored = 0
|
||||||
return {"status": "ok", "scored_articles": scored}
|
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)
|
@app.get("/api/categories", response_model=CategoriesResponse)
|
||||||
def categories() -> CategoriesResponse:
|
def categories() -> CategoriesResponse:
|
||||||
return 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