feat(auth): jti claim and token-revocation store

Stateless JWTs had no revocation path: a stolen token stayed valid for
its full 24h even after the victim changed their password, and there was
no logout. This lays the foundation for revoking them.

- User.tokens_valid_from: per-user bulk-revocation cutoff (compared against
  the token's iat). RevokedToken(jti PK, exp): single-token denylist, pruned
  opportunistically on insert so it never outgrows live-but-revoked tokens.
- login() now mints a jti; create_access_token already stamps iat/exp.
- repo.revoke_token / is_token_revoked / set_tokens_valid_from (abstract +
  shared sqlmodel impl + DummyRepo coverage stubs).
- Centralized validate path in dependencies.py: every auth dependency now
  resolves the user and fails closed on (1) missing jti (legacy/pre-deploy
  token -> one forced re-login), (2) iat before the cutoff, (3) a denylisted
  jti. Denylist lookups ride a 10s membership cache mirroring the user cache.
- Contract/fuzz harness seeds its fixed-uuid principal under
  DECNET_CONTRACT_TEST so its minted token resolves to a live admin user.
This commit is contained in:
2026-05-30 18:18:41 -04:00
parent fdb6507c6f
commit 698ecaa322
11 changed files with 392 additions and 39 deletions

View File

@@ -38,6 +38,7 @@ from .auth import (
GlobalMutationIntervalRequest,
LoginRequest,
ResetUserPasswordRequest,
RevokedToken,
Token,
UpdateUserRoleRequest,
User,
@@ -254,6 +255,7 @@ __all__ = [
"GlobalMutationIntervalRequest",
"LoginRequest",
"ResetUserPasswordRequest",
"RevokedToken",
"Token",
"UpdateUserRoleRequest",
"User",

View File

@@ -1,6 +1,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Auth + user-management tables and DTOs."""
from typing import List, Literal
from datetime import datetime, timezone
from typing import List, Literal, Optional
from pydantic import BaseModel, Field as PydanticField
from sqlmodel import Field, SQLModel
@@ -13,6 +14,25 @@ class User(SQLModel, table=True):
password_hash: str
role: str = Field(default="viewer")
must_change_password: bool = Field(default=False)
# Bulk session-revocation cutoff: any token whose ``iat`` predates this
# instant is rejected. Bumped to "now" on password change, role change,
# and admin password reset. NULL means no bulk revocation has occurred.
tokens_valid_from: Optional[datetime] = Field(default=None)
class RevokedToken(SQLModel, table=True):
"""A single JWT explicitly revoked via logout, keyed on its ``jti``.
This denylist holds only explicitly-revoked, not-yet-expired tokens, so it
stays tiny — ``revoke_token`` opportunistically prunes rows past expiry on
every insert. Bulk "log out everywhere" events use ``User.tokens_valid_from``
instead, because there is no per-user registry of live ``jti``s to enumerate.
"""
__tablename__ = "revoked_tokens"
jti: str = Field(primary_key=True)
user_uuid: str = Field(index=True) # User.uuid; no FK (independent audit row)
expires_at: datetime = Field(index=True) # token exp; row is prunable past this
revoked_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
# --- API Request/Response Models (Pydantic) ---

View File

@@ -114,6 +114,25 @@ class BaseRepository(ABC):
"""Update a user's role."""
pass
@abstractmethod
async def revoke_token(self, jti: str, user_uuid: str, expires_at: datetime) -> None:
"""Add a token's ``jti`` to the logout denylist.
Implementations also prune rows whose ``expires_at`` has passed, so the
denylist never outgrows the set of live-but-revoked tokens.
"""
pass
@abstractmethod
async def is_token_revoked(self, jti: str) -> bool:
"""True if ``jti`` is currently on the logout denylist."""
pass
@abstractmethod
async def set_tokens_valid_from(self, user_uuid: str, ts: datetime) -> None:
"""Bulk-revoke: reject every token for this user issued before ``ts``."""
pass
@abstractmethod
async def purge_logs_and_bounties(self) -> dict[str, int]:
"""Delete all logs, bounties, and attacker profiles. Returns counts of deleted rows."""

View File

@@ -14,6 +14,7 @@ from __future__ import annotations
import asyncio
import json
import os
import orjson
import uuid
@@ -57,6 +58,11 @@ from decnet.web.db.sqlmodel_repo.tarpit import TarpitMixin
from decnet.web.db.sqlmodel_repo.ttp import TTPMixin
from decnet.web.db.sqlmodel_repo.webhooks import WebhooksMixin
# Fixed principal the schemathesis contract harness mints its token for; seeded
# only under DECNET_CONTRACT_TEST (see _ensure_contract_user). Kept in sync with
# tests/api/test_schemathesis.py.
CONTRACT_TEST_USER_UUID = "00000000-0000-0000-0000-000000000001"
class SQLModelRepository(
AttackerIntelMixin,
@@ -105,6 +111,7 @@ class SQLModelRepository(
async with self.engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
await self._ensure_admin_user()
await self._ensure_contract_user()
async def reinitialize(self) -> None:
"""Re-create schema (for tests / reset flows). Does NOT drop existing tables."""
@@ -112,6 +119,7 @@ class SQLModelRepository(
async with self.engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
await self._ensure_admin_user()
await self._ensure_contract_user()
async def _ensure_admin_user(self) -> None:
async with self._session() as session:
@@ -137,6 +145,28 @@ class SQLModelRepository(
session.add(existing)
await session.commit()
async def _ensure_contract_user(self) -> None:
"""Seed the fixed-uuid principal the schemathesis contract/fuzz harness
authenticates as. Gated on DECNET_CONTRACT_TEST so it NEVER runs in a
real deployment. Since the post-revocation auth path now requires the
token's user to exist (and not be revoked), the harness's locally-minted
fixed-uuid token must resolve to a live, admin, non-revoked user. The
password hash is random and unusable, so /auth/login can never
authenticate as this principal — only the minted token works."""
if os.environ.get("DECNET_CONTRACT_TEST") != "true":
return
async with self._session() as session:
if await session.get(User, CONTRACT_TEST_USER_UUID) is not None:
return
session.add(User(
uuid=CONTRACT_TEST_USER_UUID,
username="contract-test",
password_hash=get_password_hash(uuid.uuid4().hex),
role="admin",
must_change_password=False,
))
await session.commit()
async def _migrate_attackers_table(self) -> None:
"""Legacy-schema cleanup. Override per dialect (DDL introspection is non-portable)."""
return None

View File

@@ -2,11 +2,12 @@
"""User CRUD."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import select, update
from sqlalchemy import delete, select, update
from decnet.web.db.models import User
from decnet.web.db.models import RevokedToken, User
from decnet.web.db.sqlmodel_repo._helpers import _MixinBase
@@ -75,3 +76,29 @@ class AuthMixin(_MixinBase):
update(User).where(User.uuid == uuid).values(role=role)
)
await session.commit()
async def revoke_token(self, jti: str, user_uuid: str, expires_at: datetime) -> None:
async with self._session() as session:
# Opportunistic prune — the denylist only needs unexpired tokens, so
# purge stale rows on every insert instead of a separate vacuum job.
await session.execute(
delete(RevokedToken).where(
RevokedToken.expires_at < datetime.now(timezone.utc)
)
)
if await session.get(RevokedToken, jti) is None:
session.add(
RevokedToken(jti=jti, user_uuid=user_uuid, expires_at=expires_at)
)
await session.commit()
async def is_token_revoked(self, jti: str) -> bool:
async with self._session() as session:
return await session.get(RevokedToken, jti) is not None
async def set_tokens_valid_from(self, user_uuid: str, ts: datetime) -> None:
async with self._session() as session:
await session.execute(
update(User).where(User.uuid == user_uuid).values(tokens_valid_from=ts)
)
await session.commit()