Skip to content

Add personal API tokens for users - #4466

Open
dkehne wants to merge 3 commits into
developfrom
feature/api-user-tokens
Open

Add personal API tokens for users#4466
dkehne wants to merge 3 commits into
developfrom
feature/api-user-tokens

Conversation

@dkehne

@dkehne dkehne commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Short description

First step of the CRM integration (#4138): a personal API token per user, so the upcoming region settings and statistics endpoints can be authenticated without bypassing the RBAC structure.

The CMS API currently has no authentication at all — this PR adds the foundation, not yet any protected endpoint.

Per the discussion in #4138, this deliberately avoids a per-region token: the region_slug in the URL already provides the CRM↔region mapping, and a token bound to a normal user keeps that user's permissions intact.

Implementation

  • UserApiToken model (modelled on FidoKey): user FK, name, prefix, token_hash, created_at, last_usage
    • only a SHA-256 hash is stored; the plaintext is shown once after creation and is unrecoverable
    • the random prefix allows looking up a token without hashing every row; the comparison itself is constant-time via secrets.compare_digest
    • a fast hash is appropriate here because the token carries 32 bytes of entropy — a slow password hash would only add latency to every API request
  • api_token_required(permission=None) decorator in api/decorators.py: reads Authorization: Bearer <token>, resolves and validates it, rejects tokens of deactivated users, optionally enforces a permission, records last_usage and sets request.user
  • Account settings UI: new section to create tokens (name), list them (name / last usage / created at) and delete them. Deletion is a POST form with CSRF protection and is scoped to request.user.api_tokens, so nobody can delete somebody else's token.

Testing

11 tests in tests/cms/test_user_api_token.py covering: plaintext is never persisted, token lookup (valid / wrong secret / unknown prefix / malformed / empty), successful authentication + last_usage update, rejection of missing/malformed/unknown/non-bearer headers, rejection of deactivated users, and permission enforcement (unprivileged user gets 403, privileged passes).

ruff, mypy, djlint and check_translations all pass locally.

Follow-ups (separate PRs, see #4138)

  1. POST/GET /api/v3/<region_slug>/settings/ — generic region settings endpoint + read-only form for CRM-managed regions + removal of the mt_midyear_start_month logic incl. data migration
  2. /api/v3/<region_slug>/statistics/ — async webhook replacing the region-condition CSV

Part of #4138 · CRM side: digitalfabrik/customcrm#19

🤖 Generated with Claude Code

@dkehne

dkehne commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Code review

Found 3 issues, all fixed in 6c8d397:

  1. The plaintext token was passed through the messages framework, which writes it to the log. MESSAGE_STORAGE is MessageLoggerStorage, whose add() calls logger.log(level, message) whenever MESSAGE_LOGGING_ENABLED is set — it defaults to DEBUG, is forced True in circleci_settings.py, and is operator-toggleable in production via INTEGREAT_CMS_MESSAGE_LOGGING_ENABLED. The usable secret would have landed verbatim in the logs, defeating the point of storing only a hash. Now handed over via the session and rendered once in the template, the same way TOTPRegisterView passes its TOTP secret; a regression test asserts the plaintext never reaches the log.

)
messages.success(request, _("API token was successfully created"))
# The plaintext token is only available at this point — it is stored as a hash and
# can never be displayed again, so the user has to copy it now.
messages.warning(
request,
__(
_("Your new API token is: {}").format(plaintext),
_("Copy it now — it will not be shown again."),
),
)

  1. Model naming: UserApiToken reintroduced the User prefix that was deliberately dropped in Refactor MFA setup and make fallback method available #2257 ("it's also called Organization instead of UserOrganization, Role instead of UserRole") — its siblings in the same package are FidoKey, Organization and Role. Renamed to ApiToken.

class UserApiToken(AbstractBaseModel):
"""
Data model representing a personal API token of a user

  1. ApiTokenForm.__init__ declared and forwarded *args, but CustomModelForm.__init__ accepts keyword arguments only, so any positional call would raise TypeError. The docstring documented *args as supported. Reduced to **kwargs like the sibling forms.

def __init__(self, *args: object, **kwargs: object) -> None:
r"""
Store the user the token is created for so the name can be validated against their
existing tokens.
:param \*args: The supplied arguments
:param \**kwargs: The supplied keyword arguments
"""
self.user = kwargs.pop("user", None)
super().__init__(*args, **kwargs)

Also addressed: tests moved to tests/cms/models/users/ and tests/api/ to mirror the app structure, an explicit note that api_token_required does not check region membership (region-scoped endpoints must do that themselves), and corrections to the prefix-lookup rationale and token_hash help text.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@andrew8er andrew8er left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General implementation is good, but the column documentation is a bit lacking. There is also an alternative that might be preferable (regarding storage and compute).

Comment thread integreat_cms/cms/models/users/api_token.py Outdated
Comment thread integreat_cms/cms/models/users/api_token.py Outdated
Comment thread integreat_cms/cms/models/users/api_token.py Outdated
Comment thread integreat_cms/cms/models/users/api_token.py Outdated
Comment thread integreat_cms/cms/models/users/api_token.py Outdated
dkehne added a commit that referenced this pull request Aug 11, 2026
Follow-up to the review of #4466:

- store `prefix` and `token_hash` as `bytea` instead of hex encoded `varchar`,
  which halves the storage and drops the encoding step from every lookup
- document the encoding of both columns, and derive the hash length from the
  hash algorithm instead of hard-coding it
- rename `hash_token` to `_hash_token` and let it assemble the plaintext from
  prefix and secret itself, so storage and lookup cannot disagree on the format
- reject prefixes of the wrong length or with non-hex characters before the
  database is queried

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dkehne
dkehne force-pushed the feature/api-user-tokens branch from 6c8d397 to 0ebfe91 Compare August 11, 2026 19:19
@dkehne

dkehne commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the careful look at the columns, @andrew8er — all five points are in 0ebfe91:

  • prefix and token_hash are bytea now with digest() instead of hexdigest(), which halves the storage and drops the encoding step from every lookup. The only remaining hex is the prefix inside the plaintext token, since it has to travel in an Authorization header — both help texts state the encoding explicitly.
  • _hash_token(prefix, secret) assembles the plaintext itself, so storage and lookup cannot disagree on the format. As a side effect the lookup now rejects prefixes of the wrong length or with non-hex characters before touching the database.
  • The hash length comes from hashlib.sha256().digest_size rather than from the token size, which is what the old 64 accidentally matched.

Also rebased onto the current develop (the SUMM.AI removal moved the migration to 0159). Could you have another look and approve if this is what you had in mind?

dkehne and others added 3 commits August 12, 2026 08:43
Introduce a `UserApiToken` model so users can create personal API tokens
in their account settings. The tokens authenticate API requests on behalf
of their user, so endpoints inherit exactly that user's permissions
instead of bypassing the RBAC structure.

Only a SHA-256 hash of the token is stored. The plaintext is shown once
directly after creation and cannot be recovered afterwards. A random
prefix is stored alongside the hash so a token can be looked up without
hashing every row, and the comparison itself is constant-time.

The new `api_token_required` decorator reads the `Authorization: Bearer`
header, resolves and validates the token, rejects tokens of deactivated
users, optionally enforces a permission and records the last usage.

This is the authentication foundation for the CRM integration
(#4138); the region settings and statistics
endpoints follow in separate PRs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Do not pass the plaintext token through the messages framework. The
project's MESSAGE_STORAGE is MessageLoggerStorage, which logs every
message when MESSAGE_LOGGING_ENABLED is set — a setting that defaults to
DEBUG and can be switched on in production. Creating a token would
therefore have written the usable secret verbatim into the log, which
defeats storing only a hash.

The plaintext is now handed over via the session and rendered exactly
once in the template, the same way TOTPRegisterView passes its secret. A
regression test asserts the plaintext never reaches the log.

Also:
- Rename UserApiToken to ApiToken, following the convention established
  in #2257 that per-user models in cms/models/users/ carry no User
  prefix (FidoKey, Organization, Role)
- Move the tests to tests/cms/models/users/ and tests/api/ so they
  mirror the app structure
- Drop the unsupported *args from ApiTokenForm.__init__, matching the
  other forms and CustomModelForm's signature
- Document that api_token_required does not check region membership
- Correct the prefix-lookup rationale and the token_hash help text
- Update the stale UserSettingsView.post docstring

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Follow-up to the review of #4466:

- store `prefix` and `token_hash` as `bytea` instead of hex encoded `varchar`,
  which halves the storage and drops the encoding step from every lookup
- document the encoding of both columns, and derive the hash length from the
  hash algorithm instead of hard-coding it
- rename `hash_token` to `_hash_token` and let it assemble the plaintext from
  prefix and secret itself, so storage and lookup cannot disagree on the format
- reject prefixes of the wrong length or with non-hex characters before the
  database is queried

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dkehne
dkehne force-pushed the feature/api-user-tokens branch from 0ebfe91 to 37ff6a7 Compare August 12, 2026 06:45

@andrew8er andrew8er left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants