feat(auth): custom APIToken model — close ADR-0031 gaps 2-6, 8 (#77)#165
feat(auth): custom APIToken model — close ADR-0031 gaps 2-6, 8 (#77)#165b3lz3but wants to merge 4 commits intocaptainpragmatic:masterfrom
Conversation
…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>
|
@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 Key implementation decisions worth reviewing:
Files to focus on:
|
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>
|
CI status update: The All checks introduced or affected by this PR pass:
|
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>
Summary
Replaces DRF's built-in
rest_framework.authtoken.Tokenwith a customAPITokenmodel that closes 7 of 8 ADR-0031 gaps (Gap 7 — web UI — is deferred to a follow-up). No new dependencies.Gaps closed
expires_atfield;HashedTokenAuthenticationrejects expired tokenslast_used_atfield; updated at most every 5 minutes to avoid write-per-requestForeignKey(User)— unlimited tokens per user (capped at 20)key_hash; raw key shown once at creation, never storedname+descriptionfields for per-script/per-device labelingTokenscheme only (not RFC 6750)HashedTokenAuthenticationaccepts bothAuthorization: BearerandTokenWhat changed
New files:
services/platform/apps/api/users/authentication.py—HashedTokenAuthenticationDRF backend. Hashes incoming key with SHA-256 before DB lookup. Checksis_active,expires_at, and throttleslast_used_atwrites to 5-minute intervals. Returns"Bearer"inWWW-Authenticateheader (RFC 6750).services/platform/apps/users/migrations/0002_create_apitoken.py— Schema migration for theAPITokenmodel (tableusers_api_tokens).services/platform/apps/users/migrations/0003_migrate_drf_tokens.py— Data migration that copies existingauthtoken_tokenrows intoAPITokenwith SHA-256 hashed keys, preservingcreatedtimestamps. Reversible (deletes migrated rows on rollback).services/platform/apps/users/management/commands/purge_expired_tokens.py— Management command to delete tokens whereexpires_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_atthrottle, 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— AddedAPITokenmodel 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 methodsgenerate_key()andhash_key(). Propertyis_expired.services/platform/apps/api/users/views.py—obtain_token: now creates a newAPITokenper call (multi-token), accepts optionalnamefield, enforces per-user token limit (20), returnskey_prefixandnamein response.revoke_tokenandtoken_info: useHashedTokenAuthenticationinstead of DRF'sTokenAuthentication, return richer metadata (token_name,key_prefix,expires_at,last_used_at).services/platform/config/settings/base.py— Swappedrest_framework.authentication.TokenAuthenticationforapps.api.users.authentication.HashedTokenAuthenticationinDEFAULT_AUTHENTICATION_CLASSES. Keptrest_framework.authtokeninINSTALLED_APPSfor migration history.services/platform/tests/api/test_api_users_security.py— Updated all 23 existing token tests to useAPITokeninstead of DRFToken. Added_make_token()helper. All assertions updated for new model fields.docs/ADRs/ADR-0031-api-token-authentication-strategy.md— Updated status toAccepted (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:
last_used_atrace harmless — atomicfilter().update(), worst case two writes of ~same timestampBreaking changes
obtain_tokennow creates a new token per call (previously returned existing token viaget_or_create). Callers that assumed idempotent token creation should be aware.token_inforeplacestoken_createdwithcreated_at, addstoken_name,key_prefix,expires_at,last_used_at.Migration notes
0003copies existing DRF tokens toAPITokenwith hashed keys — existing consumers continue working without re-authenticating.rest_framework.authtokenstays inINSTALLED_APPSfor migration history. Can be removed after confirmingauthtoken_tokentable is empty.Test plan
python manage.py migrateapplies cleanly on staging DBpurge_expired_tokensmanagement command on staging🤖 Generated with Claude Code
Closes #77