diff --git a/goodnews/auth.py b/goodnews/auth.py new file mode 100644 index 0000000..3ee5d76 --- /dev/null +++ b/goodnews/auth.py @@ -0,0 +1,154 @@ +"""Account auth core: users, identities, magic-link tokens, sessions. + +Stdlib-only helpers over a sqlite3 connection. The caller (the API) supplies the +connection and commits. Self-hosted, minimal-PII. Secrets are random and stored +only as SHA-256 hashes — the raw token is shown exactly once (in the magic link +or returned to the client as a session token). + +Identity model: a user can have several `identities` (email magic-link, google, +later apple). They resolve to ONE user by verified email, so signing in with +Google and with a magic link to the same address lands on the same account. +""" + +from __future__ import annotations + +import hashlib +import secrets +import sqlite3 +from datetime import datetime, timedelta, timezone + +LOGIN_TOKEN_TTL = timedelta(minutes=15) +SESSION_TTL = timedelta(days=60) + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(dt: datetime) -> str: + return dt.replace(microsecond=0).isoformat() + + +def _parse(value: str) -> datetime: + dt = datetime.fromisoformat(value) + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + + +def _hash(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def normalize_email(email: str) -> str: + return (email or "").strip().lower() + + +# ---- users & identities ------------------------------------------------------ + +def get_user(conn: sqlite3.Connection, user_id: int) -> sqlite3.Row | None: + return conn.execute( + "SELECT id, email, display_name, created_at FROM users WHERE id = ?", (user_id,) + ).fetchone() + + +def find_or_create_user( + conn: sqlite3.Connection, + email: str, + provider: str, + provider_subject: str, + display_name: str | None = None, +) -> int: + """Resolve (or create) the user for a verified sign-in, linking the identity. + + Order: an existing identity wins; else an existing user with the same verified + email is linked to the new identity; else a fresh user is created. + """ + email = normalize_email(email) + existing = conn.execute( + "SELECT user_id FROM identities WHERE provider = ? AND provider_subject = ?", + (provider, provider_subject), + ).fetchone() + if existing: + return existing["user_id"] + + user = conn.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone() + if user: + user_id = user["id"] + if display_name: + conn.execute( + "UPDATE users SET display_name = COALESCE(display_name, ?), " + "updated_at = CURRENT_TIMESTAMP WHERE id = ?", + (display_name, user_id), + ) + else: + user_id = conn.execute( + "INSERT INTO users (email, display_name) VALUES (?, ?)", (email, display_name) + ).lastrowid + + conn.execute( + "INSERT OR IGNORE INTO identities (user_id, provider, provider_subject) VALUES (?, ?, ?)", + (user_id, provider, provider_subject), + ) + return user_id + + +# ---- magic-link tokens ------------------------------------------------------- + +def create_login_token(conn: sqlite3.Connection, email: str) -> str: + """Create a single-use sign-in token; return the raw token (goes in the link).""" + raw = secrets.token_urlsafe(32) + conn.execute( + "INSERT INTO login_tokens (email, token_hash, expires_at) VALUES (?, ?, ?)", + (normalize_email(email), _hash(raw), _iso(_now() + LOGIN_TOKEN_TTL)), + ) + return raw + + +def consume_login_token(conn: sqlite3.Connection, raw: str) -> str | None: + """Return the email for a valid, unused, unexpired token, marking it used. + + Returns None (and changes nothing) if the token is unknown, already used, or + expired. Single-use even under concurrent taps (the consumed_at guard). + """ + if not raw: + return None + row = conn.execute( + "SELECT id, email, expires_at, consumed_at FROM login_tokens WHERE token_hash = ?", + (_hash(raw),), + ).fetchone() + if not row or row["consumed_at"] is not None or _parse(row["expires_at"]) < _now(): + return None + consumed = conn.execute( + "UPDATE login_tokens SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL", + (_iso(_now()), row["id"]), + ).rowcount + return row["email"] if consumed else None + + +# ---- sessions ---------------------------------------------------------------- + +def create_session(conn: sqlite3.Connection, user_id: int, user_agent: str | None = None) -> str: + """Create a session; return the raw token (cookie value / bearer token).""" + raw = secrets.token_urlsafe(32) + conn.execute( + "INSERT INTO sessions (user_id, token_hash, expires_at, user_agent) VALUES (?, ?, ?, ?)", + (user_id, _hash(raw), _iso(_now() + SESSION_TTL), (user_agent or "")[:300]), + ) + return raw + + +def resolve_session(conn: sqlite3.Connection, raw: str | None) -> sqlite3.Row | None: + """Return the user for a valid session token (and refresh last_seen), else None.""" + if not raw: + return None + row = conn.execute( + "SELECT id, user_id, expires_at FROM sessions WHERE token_hash = ?", (_hash(raw),) + ).fetchone() + if not row or _parse(row["expires_at"]) < _now(): + return None + conn.execute("UPDATE sessions SET last_seen_at = ? WHERE id = ?", (_iso(_now()), row["id"])) + return get_user(conn, row["user_id"]) + + +def revoke_session(conn: sqlite3.Connection, raw: str | None) -> None: + if raw: + conn.execute("DELETE FROM sessions WHERE token_hash = ?", (_hash(raw),)) diff --git a/goodnews/db.py b/goodnews/db.py index 1c99035..97634b6 100644 --- a/goodnews/db.py +++ b/goodnews/db.py @@ -127,15 +127,90 @@ CREATE TABLE IF NOT EXISTS daily_brief_items ( PRIMARY KEY (brief_id, article_id), UNIQUE (brief_id, rank) ); + +-- ---- Accounts ---------------------------------------------------------------- +-- Self-hosted, minimal-PII. The host ingestion owns the content tables above; +-- the API owns these (writes happen via the API, so the DB runs in WAL mode). + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE, + display_name TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- One row per sign-in method linked to a user; lets Google + magic-link +-- (same verified email) resolve to a single account. +CREATE TABLE IF NOT EXISTS identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, -- 'email' | 'google' | 'apple' + provider_subject TEXT NOT NULL, -- email address, or the provider's stable user id + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (provider, provider_subject) +); +CREATE INDEX IF NOT EXISTS idx_identities_user ON identities(user_id); + +-- Single-use, short-lived magic-link tokens (stored hashed). +CREATE TABLE IF NOT EXISTS login_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + consumed_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_login_tokens_email ON login_tokens(email); + +-- Active sessions (opaque token stored hashed); validated for cookie or bearer. +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TEXT NOT NULL, + user_agent TEXT +); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + +CREATE TABLE IF NOT EXISTS saved_articles ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + saved_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, article_id) +); + +CREATE TABLE IF NOT EXISTS user_history ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE, + event TEXT NOT NULL DEFAULT 'seen', -- 'seen' | 'dismissed' + at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, article_id, event) +); + +CREATE TABLE IF NOT EXISTS user_prefs ( + user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + prefs_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); """ def connect(db_path: Path | str) -> sqlite3.Connection: path = Path(db_path) path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(path) + conn = sqlite3.connect(path, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys = ON") + # WAL lets the API write account data while the ingestion cycle writes content + # concurrently (readers never block the writer). busy_timeout rides out the + # brief moments the single writer lock is held. Both are no-ops if already set. + conn.execute("PRAGMA busy_timeout = 5000") + if str(path) != ":memory:": + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") return conn diff --git a/goodnews/email_send.py b/goodnews/email_send.py new file mode 100644 index 0000000..09a4be2 --- /dev/null +++ b/goodnews/email_send.py @@ -0,0 +1,74 @@ +"""Minimal transactional email over SMTP (magic-link sign-in). + +Config comes from the environment (GOODNEWS_SMTP_*), supplied to the API +container via its env_file. STARTTLS submission; plain-text with a simple HTML +alternative. No third-party email API — just the configured relay. +""" + +from __future__ import annotations + +import os +import smtplib +import ssl +from email.message import EmailMessage +from html import escape + + +def smtp_configured() -> bool: + return bool(os.environ.get("GOODNEWS_SMTP_HOST")) + + +def _cfg() -> dict: + return { + "host": os.environ.get("GOODNEWS_SMTP_HOST", ""), + "port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")), + "user": os.environ.get("GOODNEWS_SMTP_USER", ""), + "password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""), + "sender": os.environ.get("GOODNEWS_SMTP_FROM", "Upbeat Bytes "), + } + + +def send_email(to: str, subject: str, text: str, html: str | None = None) -> None: + """Send one message. Raises on failure (caller decides how loud to be).""" + cfg = _cfg() + if not cfg["host"]: + raise RuntimeError("SMTP not configured (set GOODNEWS_SMTP_HOST)") + msg = EmailMessage() + msg["From"] = cfg["sender"] + msg["To"] = to + msg["Subject"] = subject + msg.set_content(text) + if html: + msg.add_alternative(html, subtype="html") + context = ssl.create_default_context() + with smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) as server: + server.ehlo() + server.starttls(context=context) + server.ehlo() + if cfg["user"]: + server.login(cfg["user"], cfg["password"]) + server.send_message(msg) + + +def send_magic_link(to: str, link: str) -> None: + """Send a calm, single-purpose sign-in email.""" + subject = "Your Upbeat Bytes sign-in link" + text = ( + "Welcome back to Upbeat Bytes.\n\n" + f"Tap to sign in:\n{link}\n\n" + "This link works once and expires in 15 minutes.\n" + "If you didn't request it, you can safely ignore this email." + ) + safe = escape(link, quote=True) + html = ( + '
' + "

Welcome back to Upbeat Bytes.

" + f'

' + "Sign in

" + '

This link works once and expires in 15 minutes. ' + "If you didn't request it, you can safely ignore this email.

" + "
" + ) + send_email(to, subject, text, html) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..d1ca21e --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,71 @@ +import sqlite3 +from datetime import timedelta + +from goodnews import auth +from goodnews.db import connect, init_db + + +def _db(): + c = connect(":memory:") + init_db(c) + # an article to save/history against later flows + c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)") + c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash) " + "VALUES (1,1,'http://s/1','t1','h1')") + c.commit() + return c + + +def test_normalize_email(): + assert auth.normalize_email(" Foo@Bar.COM ") == "foo@bar.com" + + +def test_find_or_create_links_by_email_and_dedupes_identity(): + c = _db() + uid = auth.find_or_create_user(c, "a@b.com", "email", "a@b.com") + # same identity again → same user, no duplicate + assert auth.find_or_create_user(c, "a@b.com", "email", "a@b.com") == uid + # a different provider with the SAME verified email links to the same user + uid2 = auth.find_or_create_user(c, "A@B.com", "google", "google-sub-123", display_name="A") + assert uid2 == uid + assert c.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 1 + assert c.execute("SELECT COUNT(*) FROM identities WHERE user_id=?", (uid,)).fetchone()[0] == 2 + assert auth.get_user(c, uid)["display_name"] == "A" + + +def test_magic_link_token_single_use(): + c = _db() + raw = auth.create_login_token(c, "a@b.com") + assert auth.consume_login_token(c, raw) == "a@b.com" # first use works + assert auth.consume_login_token(c, raw) is None # reuse rejected + assert auth.consume_login_token(c, "nonsense") is None # unknown rejected + + +def test_magic_link_token_expiry(): + c = _db() + raw = auth.create_login_token(c, "a@b.com") + # backdate expiry into the past + c.execute("UPDATE login_tokens SET expires_at = ?", + (auth._iso(auth._now() - timedelta(minutes=1)),)) + assert auth.consume_login_token(c, raw) is None + + +def test_session_lifecycle(): + c = _db() + uid = auth.find_or_create_user(c, "a@b.com", "email", "a@b.com") + tok = auth.create_session(c, uid, user_agent="pytest") + user = auth.resolve_session(c, tok) + assert user and user["id"] == uid and user["email"] == "a@b.com" + assert auth.resolve_session(c, None) is None + assert auth.resolve_session(c, "bogus") is None + auth.revoke_session(c, tok) + assert auth.resolve_session(c, tok) is None + + +def test_session_expiry(): + c = _db() + uid = auth.find_or_create_user(c, "a@b.com", "email", "a@b.com") + tok = auth.create_session(c, uid) + c.execute("UPDATE sessions SET expires_at = ?", + (auth._iso(auth._now() - timedelta(seconds=1)),)) + assert auth.resolve_session(c, tok) is None