No description
  • Python 90.4%
  • HTML 9.2%
  • Mako 0.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
drorganvidez 4a13c1b702 License under LGPL-3.0-or-later
The feature had no license, so legally nobody could use, change or share
it, which is what the marketplace exists for. It now carries the license of
the SPLENT catalog, LGPL-3.0-or-later. A product that uses it can carry any
license of its own, and changes to the feature itself stay open.
COPYING.LESSER and COPYING hold the texts and pyproject.toml declares them,
so the next release takes them to PyPI.
2026-10-11 11:47:23 +02:00
src A token response is never cached, and the token list heading is one translatable phrase 2026-10-09 21:11:20 +02:00
.gitignore fix: ship the compiled translations, so a released feature is actually translated 2026-07-30 18:15:07 +02:00
COPYING License under LGPL-3.0-or-later 2026-10-11 11:47:23 +02:00
COPYING.LESSER License under LGPL-3.0-or-later 2026-10-11 11:47:23 +02:00
MANIFEST.in feat: initial implementation of splent_feature_apikeys 2026-07-27 01:09:13 +02:00
pyproject.toml License under LGPL-3.0-or-later 2026-10-11 11:47:23 +02:00
README.md Token throttle shared across workers, tokens removed with their account, a clearer token page 2026-10-09 18:36:58 +02:00

splent_feature_apikeys

Developer API tokens for a SPLENT product. A port of the UVLHub apikeys feature to SPLENT conventions, and the only place in a product where a password is exchanged for a credential a machine can carry.

It owns tokens and nothing else. It does not know what the tokens are used for, and it never touches the auth feature: credentials are checked through the AuthenticationService that auth already registers.

The model

ApiToken belongs to one user and keeps:

Column Meaning
user_id Owner, foreign key to auth's user.id
name Human label chosen by the owner
token_hash sha256 of the plaintext, hex. The plaintext is never stored
prefix First 15 characters of the plaintext, indexed, safe to show
scopes Comma separated, as in UVLHub
created_at, expires_at, revoked_at, last_used_at lifecycle

A plaintext token looks like splent_<64 hex characters>. The splent_ label makes a leaked token greppable; the prefix makes verification a single indexed lookup instead of a table scan.

Scopes

read_spl and write_spl. Unknown names are dropped, and a request that asks for nothing but unknown names is refused rather than silently producing a token that can do nothing.

The service

ApiTokenService, registered under that name in the service locator.

api_token_service = service_proxy("ApiTokenService")

row, plaintext = api_token_service.mint(user, "laptop", ["write_spl"], ttl_days=90)
api_token_service.verify(plaintext)       # row, or None if unknown/revoked/expired
api_token_service.find(plaintext)         # row whatever its state, to explain a refusal
api_token_service.revoke(row.id, user)    # only the owner can revoke
api_token_service.touch(row)              # last_used_at, at most once every 5 minutes
api_token_service.authenticate(email, password)  # (user, None) or (None, reason)

mint returns the plaintext once. Nothing else in the product can ever produce it again.

Guarding a route

from splent_io.splent_feature_apikeys.decorators import require_token

@some_bp.route("/api/v1/spls", methods=["POST"])
@require_token("write_spl")
def publish():
    g.api_user   # the owner
    g.api_token  # the presenting token

require_token() with no scope accepts any usable token. For routes where a token is one of several ways in, call resolve_token(scope) directly: it returns (api_token, None) or (None, (response, status)).

Endpoints

Method Path Auth
POST /api/v1/auth/tokens email + password
GET /api/v1/auth/whoami Bearer token
DELETE /api/v1/auth/tokens/current Bearer token
curl -X POST https://<product>/api/v1/auth/tokens \
     -H "Content-Type: application/json" \
     -d '{"email": "dev@example.com", "password": "...", "name": "laptop"}'

curl https://<product>/api/v1/auth/whoami \
     -H "Authorization: Bearer splent_..."

Omitting ttl_days gives the product default; sending it as null gives a token that never expires.

Failed credential attempts are throttled, and a refusal is a 429 with a Retry-After header and the code rate_limited. Three budgets are counted. One address against every account and one address against one account are checked before the password is, and one account from every address together only after wrong credentials, so the right password is never refused because a stranger made noise. The counts live in a table of the product's database that auth owns (auth_throttle_failure), so every gunicorn worker and every container shares them. Until auth's migration has created that table each worker counts on its own and says so in the log. An account is counted however its address is spelled. The database matches addresses under its collation, so dëveloper@example.com or a full-width spelling reaches the same account, and both budgets are keyed on the account it resolves to, never on the typed text. The budget of one address against every account stops a caller spraying one password over many accounts. A token does not clear it, and it sits far above what an office or a campus behind one address gets wrong honestly.

Users are created inactive and an administrator activates them, so an account still waiting is answered with 403 and a message that says exactly that, never with "wrong password".

Pages

  • /developer/tokens — the owner's tokens, by prefix, with a revoke button.
  • /developer/tokens/new — create one; the plaintext is shown once.

A link appears in the authenticated sidebar (layout.authenticated_sidebar hook).

When an account is deleted

A token acts as its owner, so it goes with the owner. auth sends user-deleting from the database session just before an account is deleted, whoever deletes it (the accounts screen, the admin panel's generic CRUD, the terminal), and this feature deletes that account's tokens through the same session, in the same transaction. See signals.py.

Configuration

Variable Default Meaning
APIKEYS_DEFAULT_TTL_DAYS 90 Life of a token when none is asked
APIKEYS_MAX_LOGIN_ATTEMPTS 5 Failed attempts per address and account in a window
APIKEYS_MAX_ACCOUNT_ATTEMPTS 20 Failed attempts per account from every address in a window
APIKEYS_MAX_ADDRESS_ATTEMPTS 200 Failed attempts per address against every account in a window
APIKEYS_LOGIN_WINDOW_SECONDS 300 Length of that window
APIKEYS_THROTTLE_MAX_KEYS 10000 Counters one worker keeps while auth's table is missing

All of them have working defaults, so the feature needs no .env entry.

Install

This feature counts failed logins through auth's shared throttle (splent_io.splent_feature_auth.throttle and its migration auth0003_throttle), which auth v1.18.0 and earlier do not have. Pin it with an auth release that ships them, and release that auth first. With an older auth the app stops at startup on an ImportError.

splent feature:install splent-io/splent_feature_apikeys
splent db:migrate splent_feature_apikeys
splent db:upgrade

Tests

splent feature:test splent_feature_apikeys