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(
|
||||
|
||||
Reference in New Issue
Block a user