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:
parent
8a155ef157
commit
480fd311c5
12 changed files with 644 additions and 21 deletions
40
alembic/versions/0007_users.py
Normal file
40
alembic/versions/0007_users.py
Normal 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")
|
||||||
150
app/auth.py
150
app/auth.py
|
|
@ -1,31 +1,139 @@
|
||||||
"""Bearer-token auth — single static token from CASSANDRA_TOKEN env.
|
"""Request-level authentication.
|
||||||
If the env is empty, the app runs open (LAN-only / dev mode).
|
|
||||||
Constant-time comparison via secrets.compare_digest.
|
Two paths accepted:
|
||||||
|
|
||||||
|
1. **Session cookie** (`cassandra_session`) — set by /login. Signed with
|
||||||
|
`CASSANDRA_SESSION_SECRET` via itsdangerous; carries just the user id.
|
||||||
|
On each request we deserialise, then load the User from the DB so the
|
||||||
|
tier value is always fresh.
|
||||||
|
|
||||||
|
2. **Bearer token** (`Authorization: Bearer …`) — the legacy single-user
|
||||||
|
path kept as an admin/dev escape hatch and for programmatic API access
|
||||||
|
(CLI, curl, scripts). Matches `CASSANDRA_TOKEN` if set.
|
||||||
|
|
||||||
|
If neither matches:
|
||||||
|
- HTML requests get 303 → /login
|
||||||
|
- API / curl-style requests get 401
|
||||||
|
|
||||||
|
For backwards-compat, `require_token` is an alias for `require_auth` so
|
||||||
|
existing routers that do `dependencies=[Depends(require_token)]` keep
|
||||||
|
working without edit.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from fastapi import Header, HTTPException, status
|
from fastapi import Header, HTTPException, Request, status
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
|
from app.db import get_session_factory
|
||||||
|
from app.models import User
|
||||||
|
from app.services.auth_service import get_user
|
||||||
|
|
||||||
|
|
||||||
async def require_token(
|
SESSION_COOKIE_NAME = "cassandra_session"
|
||||||
|
SESSION_TTL_SECONDS = 14 * 24 * 60 * 60 # 14 days
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CurrentUser:
|
||||||
|
"""The authenticated principal for the current request.
|
||||||
|
|
||||||
|
`user` is None when the bearer token was used (admin/dev path with no
|
||||||
|
matching DB row). Routes that need per-user scoping should check
|
||||||
|
`is_admin` and either use a sentinel admin scope or 403."""
|
||||||
|
is_admin: bool
|
||||||
|
user: User | None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> int | None:
|
||||||
|
return self.user.id if self.user else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def email(self) -> str | None:
|
||||||
|
return self.user.email if self.user else None
|
||||||
|
|
||||||
|
|
||||||
|
def _serializer() -> URLSafeTimedSerializer:
|
||||||
|
s = get_settings()
|
||||||
|
secret = s.CASSANDRA_SESSION_SECRET or s.CASSANDRA_TOKEN or "dev-insecure-secret"
|
||||||
|
return URLSafeTimedSerializer(secret, salt="cassandra-session-v1")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_session(user_id: int) -> str:
|
||||||
|
return _serializer().dumps({"uid": int(user_id)})
|
||||||
|
|
||||||
|
|
||||||
|
def verify_session(cookie: str) -> int | None:
|
||||||
|
try:
|
||||||
|
data = _serializer().loads(cookie, max_age=SESSION_TTL_SECONDS)
|
||||||
|
return int(data["uid"])
|
||||||
|
except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _wants_html(request: Request) -> bool:
|
||||||
|
accept = request.headers.get("accept", "").lower()
|
||||||
|
# Treat a missing Accept header as HTML for browser navigations.
|
||||||
|
if not accept:
|
||||||
|
return True
|
||||||
|
return "text/html" in accept and "application/json" not in accept
|
||||||
|
|
||||||
|
|
||||||
|
async def require_auth(
|
||||||
|
request: Request,
|
||||||
authorization: str | None = Header(default=None),
|
authorization: str | None = Header(default=None),
|
||||||
) -> None:
|
) -> CurrentUser:
|
||||||
expected = get_settings().CASSANDRA_TOKEN
|
"""Resolve the current authenticated principal. Raises HTTPException
|
||||||
if not expected:
|
on failure (303 redirect to /login for HTML, 401 for API)."""
|
||||||
return # open mode — no auth required
|
s = get_settings()
|
||||||
if not authorization or not authorization.lower().startswith("bearer "):
|
|
||||||
raise HTTPException(
|
# --- 1) Bearer token (admin / dev / scripts) ---
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
if s.CASSANDRA_TOKEN and authorization and authorization.lower().startswith("bearer "):
|
||||||
detail="Bearer token required",
|
provided = authorization.split(" ", 1)[1].strip()
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
if secrets.compare_digest(provided.encode(), s.CASSANDRA_TOKEN.encode()):
|
||||||
)
|
principal = CurrentUser(is_admin=True, user=None)
|
||||||
provided = authorization.split(" ", 1)[1].strip()
|
request.state.current_user = principal
|
||||||
if not secrets.compare_digest(provided.encode(), expected.encode()):
|
return principal
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
# --- 2) Session cookie (browser) ---
|
||||||
detail="Invalid token",
|
cookie = request.cookies.get(SESSION_COOKIE_NAME)
|
||||||
)
|
if cookie:
|
||||||
|
uid = verify_session(cookie)
|
||||||
|
if uid is not None:
|
||||||
|
async with get_session_factory()() as db_session:
|
||||||
|
user = await get_user(db_session, uid)
|
||||||
|
if user is not None:
|
||||||
|
principal = CurrentUser(is_admin=False, user=user)
|
||||||
|
request.state.current_user = principal
|
||||||
|
return principal
|
||||||
|
|
||||||
|
# --- 3) Unauthenticated ---
|
||||||
|
if _wants_html(request):
|
||||||
|
# Preserve the originally-requested path so /login can redirect back.
|
||||||
|
path = request.url.path
|
||||||
|
if request.url.query:
|
||||||
|
path += "?" + request.url.query
|
||||||
|
return _raise_redirect_to_login(next_path=path)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_redirect_to_login(next_path: str = "/") -> None:
|
||||||
|
# Some pages (login itself) are paths a redirect loop would be silly
|
||||||
|
# to send back to. The auth router opts out of this dependency
|
||||||
|
# entirely, so we don't need to filter here.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER,
|
||||||
|
detail="Login required",
|
||||||
|
headers={"Location": f"/login?next={next_path}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Backwards compatibility: every existing router uses Depends(require_token).
|
||||||
|
require_token = require_auth
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,13 @@ class Settings(BaseSettings):
|
||||||
# App
|
# App
|
||||||
CASSANDRA_TOKEN: str = ""
|
CASSANDRA_TOKEN: str = ""
|
||||||
CASSANDRA_PORT: int = 8000
|
CASSANDRA_PORT: int = 8000
|
||||||
|
# Signing key for session cookies. Generate with:
|
||||||
|
# python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
# Falls back to CASSANDRA_TOKEN if unset (acceptable for single-host dev).
|
||||||
|
CASSANDRA_SESSION_SECRET: str = ""
|
||||||
|
# Set to false (or 0/no) to disable /signup after the first account is
|
||||||
|
# created. Phase A leaves this open so the operator can self-onboard.
|
||||||
|
CASSANDRA_SIGNUP_ENABLED: bool = True
|
||||||
CASSANDRA_BASE_CURRENCY: str = "GBP"
|
CASSANDRA_BASE_CURRENCY: str = "GBP"
|
||||||
CASSANDRA_ANCHOR_DATE: str = ""
|
CASSANDRA_ANCHOR_DATE: str = ""
|
||||||
CASSANDRA_MOCK: bool = False
|
CASSANDRA_MOCK: bool = False
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ from app.config import get_settings
|
||||||
from app.db import get_session_factory
|
from app.db import get_session_factory
|
||||||
from app.logging import configure_logging, get_logger
|
from app.logging import configure_logging, get_logger
|
||||||
from app.routers import api as api_router
|
from app.routers import api as api_router
|
||||||
|
from app.routers import auth as auth_router
|
||||||
from app.routers import pages as pages_router
|
from app.routers import pages as pages_router
|
||||||
from app.services.feeds_bootstrap import bootstrap_feeds
|
from app.services.feeds_bootstrap import bootstrap_feeds
|
||||||
|
|
||||||
|
|
@ -65,5 +66,6 @@ app.mount(
|
||||||
name="static",
|
name="static",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.include_router(auth_router.router, tags=["auth"])
|
||||||
app.include_router(api_router.router, prefix="/api", tags=["api"])
|
app.include_router(api_router.router, prefix="/api", tags=["api"])
|
||||||
app.include_router(pages_router.router, tags=["pages"])
|
app.include_router(pages_router.router, tags=["pages"])
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,23 @@ class Position(Base):
|
||||||
snapshot: Mapped[PortfolioSnapshot] = relationship(back_populates="positions")
|
snapshot: Mapped[PortfolioSnapshot] = relationship(back_populates="positions")
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""A multi-user account. Phase A wires login + session cookies; phase C
|
||||||
|
adds owner_user_id FKs across portfolios/snapshots/positions so data
|
||||||
|
becomes properly tenant-scoped."""
|
||||||
|
__tablename__ = "users"
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
tier: Mapped[str] = mapped_column(String(16), default="free") # free | paid | enterprise
|
||||||
|
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
settings_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("email", name="uq_users_email"),)
|
||||||
|
|
||||||
|
|
||||||
class InstrumentMap(Base):
|
class InstrumentMap(Base):
|
||||||
"""Maps T212's tickers/shortnames to Yahoo Finance tickers so we can
|
"""Maps T212's tickers/shortnames to Yahoo Finance tickers so we can
|
||||||
refresh prices via Yahoo after a user uploads a T212 pie CSV.
|
refresh prices via Yahoo after a user uploads a T212 pie CSV.
|
||||||
|
|
|
||||||
131
app/routers/auth.py
Normal file
131
app/routers/auth.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""Authentication routes: /login, /signup, /logout.
|
||||||
|
|
||||||
|
These do NOT depend on require_auth (they're how you become authenticated).
|
||||||
|
The router is included separately in app/main.py without a router-level
|
||||||
|
auth dependency.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth import SESSION_COOKIE_NAME, SESSION_TTL_SECONDS, sign_session
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.db import get_session
|
||||||
|
from app.services.auth_service import AuthError, authenticate, create_user
|
||||||
|
from app.templates_env import templates
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_next(next_value: str | None) -> str:
|
||||||
|
"""Only allow same-origin relative paths to prevent open-redirect."""
|
||||||
|
if not next_value or not next_value.startswith("/") or next_value.startswith("//"):
|
||||||
|
return "/"
|
||||||
|
# Block any embedded scheme or host.
|
||||||
|
if urlparse(next_value).netloc:
|
||||||
|
return "/"
|
||||||
|
return next_value
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(response: RedirectResponse, user_id: int) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=SESSION_COOKIE_NAME,
|
||||||
|
value=sign_session(user_id),
|
||||||
|
max_age=SESSION_TTL_SECONDS,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
# `secure=True` requires HTTPS; the operator should enable this in
|
||||||
|
# production via a reverse proxy. Off for local-dev convenience.
|
||||||
|
secure=False,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request, next: str | None = None, error: str | None = None):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "login.html",
|
||||||
|
{"next_path": _safe_next(next), "error": error,
|
||||||
|
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login_submit(
|
||||||
|
request: Request,
|
||||||
|
email: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
next: str | None = Form(default=None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
user = await authenticate(session, email, password)
|
||||||
|
except AuthError as e:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "login.html",
|
||||||
|
{"next_path": _safe_next(next), "error": str(e),
|
||||||
|
"email": email,
|
||||||
|
"signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
target = _safe_next(next)
|
||||||
|
resp = RedirectResponse(url=target, status_code=303)
|
||||||
|
_set_session_cookie(resp, user.id)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/signup", response_class=HTMLResponse)
|
||||||
|
async def signup_page(request: Request, error: str | None = None):
|
||||||
|
s = get_settings()
|
||||||
|
if not s.CASSANDRA_SIGNUP_ENABLED:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "login.html",
|
||||||
|
{"next_path": "/", "error": "Sign-ups are currently disabled. Ask the operator.",
|
||||||
|
"signup_enabled": False},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "signup.html",
|
||||||
|
{"error": error},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/signup")
|
||||||
|
async def signup_submit(
|
||||||
|
request: Request,
|
||||||
|
email: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
s = get_settings()
|
||||||
|
if not s.CASSANDRA_SIGNUP_ENABLED:
|
||||||
|
return RedirectResponse(url="/login", status_code=303)
|
||||||
|
try:
|
||||||
|
user = await create_user(session, email, password)
|
||||||
|
except AuthError as e:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request, "signup.html",
|
||||||
|
{"error": str(e), "email": email},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
resp = RedirectResponse(url="/", status_code=303)
|
||||||
|
_set_session_cookie(resp, user.id)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
resp = RedirectResponse(url="/login", status_code=303)
|
||||||
|
resp.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout_get(request: Request):
|
||||||
|
# Convenience for users who click a logout link rather than POSTing.
|
||||||
|
return await logout(request)
|
||||||
130
app/services/auth_service.py
Normal file
130
app/services/auth_service.py
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
"""User authentication primitives: password hashing, signup, login.
|
||||||
|
|
||||||
|
Argon2id for password hashing (argon2-cffi). itsdangerous for signed
|
||||||
|
session cookies. Tier-aware user creation; phase D adds the actual
|
||||||
|
tier-based feature gating.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError, InvalidHashError
|
||||||
|
from email_validator import EmailNotValidError, validate_email
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import utcnow
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
# Argon2 default parameters are sensible; we let it pick.
|
||||||
|
_HASHER = PasswordHasher()
|
||||||
|
|
||||||
|
# Reasonable floor. Real password policy lives in Phase E.
|
||||||
|
MIN_PASSWORD_LENGTH = 8
|
||||||
|
MAX_PASSWORD_LENGTH = 256
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
"""Raised when signup/login validation fails. The message is safe to
|
||||||
|
surface to the user as-is."""
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
return _HASHER.hash(plain)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
_HASHER.verify(hashed, plain)
|
||||||
|
return True
|
||||||
|
except (VerifyMismatchError, InvalidHashError):
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_email_or_raise(email: str) -> str:
|
||||||
|
try:
|
||||||
|
info = validate_email(email, check_deliverability=False)
|
||||||
|
return info.normalized
|
||||||
|
except EmailNotValidError as e:
|
||||||
|
raise AuthError(f"Invalid email: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_password_or_raise(password: str) -> None:
|
||||||
|
if not isinstance(password, str):
|
||||||
|
raise AuthError("Password must be a string")
|
||||||
|
if len(password) < MIN_PASSWORD_LENGTH:
|
||||||
|
raise AuthError(
|
||||||
|
f"Password must be at least {MIN_PASSWORD_LENGTH} characters"
|
||||||
|
)
|
||||||
|
if len(password) > MAX_PASSWORD_LENGTH:
|
||||||
|
raise AuthError("Password too long")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
*,
|
||||||
|
tier: str = "free",
|
||||||
|
) -> User:
|
||||||
|
"""Create a new user. Raises AuthError on bad input or duplicate email."""
|
||||||
|
email = _validate_email_or_raise(email).lower()
|
||||||
|
_validate_password_or_raise(password)
|
||||||
|
|
||||||
|
existing = (await session.execute(
|
||||||
|
select(User).where(User.email == email)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
raise AuthError("An account with this email already exists")
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email=email,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
tier=tier,
|
||||||
|
email_verified=False, # phase E enforces verification
|
||||||
|
settings_json={},
|
||||||
|
created_at=utcnow(),
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def authenticate(
|
||||||
|
session: AsyncSession,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
) -> User:
|
||||||
|
"""Return the User if credentials match. Raises AuthError on miss.
|
||||||
|
|
||||||
|
Uses the same generic message for unknown-email and wrong-password to
|
||||||
|
avoid a username-enumeration oracle."""
|
||||||
|
email = email.strip().lower()
|
||||||
|
user = (await session.execute(
|
||||||
|
select(User).where(User.email == email)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
|
# Always run a hash verification even on unknown-email to keep timing
|
||||||
|
# similar (mitigates timing-based user enumeration).
|
||||||
|
if user is None:
|
||||||
|
verify_password(password, "$argon2id$v=19$m=65536,t=3,p=4$" + "a" * 22 + "$" + "b" * 43)
|
||||||
|
raise AuthError("Invalid email or password")
|
||||||
|
|
||||||
|
if not verify_password(password, user.password_hash):
|
||||||
|
raise AuthError("Invalid email or password")
|
||||||
|
|
||||||
|
user.last_login_at = utcnow()
|
||||||
|
await session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user(session: AsyncSession, user_id: int) -> User | None:
|
||||||
|
return (await session.execute(
|
||||||
|
select(User).where(User.id == user_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
|
@ -591,6 +591,104 @@ table.dense tr.row-stale td { color: var(--dim); }
|
||||||
.log-meta__row { display: flex; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; }
|
.log-meta__row { display: flex; flex-wrap: wrap; align-items: center; gap: 0; margin-top: 6px; }
|
||||||
.log-meta__row--dim { color: var(--dim); font-size: 10.5px; }
|
.log-meta__row--dim { color: var(--dim); font-size: 10.5px; }
|
||||||
|
|
||||||
|
/* --- Auth pages (login / signup, standalone — no app chrome) -------- */
|
||||||
|
|
||||||
|
.auth-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.auth-card {
|
||||||
|
width: 360px;
|
||||||
|
max-width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 28px 26px;
|
||||||
|
}
|
||||||
|
.auth-card__brand {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.auth-card__brand::before { content: "▰ "; opacity: 0.6; }
|
||||||
|
.auth-card__hint {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin: 2px 0 18px;
|
||||||
|
}
|
||||||
|
.auth-card form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.auth-card label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.auth-card input[type="email"], .auth-card input[type="password"] {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.auth-card input:focus { border-color: var(--accent); }
|
||||||
|
.auth-card button {
|
||||||
|
margin-top: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.auth-card button:hover { background: var(--accent); color: var(--bg); }
|
||||||
|
.auth-card__alt {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.auth-error {
|
||||||
|
border-left: 3px solid var(--negative);
|
||||||
|
background: color-mix(in srgb, var(--negative) 6%, transparent);
|
||||||
|
color: var(--negative);
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User chip in header */
|
||||||
|
.user-chip {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-left: 8px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.user-chip a {
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px dotted var(--muted);
|
||||||
|
}
|
||||||
|
.user-chip a:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
/* --- Upload page (drag-drop CSV) ------------------------------------- */
|
/* --- Upload page (drag-drop CSV) ------------------------------------- */
|
||||||
|
|
||||||
.dz {
|
.dz {
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@
|
||||||
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
|
onclick="(function(){var d=document.documentElement;var t=d.dataset.theme==='light'?'dark':'light';d.dataset.theme=t;try{localStorage.setItem('cassandra.theme',t);}catch(e){}})()">
|
||||||
<span class="theme-toggle__label"></span>
|
<span class="theme-toggle__label"></span>
|
||||||
</button>
|
</button>
|
||||||
|
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
|
||||||
|
{% if cu and cu.user %}
|
||||||
|
<span class="user-chip">{{ cu.user.email }} · <a href="/logout">logout</a></span>
|
||||||
|
{% elif cu and cu.is_admin %}
|
||||||
|
<span class="user-chip">admin · <a href="/logout">logout</a></span>
|
||||||
|
{% endif %}
|
||||||
<span class="meta">v0.1 · UTC</span>
|
<span class="meta">v0.1 · UTC</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
42
app/templates/login.html
Normal file
42
app/templates/login.html
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Cassandra · Login</title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; }
|
||||||
|
catch (e) { document.documentElement.dataset.theme = 'dark'; }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-shell">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-card__brand">Cassandra</div>
|
||||||
|
<div class="auth-card__hint">log in to access the dashboard</div>
|
||||||
|
|
||||||
|
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/login" autocomplete="on">
|
||||||
|
<input type="hidden" name="next" value="{{ next_path or '/' }}">
|
||||||
|
<label>Email
|
||||||
|
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
||||||
|
</label>
|
||||||
|
<label>Password
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Sign in</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if signup_enabled %}
|
||||||
|
<div class="auth-card__alt">
|
||||||
|
No account? <a href="/signup">Create one →</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
app/templates/signup.html
Normal file
39
app/templates/signup.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Cassandra · Sign up</title>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
try { document.documentElement.dataset.theme = localStorage.getItem('cassandra.theme') || 'dark'; }
|
||||||
|
catch (e) { document.documentElement.dataset.theme = 'dark'; }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', path='/css/cassandra.css') }}" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="auth-shell">
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-card__brand">Cassandra</div>
|
||||||
|
<div class="auth-card__hint">create an account</div>
|
||||||
|
|
||||||
|
{% if error %}<div class="auth-error">{{ error }}</div>{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/signup" autocomplete="on">
|
||||||
|
<label>Email
|
||||||
|
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
|
||||||
|
</label>
|
||||||
|
<label>Password (min 8 characters)
|
||||||
|
<input type="password" name="password" minlength="8" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Create account</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="auth-card__alt">
|
||||||
|
Already have an account? <a href="/login">Sign in →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -17,6 +17,9 @@ dependencies = [
|
||||||
"apscheduler>=3.10",
|
"apscheduler>=3.10",
|
||||||
"tenacity>=9.0",
|
"tenacity>=9.0",
|
||||||
"structlog>=24.4",
|
"structlog>=24.4",
|
||||||
|
"argon2-cffi>=23.1",
|
||||||
|
"itsdangerous>=2.2",
|
||||||
|
"email-validator>=2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue