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:
@@ -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",
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user