phase A: user accounts + session-cookie auth

Replaces the static bearer-token gate with a real auth boundary. The
existing CASSANDRA_TOKEN path is retained as an admin / scripting escape
hatch — kept compatible by aliasing require_token to require_auth.

- New users table (migration 0007): email, argon2 password_hash, tier,
  email_verified (declared but not enforced until phase E), settings_json
  for the tone/analysis/anchor knobs we'll wire in phase D.
- app/services/auth_service.py: argon2-cffi password hashing with timing-
  attack-resistant authenticate() (always runs a hash verify even on
  unknown-email to deny a username-enumeration oracle).
- app/auth.py rewritten: require_auth returns a CurrentUser with either
  is_admin=True (bearer path) or a User object (session path). Failing
  requests get 303 → /login for HTML, 401 for API. Sessions signed with
  itsdangerous against CASSANDRA_SESSION_SECRET; 14-day TTL.
- app/routers/auth.py: /login, /signup, /logout. Login form preserves the
  ?next=… param for redirect-after-login. Signup respects a new
  CASSANDRA_SIGNUP_ENABLED flag.
- Standalone /login + /signup templates (no app chrome). base.html grows
  a user chip + logout link in the header (reads request.state.current_user).

Phase A's main known limitations are documented in the plan: email
verification is declared but not enforced; session revocation is
best-effort (cookie-only, not DB-backed). Both land in phase E.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-16 11:12:10 +01:00
parent 8a155ef157
commit 480fd311c5
12 changed files with 644 additions and 21 deletions

View file

@ -0,0 +1,40 @@
"""users table — accounts, password hashing (argon2), tier, settings
Phase A of the multi-user migration. Adds the table but doesn't add owner
FKs to existing rows yet that's phase C. Until then, data is still
effectively shared across the (small) set of authenticated accounts.
Revision ID: 0007
Revises: 0006
Create Date: 2026-05-16
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0007"
down_revision: Union[str, None] = "0006"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("tier", sa.String(16), nullable=False, server_default="free"),
# Not enforced in phase A — wired up in phase E.
sa.Column("email_verified", sa.Boolean, nullable=False, server_default=sa.text("0")),
sa.Column("settings_json", sa.JSON), # per-user tone/analysis/anchor/...
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_login_at", sa.DateTime(timezone=True)),
sa.UniqueConstraint("email", name="uq_users_email"),
)
def downgrade() -> None:
op.drop_table("users")