Skip to content

feat(auth): custom APIToken model — close ADR-0031 gaps 2-6, 8 (#77)#165

Open
b3lz3but wants to merge 4 commits intocaptainpragmatic:masterfrom
b3lz3but:feat/api-token-auth-gaps
Open

feat(auth): custom APIToken model — close ADR-0031 gaps 2-6, 8 (#77)#165
b3lz3but wants to merge 4 commits intocaptainpragmatic:masterfrom
b3lz3but:feat/api-token-auth-gaps

Conversation

@b3lz3but
Copy link
Copy Markdown
Contributor

@b3lz3but b3lz3but commented Mar 31, 2026

Summary

Replaces DRF's built-in rest_framework.authtoken.Token with a custom APIToken model that closes 7 of 8 ADR-0031 gaps (Gap 7 — web UI — is deferred to a follow-up). No new dependencies.

Gaps closed

Gap Problem Solution
2 No token expiry expires_at field; HashedTokenAuthentication rejects expired tokens
3 No usage tracking last_used_at field; updated at most every 5 minutes to avoid write-per-request
4 One token per user (OneToOneField) ForeignKey(User) — unlimited tokens per user (capped at 20)
5 Tokens stored in plaintext SHA-256 hashed via key_hash; raw key shown once at creation, never stored
6 No token name/description name + description fields for per-script/per-device labeling
8 Token scheme only (not RFC 6750) HashedTokenAuthentication accepts both Authorization: Bearer and Token

What changed

New files:

  • services/platform/apps/api/users/authentication.pyHashedTokenAuthentication DRF backend. Hashes incoming key with SHA-256 before DB lookup. Checks is_active, expires_at, and throttles last_used_at writes to 5-minute intervals. Returns "Bearer" in WWW-Authenticate header (RFC 6750).
  • services/platform/apps/users/migrations/0002_create_apitoken.py — Schema migration for the APIToken model (table users_api_tokens).
  • services/platform/apps/users/migrations/0003_migrate_drf_tokens.py — Data migration that copies existing authtoken_token rows into APIToken with SHA-256 hashed keys, preserving created timestamps. Reversible (deletes migrated rows on rollback).
  • services/platform/apps/users/management/commands/purge_expired_tokens.py — Management command to delete tokens where expires_at < now(). Intended for cron/scheduled task.
  • services/platform/tests/api/test_api_token_auth.py — 28 new tests covering: model methods (generate_key, hash_key, is_expired), auth backend (valid/invalid/expired tokens, Bearer+Token schemes, last_used_at throttle, inactive user), obtain_token (multi-token creation, name field, per-user limit enforcement), revoke_token (only revokes authenticating token, siblings survive), token_info (returns new metadata fields).

Modified files:

  • services/platform/apps/users/models.py — Added APIToken model with: key_hash (SHA-256, unique, indexed), key_prefix (first 8 chars for identification), name, description, expires_at, last_used_at, created_at, MAX_TOKENS_PER_USER = 20. Static methods generate_key() and hash_key(). Property is_expired.
  • services/platform/apps/api/users/views.pyobtain_token: now creates a new APIToken per call (multi-token), accepts optional name field, enforces per-user token limit (20), returns key_prefix and name in response. revoke_token and token_info: use HashedTokenAuthentication instead of DRF's TokenAuthentication, return richer metadata (token_name, key_prefix, expires_at, last_used_at).
  • services/platform/config/settings/base.py — Swapped rest_framework.authentication.TokenAuthentication for apps.api.users.authentication.HashedTokenAuthentication in DEFAULT_AUTHENTICATION_CLASSES. Kept rest_framework.authtoken in INSTALLED_APPS for migration history.
  • services/platform/tests/api/test_api_users_security.py — Updated all 23 existing token tests to use APIToken instead of DRF Token. Added _make_token() helper. All assertions updated for new model fields.
  • docs/ADRs/ADR-0031-api-token-authentication-strategy.md — Updated status to Accepted (partial — Gap 7 remaining). Replaced Decision section with implemented architecture. Updated compliance table (6 items now compliant). Added Gap Closure Log with per-gap resolution details.

Security review

Reviewed by 4-model quorum (Gemini, Qwen, Codex, Claude). Unanimous findings:

  • SHA-256 is correct for 160-bit random tokens (not passwords — KDF unnecessary)
  • No timing attack vector — hash-then-index-lookup, no byte-by-byte comparison
  • 8-char prefix safe — exposes 32 of 160 bits, leaves 128 bits computationally infeasible
  • last_used_at race harmless — atomic filter().update(), worst case two writes of ~same timestamp
  • Per-user token limit added (MAX=20) based on quorum recommendation to prevent token sprawl

Breaking changes

  • obtain_token now creates a new token per call (previously returned existing token via get_or_create). Callers that assumed idempotent token creation should be aware.
  • Response from token_info replaces token_created with created_at, adds token_name, key_prefix, expires_at, last_used_at.

Migration notes

  • Data migration 0003 copies existing DRF tokens to APIToken with hashed keys — existing consumers continue working without re-authenticating.
  • rest_framework.authtoken stays in INSTALLED_APPS for migration history. Can be removed after confirming authtoken_token table is empty.
  • Portal service is unaffected (uses HMAC, not token auth).

Test plan

  • 51 token-related tests pass (28 new + 23 updated existing)
  • 154 total tests pass (full suite excluding pre-existing import failures)
  • All pre-commit hooks pass (ruff, mypy, i18n, security, credentials, performance)
  • DCO sign-off present
  • Verify python manage.py migrate applies cleanly on staging DB
  • Verify existing token consumers can still authenticate after data migration
  • Run purge_expired_tokens management command on staging

🤖 Generated with Claude Code

Closes #77

…xpiry (captainpragmatic#77)

Replace DRF's built-in authtoken with a custom APIToken model that closes
ADR-0031 gaps 2-6 and 8: SHA-256 hashed storage, multiple tokens per user,
optional expiry, last_used_at tracking, token naming, and RFC 6750 Bearer
scheme support. Includes data migration from existing DRF tokens, per-user
token limit (20), and purge_expired_tokens management command.

Closes captainpragmatic#77

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciprian Radulescu <craps2003@gmail.com>
@b3lz3but
Copy link
Copy Markdown
Contributor Author

@mostlyvirtual — requesting your review on this one.

This PR closes 7 of 8 ADR-0031 token authentication gaps (Gap 7 web UI is a follow-up). It replaces DRF's built-in authtoken with a custom APIToken model — no new dependencies.

Key implementation decisions worth reviewing:

  1. SHA-256 for token hashing (not bcrypt/argon2) — tokens are 160-bit random, not user-chosen passwords. SHA-256 is preimage-resistant at this entropy. Validated by 4-model quorum (Gemini, Qwen, Codex, Claude — all agreed).

  2. Per-user token limit of 20 — added based on quorum recommendation to prevent token sprawl. Enforced in obtain_token view before APIToken.objects.create().

  3. last_used_at write throttle (5 min) — avoids a DB write per authenticated request. Uses atomic filter().update() so concurrent requests are harmless.

  4. Data migration from DRF tokens — existing tokens are SHA-256 hashed and copied to APIToken. Consumers keep working without re-authenticating. Reversible.

  5. Breaking change in obtain_token — each call now creates a NEW token (was get_or_create returning the same one). This is intentional for multi-token support (Gap 4).

Files to focus on:

  • apps/api/users/authentication.py — the auth backend (~70 lines)
  • apps/users/models.pyAPIToken model at the bottom (~90 lines)
  • apps/api/users/views.py — updated obtain_token, revoke_token, token_info

The audit coverage regression test requires every model to either have
audit signals or be explicitly allowlisted. APIToken lifecycle is tracked
via authentication endpoint logs, not model-level signals.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciprian Radulescu <craps2003@gmail.com>
@b3lz3but
Copy link
Copy Markdown
Contributor Author

b3lz3but commented Apr 1, 2026

CI status update: The platform-test failure is the pre-existing flaky test_get_monthly_summary test (infrastructure cost service). This same test also fails intermittently on master — see run 23623420673. It's a timezone/date-boundary issue in the test fixture, unrelated to this PR.

All checks introduced or affected by this PR pass:

  • ✅ DCO sign-off
  • ✅ lint-and-quality (ruff, pre-commit)
  • ✅ integration-test
  • ✅ GitGuardian security
  • ✅ Audit model coverage (fixed in second commit — added APIToken to allowlist)

b3lz3but and others added 2 commits April 1, 2026 11:34
test_get_monthly_summary was flaky near month-end because timezone.now()
returns UTC timestamps while get_monthly_summary uses make_aware() with
TIME_ZONE=Europe/Bucharest (UTC+2/+3). Near month boundaries, the UTC
record period_end could exceed the Bucharest-aware query window, causing
the filter to miss the record. Fixed by using deterministic dates via
make_aware() to match the service's own date construction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciprian Radulescu <craps2003@gmail.com>
DRF's BaseAuthentication is typed as Any in stubs, causing mypy [misc]
error when subclassing. Same pattern used elsewhere in the codebase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Ciprian Radulescu <craps2003@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(platform-api): close ADR-0031 token authentication gaps — expiry, hashing, multi-token, web UI

1 participant