Accounts Phase 1 foundation: schema + WAL, auth core, email sender
Groundwork for self-hosted accounts (magic link + Google later), no third parties. - db: account tables (users, identities, login_tokens, sessions, saved_articles, user_history, user_prefs); identities link multiple sign-in methods to one user by verified email. connect() now enables WAL + busy_timeout so the API can write account data alongside the host ingestion cycle. - auth.py: users/identities (find-or-create + link), single-use magic-link tokens, opaque sessions — all secrets stored only as SHA-256 hashes. - email_send.py: minimal STARTTLS SMTP sender + the magic-link email. Secrets (SMTP, Google, session) live in the API container's env_file, not git. API endpoints + sign-in UI come next. 105 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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),))
|
||||||
+76
-1
@@ -127,15 +127,90 @@ CREATE TABLE IF NOT EXISTS daily_brief_items (
|
|||||||
PRIMARY KEY (brief_id, article_id),
|
PRIMARY KEY (brief_id, article_id),
|
||||||
UNIQUE (brief_id, rank)
|
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:
|
def connect(db_path: Path | str) -> sqlite3.Connection:
|
||||||
path = Path(db_path)
|
path = Path(db_path)
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
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.row_factory = sqlite3.Row
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
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
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 <hello@upbeatbytes.com>"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||||
|
'color:#16263a;line-height:1.6">'
|
||||||
|
"<p>Welcome back to <strong>Upbeat Bytes</strong>.</p>"
|
||||||
|
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
|
||||||
|
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
||||||
|
"Sign in</a></p>"
|
||||||
|
'<p style="color:#5d6b78;font-size:14px">This link works once and expires in 15 minutes. '
|
||||||
|
"If you didn't request it, you can safely ignore this email.</p>"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
send_email(to, subject, text, html)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user