splent-feature-apikeys 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- splent_feature_apikeys-0.1.1/MANIFEST.in +8 -0
- splent_feature_apikeys-0.1.1/PKG-INFO +129 -0
- splent_feature_apikeys-0.1.1/README.md +123 -0
- splent_feature_apikeys-0.1.1/pyproject.toml +75 -0
- splent_feature_apikeys-0.1.1/setup.cfg +4 -0
- splent_feature_apikeys-0.1.1/src/splent_feature_apikeys.egg-info/PKG-INFO +129 -0
- splent_feature_apikeys-0.1.1/src/splent_feature_apikeys.egg-info/SOURCES.txt +27 -0
- splent_feature_apikeys-0.1.1/src/splent_feature_apikeys.egg-info/dependency_links.txt +1 -0
- splent_feature_apikeys-0.1.1/src/splent_feature_apikeys.egg-info/top_level.txt +1 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/__init__.py +35 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/config.py +44 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/decorators.py +130 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/forms.py +42 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/hooks.py +16 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/migrations/alembic.ini +36 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/migrations/env.py +9 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/migrations/script.py.mako +24 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/migrations/versions/089274640134_splent_feature_apikeys.py +53 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/models.py +103 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/repositories.py +27 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/routes.py +322 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/services.py +180 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/templates/apikeys/created.html +48 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/templates/apikeys/index.html +97 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/templates/apikeys/new.html +62 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/templates/hooks/sidebar_tokens.html +5 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/throttle.py +191 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/translations/es/LC_MESSAGES/messages.mo +0 -0
- splent_feature_apikeys-0.1.1/src/splent_io/splent_feature_apikeys/translations/es/LC_MESSAGES/messages.po +204 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Jinja templates (feature views + hook fragments)
|
|
2
|
+
recursive-include src/splent_io/splent_feature_apikeys/templates *.html
|
|
3
|
+
|
|
4
|
+
# Alembic migration config and scripts
|
|
5
|
+
recursive-include src/splent_io/splent_feature_apikeys/migrations *.py *.ini *.mako
|
|
6
|
+
|
|
7
|
+
# Compiled translation catalogs
|
|
8
|
+
recursive-include src/splent_io/splent_feature_apikeys/translations *.mo *.po
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: splent_feature_apikeys
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Requires-Python: >=3.13
|
|
5
|
+
Description-Content-Type: text/markdown
|
|
6
|
+
|
|
7
|
+
# splent_feature_apikeys
|
|
8
|
+
|
|
9
|
+
Developer API tokens for a SPLENT product. A port of the UVLHub `apikeys`
|
|
10
|
+
feature to SPLENT conventions, and the only place in a product where a
|
|
11
|
+
password is exchanged for a credential a machine can carry.
|
|
12
|
+
|
|
13
|
+
It owns tokens and nothing else. It does not know what the tokens are used
|
|
14
|
+
for, and it never touches the `auth` feature: credentials are checked through
|
|
15
|
+
the `AuthenticationService` that auth already registers.
|
|
16
|
+
|
|
17
|
+
## The model
|
|
18
|
+
|
|
19
|
+
`ApiToken` belongs to one user and keeps:
|
|
20
|
+
|
|
21
|
+
| Column | Meaning |
|
|
22
|
+
|----------------|------------------------------------------------------------|
|
|
23
|
+
| `user_id` | Owner, foreign key to auth's `user.id` |
|
|
24
|
+
| `name` | Human label chosen by the owner |
|
|
25
|
+
| `token_hash` | sha256 of the plaintext, hex. The plaintext is never stored |
|
|
26
|
+
| `prefix` | First 15 characters of the plaintext, indexed, safe to show |
|
|
27
|
+
| `scopes` | Comma separated, as in UVLHub |
|
|
28
|
+
| `created_at`, `expires_at`, `revoked_at`, `last_used_at` | lifecycle |
|
|
29
|
+
|
|
30
|
+
A plaintext token looks like `splent_<64 hex characters>`. The `splent_`
|
|
31
|
+
label makes a leaked token greppable; the prefix makes verification a single
|
|
32
|
+
indexed lookup instead of a table scan.
|
|
33
|
+
|
|
34
|
+
## Scopes
|
|
35
|
+
|
|
36
|
+
`read_spl` and `write_spl`. Unknown names are dropped, and a request that
|
|
37
|
+
asks for nothing but unknown names is refused rather than silently producing
|
|
38
|
+
a token that can do nothing.
|
|
39
|
+
|
|
40
|
+
## The service
|
|
41
|
+
|
|
42
|
+
`ApiTokenService`, registered under that name in the service locator.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
api_token_service = service_proxy("ApiTokenService")
|
|
46
|
+
|
|
47
|
+
row, plaintext = api_token_service.mint(user, "laptop", ["write_spl"], ttl_days=90)
|
|
48
|
+
api_token_service.verify(plaintext) # row, or None if unknown/revoked/expired
|
|
49
|
+
api_token_service.find(plaintext) # row whatever its state, to explain a refusal
|
|
50
|
+
api_token_service.revoke(row.id, user) # only the owner can revoke
|
|
51
|
+
api_token_service.touch(row) # last_used_at, at most once every 5 minutes
|
|
52
|
+
api_token_service.authenticate(email, password) # (user, None) or (None, reason)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`mint` returns the plaintext once. Nothing else in the product can ever
|
|
56
|
+
produce it again.
|
|
57
|
+
|
|
58
|
+
## Guarding a route
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from splent_io.splent_feature_apikeys.decorators import require_token
|
|
62
|
+
|
|
63
|
+
@some_bp.route("/api/v1/spls", methods=["POST"])
|
|
64
|
+
@require_token("write_spl")
|
|
65
|
+
def publish():
|
|
66
|
+
g.api_user # the owner
|
|
67
|
+
g.api_token # the presenting token
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`require_token()` with no scope accepts any usable token. For routes where a
|
|
71
|
+
token is one of several ways in, call `resolve_token(scope)` directly: it
|
|
72
|
+
returns `(api_token, None)` or `(None, (response, status))`.
|
|
73
|
+
|
|
74
|
+
## Endpoints
|
|
75
|
+
|
|
76
|
+
| Method | Path | Auth |
|
|
77
|
+
|--------|---------------------------------|-------------------|
|
|
78
|
+
| POST | `/api/v1/auth/tokens` | email + password |
|
|
79
|
+
| GET | `/api/v1/auth/whoami` | Bearer token |
|
|
80
|
+
| DELETE | `/api/v1/auth/tokens/current` | Bearer token |
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
curl -X POST https://<product>/api/v1/auth/tokens \
|
|
84
|
+
-H "Content-Type: application/json" \
|
|
85
|
+
-d '{"email": "dev@example.com", "password": "...", "name": "laptop"}'
|
|
86
|
+
|
|
87
|
+
curl https://<product>/api/v1/auth/whoami \
|
|
88
|
+
-H "Authorization: Bearer splent_..."
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Omitting `ttl_days` gives the product default; sending it as `null` gives a
|
|
92
|
+
token that never expires.
|
|
93
|
+
|
|
94
|
+
Failed credential attempts are throttled per caller and account. Users are
|
|
95
|
+
created inactive and an administrator activates them, so an account still
|
|
96
|
+
waiting is answered with `403` and a message that says exactly that, never
|
|
97
|
+
with "wrong password".
|
|
98
|
+
|
|
99
|
+
## Pages
|
|
100
|
+
|
|
101
|
+
- `/developer/tokens` — the owner's tokens, by prefix, with a revoke button.
|
|
102
|
+
- `/developer/tokens/new` — create one; the plaintext is shown once.
|
|
103
|
+
|
|
104
|
+
A link appears in the authenticated sidebar
|
|
105
|
+
(`layout.authenticated_sidebar` hook).
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
| Variable | Default | Meaning |
|
|
110
|
+
|--------------------------------|---------|----------------------------------|
|
|
111
|
+
| `APIKEYS_DEFAULT_TTL_DAYS` | 90 | Life of a token when none is asked |
|
|
112
|
+
| `APIKEYS_MAX_LOGIN_ATTEMPTS` | 5 | Failed attempts per window |
|
|
113
|
+
| `APIKEYS_LOGIN_WINDOW_SECONDS` | 300 | Length of that window |
|
|
114
|
+
|
|
115
|
+
All three have working defaults, so the feature needs no `.env` entry.
|
|
116
|
+
|
|
117
|
+
## Install
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
splent feature:install splent-io/splent_feature_apikeys
|
|
121
|
+
splent db:migrate splent_feature_apikeys
|
|
122
|
+
splent db:upgrade
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Tests
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
splent feature:test splent_feature_apikeys
|
|
129
|
+
```
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# splent_feature_apikeys
|
|
2
|
+
|
|
3
|
+
Developer API tokens for a SPLENT product. A port of the UVLHub `apikeys`
|
|
4
|
+
feature to SPLENT conventions, and the only place in a product where a
|
|
5
|
+
password is exchanged for a credential a machine can carry.
|
|
6
|
+
|
|
7
|
+
It owns tokens and nothing else. It does not know what the tokens are used
|
|
8
|
+
for, and it never touches the `auth` feature: credentials are checked through
|
|
9
|
+
the `AuthenticationService` that auth already registers.
|
|
10
|
+
|
|
11
|
+
## The model
|
|
12
|
+
|
|
13
|
+
`ApiToken` belongs to one user and keeps:
|
|
14
|
+
|
|
15
|
+
| Column | Meaning |
|
|
16
|
+
|----------------|------------------------------------------------------------|
|
|
17
|
+
| `user_id` | Owner, foreign key to auth's `user.id` |
|
|
18
|
+
| `name` | Human label chosen by the owner |
|
|
19
|
+
| `token_hash` | sha256 of the plaintext, hex. The plaintext is never stored |
|
|
20
|
+
| `prefix` | First 15 characters of the plaintext, indexed, safe to show |
|
|
21
|
+
| `scopes` | Comma separated, as in UVLHub |
|
|
22
|
+
| `created_at`, `expires_at`, `revoked_at`, `last_used_at` | lifecycle |
|
|
23
|
+
|
|
24
|
+
A plaintext token looks like `splent_<64 hex characters>`. The `splent_`
|
|
25
|
+
label makes a leaked token greppable; the prefix makes verification a single
|
|
26
|
+
indexed lookup instead of a table scan.
|
|
27
|
+
|
|
28
|
+
## Scopes
|
|
29
|
+
|
|
30
|
+
`read_spl` and `write_spl`. Unknown names are dropped, and a request that
|
|
31
|
+
asks for nothing but unknown names is refused rather than silently producing
|
|
32
|
+
a token that can do nothing.
|
|
33
|
+
|
|
34
|
+
## The service
|
|
35
|
+
|
|
36
|
+
`ApiTokenService`, registered under that name in the service locator.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
api_token_service = service_proxy("ApiTokenService")
|
|
40
|
+
|
|
41
|
+
row, plaintext = api_token_service.mint(user, "laptop", ["write_spl"], ttl_days=90)
|
|
42
|
+
api_token_service.verify(plaintext) # row, or None if unknown/revoked/expired
|
|
43
|
+
api_token_service.find(plaintext) # row whatever its state, to explain a refusal
|
|
44
|
+
api_token_service.revoke(row.id, user) # only the owner can revoke
|
|
45
|
+
api_token_service.touch(row) # last_used_at, at most once every 5 minutes
|
|
46
|
+
api_token_service.authenticate(email, password) # (user, None) or (None, reason)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`mint` returns the plaintext once. Nothing else in the product can ever
|
|
50
|
+
produce it again.
|
|
51
|
+
|
|
52
|
+
## Guarding a route
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from splent_io.splent_feature_apikeys.decorators import require_token
|
|
56
|
+
|
|
57
|
+
@some_bp.route("/api/v1/spls", methods=["POST"])
|
|
58
|
+
@require_token("write_spl")
|
|
59
|
+
def publish():
|
|
60
|
+
g.api_user # the owner
|
|
61
|
+
g.api_token # the presenting token
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`require_token()` with no scope accepts any usable token. For routes where a
|
|
65
|
+
token is one of several ways in, call `resolve_token(scope)` directly: it
|
|
66
|
+
returns `(api_token, None)` or `(None, (response, status))`.
|
|
67
|
+
|
|
68
|
+
## Endpoints
|
|
69
|
+
|
|
70
|
+
| Method | Path | Auth |
|
|
71
|
+
|--------|---------------------------------|-------------------|
|
|
72
|
+
| POST | `/api/v1/auth/tokens` | email + password |
|
|
73
|
+
| GET | `/api/v1/auth/whoami` | Bearer token |
|
|
74
|
+
| DELETE | `/api/v1/auth/tokens/current` | Bearer token |
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
curl -X POST https://<product>/api/v1/auth/tokens \
|
|
78
|
+
-H "Content-Type: application/json" \
|
|
79
|
+
-d '{"email": "dev@example.com", "password": "...", "name": "laptop"}'
|
|
80
|
+
|
|
81
|
+
curl https://<product>/api/v1/auth/whoami \
|
|
82
|
+
-H "Authorization: Bearer splent_..."
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Omitting `ttl_days` gives the product default; sending it as `null` gives a
|
|
86
|
+
token that never expires.
|
|
87
|
+
|
|
88
|
+
Failed credential attempts are throttled per caller and account. Users are
|
|
89
|
+
created inactive and an administrator activates them, so an account still
|
|
90
|
+
waiting is answered with `403` and a message that says exactly that, never
|
|
91
|
+
with "wrong password".
|
|
92
|
+
|
|
93
|
+
## Pages
|
|
94
|
+
|
|
95
|
+
- `/developer/tokens` — the owner's tokens, by prefix, with a revoke button.
|
|
96
|
+
- `/developer/tokens/new` — create one; the plaintext is shown once.
|
|
97
|
+
|
|
98
|
+
A link appears in the authenticated sidebar
|
|
99
|
+
(`layout.authenticated_sidebar` hook).
|
|
100
|
+
|
|
101
|
+
## Configuration
|
|
102
|
+
|
|
103
|
+
| Variable | Default | Meaning |
|
|
104
|
+
|--------------------------------|---------|----------------------------------|
|
|
105
|
+
| `APIKEYS_DEFAULT_TTL_DAYS` | 90 | Life of a token when none is asked |
|
|
106
|
+
| `APIKEYS_MAX_LOGIN_ATTEMPTS` | 5 | Failed attempts per window |
|
|
107
|
+
| `APIKEYS_LOGIN_WINDOW_SECONDS` | 300 | Length of that window |
|
|
108
|
+
|
|
109
|
+
All three have working defaults, so the feature needs no `.env` entry.
|
|
110
|
+
|
|
111
|
+
## Install
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
splent feature:install splent-io/splent_feature_apikeys
|
|
115
|
+
splent db:migrate splent_feature_apikeys
|
|
116
|
+
splent db:upgrade
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Tests
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
splent feature:test splent_feature_apikeys
|
|
123
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80.3.1", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "splent_feature_apikeys"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
|
|
11
|
+
dependencies = [
|
|
12
|
+
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.setuptools]
|
|
16
|
+
package-dir = { "" = "src" }
|
|
17
|
+
include-package-data = true
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
exclude = ["*.tests", "*.tests.*"]
|
|
22
|
+
|
|
23
|
+
[tool.splent]
|
|
24
|
+
cli_version = "1.14.0"
|
|
25
|
+
|
|
26
|
+
# ── Feature Contract (auto-generated) ────────────────────────────────────────
|
|
27
|
+
# Do not edit manually — re-run `splent feature:contract --write` to refresh.
|
|
28
|
+
# Preserved across refreshes: description, category, tags, env, and
|
|
29
|
+
# requires.features_manual (dependencies invisible to static analysis).
|
|
30
|
+
[tool.splent.contract]
|
|
31
|
+
description = "Developer API tokens: mint, verify, revoke, and a Bearer decorator for the product's own API"
|
|
32
|
+
archetype = "full"
|
|
33
|
+
category = "security"
|
|
34
|
+
tags = ["api", "tokens", "authentication", "developer"]
|
|
35
|
+
|
|
36
|
+
[tool.splent.contract.provides]
|
|
37
|
+
routes = ["/api/v1/auth/tokens", "/api/v1/auth/tokens/current", "/api/v1/auth/whoami", "/developer/tokens", "/developer/tokens/<int:token_id>/revoke", "/developer/tokens/new"]
|
|
38
|
+
blueprints = []
|
|
39
|
+
models = ["ApiToken"]
|
|
40
|
+
commands = []
|
|
41
|
+
hooks = ["layout.authenticated_sidebar"]
|
|
42
|
+
services = ["ApiTokenService"]
|
|
43
|
+
signals = []
|
|
44
|
+
translations = []
|
|
45
|
+
docker = []
|
|
46
|
+
|
|
47
|
+
[tool.splent.contract.requires]
|
|
48
|
+
features = ["auth"]
|
|
49
|
+
env_vars = ["APIKEYS_DEFAULT_TTL_DAYS", "APIKEYS_LOGIN_WINDOW_SECONDS", "APIKEYS_MAX_ACCOUNT_ATTEMPTS", "APIKEYS_MAX_BODY_BYTES", "APIKEYS_MAX_LOGIN_ATTEMPTS", "APIKEYS_THROTTLE_MAX_KEYS"]
|
|
50
|
+
signals = []
|
|
51
|
+
|
|
52
|
+
[tool.splent.contract.extensible]
|
|
53
|
+
services = ["ApiTokenService"]
|
|
54
|
+
templates = ["apikeys/created.html", "apikeys/index.html", "apikeys/new.html", "hooks/sidebar_tokens.html"]
|
|
55
|
+
models = ["ApiToken"]
|
|
56
|
+
hooks = ["layout.authenticated_sidebar"]
|
|
57
|
+
routes = false
|
|
58
|
+
|
|
59
|
+
# ── Configuration ─────────────────────────────────────────────────────────
|
|
60
|
+
# What this feature reads from the environment, and what it falls back to.
|
|
61
|
+
[tool.splent.config]
|
|
62
|
+
# How long a key issued without an explicit expiry is good for.
|
|
63
|
+
APIKEYS_DEFAULT_TTL_DAYS = 90
|
|
64
|
+
# Rate limiting on the exchange endpoint, which takes a password. The window
|
|
65
|
+
# is per address, the account limit per account, so one address hammering
|
|
66
|
+
# many accounts and many addresses hammering one are both bounded.
|
|
67
|
+
APIKEYS_MAX_LOGIN_ATTEMPTS = 5
|
|
68
|
+
APIKEYS_LOGIN_WINDOW_SECONDS = 300
|
|
69
|
+
APIKEYS_MAX_ACCOUNT_ATTEMPTS = 20
|
|
70
|
+
# The throttle keeps its counters in memory. This caps how many it holds, so
|
|
71
|
+
# a flood of distinct addresses cannot grow it without bound.
|
|
72
|
+
APIKEYS_THROTTLE_MAX_KEYS = 10000
|
|
73
|
+
# Largest body accepted on the token endpoints. They take a short JSON
|
|
74
|
+
# object, and anything larger is not a client that means well.
|
|
75
|
+
APIKEYS_MAX_BODY_BYTES = 8192
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: splent_feature_apikeys
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Requires-Python: >=3.13
|
|
5
|
+
Description-Content-Type: text/markdown
|
|
6
|
+
|
|
7
|
+
# splent_feature_apikeys
|
|
8
|
+
|
|
9
|
+
Developer API tokens for a SPLENT product. A port of the UVLHub `apikeys`
|
|
10
|
+
feature to SPLENT conventions, and the only place in a product where a
|
|
11
|
+
password is exchanged for a credential a machine can carry.
|
|
12
|
+
|
|
13
|
+
It owns tokens and nothing else. It does not know what the tokens are used
|
|
14
|
+
for, and it never touches the `auth` feature: credentials are checked through
|
|
15
|
+
the `AuthenticationService` that auth already registers.
|
|
16
|
+
|
|
17
|
+
## The model
|
|
18
|
+
|
|
19
|
+
`ApiToken` belongs to one user and keeps:
|
|
20
|
+
|
|
21
|
+
| Column | Meaning |
|
|
22
|
+
|----------------|------------------------------------------------------------|
|
|
23
|
+
| `user_id` | Owner, foreign key to auth's `user.id` |
|
|
24
|
+
| `name` | Human label chosen by the owner |
|
|
25
|
+
| `token_hash` | sha256 of the plaintext, hex. The plaintext is never stored |
|
|
26
|
+
| `prefix` | First 15 characters of the plaintext, indexed, safe to show |
|
|
27
|
+
| `scopes` | Comma separated, as in UVLHub |
|
|
28
|
+
| `created_at`, `expires_at`, `revoked_at`, `last_used_at` | lifecycle |
|
|
29
|
+
|
|
30
|
+
A plaintext token looks like `splent_<64 hex characters>`. The `splent_`
|
|
31
|
+
label makes a leaked token greppable; the prefix makes verification a single
|
|
32
|
+
indexed lookup instead of a table scan.
|
|
33
|
+
|
|
34
|
+
## Scopes
|
|
35
|
+
|
|
36
|
+
`read_spl` and `write_spl`. Unknown names are dropped, and a request that
|
|
37
|
+
asks for nothing but unknown names is refused rather than silently producing
|
|
38
|
+
a token that can do nothing.
|
|
39
|
+
|
|
40
|
+
## The service
|
|
41
|
+
|
|
42
|
+
`ApiTokenService`, registered under that name in the service locator.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
api_token_service = service_proxy("ApiTokenService")
|
|
46
|
+
|
|
47
|
+
row, plaintext = api_token_service.mint(user, "laptop", ["write_spl"], ttl_days=90)
|
|
48
|
+
api_token_service.verify(plaintext) # row, or None if unknown/revoked/expired
|
|
49
|
+
api_token_service.find(plaintext) # row whatever its state, to explain a refusal
|
|
50
|
+
api_token_service.revoke(row.id, user) # only the owner can revoke
|
|
51
|
+
api_token_service.touch(row) # last_used_at, at most once every 5 minutes
|
|
52
|
+
api_token_service.authenticate(email, password) # (user, None) or (None, reason)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`mint` returns the plaintext once. Nothing else in the product can ever
|
|
56
|
+
produce it again.
|
|
57
|
+
|
|
58
|
+
## Guarding a route
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from splent_io.splent_feature_apikeys.decorators import require_token
|
|
62
|
+
|
|
63
|
+
@some_bp.route("/api/v1/spls", methods=["POST"])
|
|
64
|
+
@require_token("write_spl")
|
|
65
|
+
def publish():
|
|
66
|
+
g.api_user # the owner
|
|
67
|
+
g.api_token # the presenting token
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`require_token()` with no scope accepts any usable token. For routes where a
|
|
71
|
+
token is one of several ways in, call `resolve_token(scope)` directly: it
|
|
72
|
+
returns `(api_token, None)` or `(None, (response, status))`.
|
|
73
|
+
|
|
74
|
+
## Endpoints
|
|
75
|
+
|
|
76
|
+
| Method | Path | Auth |
|
|
77
|
+
|--------|---------------------------------|-------------------|
|
|
78
|
+
| POST | `/api/v1/auth/tokens` | email + password |
|
|
79
|
+
| GET | `/api/v1/auth/whoami` | Bearer token |
|
|
80
|
+
| DELETE | `/api/v1/auth/tokens/current` | Bearer token |
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
curl -X POST https://<product>/api/v1/auth/tokens \
|
|
84
|
+
-H "Content-Type: application/json" \
|
|
85
|
+
-d '{"email": "dev@example.com", "password": "...", "name": "laptop"}'
|
|
86
|
+
|
|
87
|
+
curl https://<product>/api/v1/auth/whoami \
|
|
88
|
+
-H "Authorization: Bearer splent_..."
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Omitting `ttl_days` gives the product default; sending it as `null` gives a
|
|
92
|
+
token that never expires.
|
|
93
|
+
|
|
94
|
+
Failed credential attempts are throttled per caller and account. Users are
|
|
95
|
+
created inactive and an administrator activates them, so an account still
|
|
96
|
+
waiting is answered with `403` and a message that says exactly that, never
|
|
97
|
+
with "wrong password".
|
|
98
|
+
|
|
99
|
+
## Pages
|
|
100
|
+
|
|
101
|
+
- `/developer/tokens` — the owner's tokens, by prefix, with a revoke button.
|
|
102
|
+
- `/developer/tokens/new` — create one; the plaintext is shown once.
|
|
103
|
+
|
|
104
|
+
A link appears in the authenticated sidebar
|
|
105
|
+
(`layout.authenticated_sidebar` hook).
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
| Variable | Default | Meaning |
|
|
110
|
+
|--------------------------------|---------|----------------------------------|
|
|
111
|
+
| `APIKEYS_DEFAULT_TTL_DAYS` | 90 | Life of a token when none is asked |
|
|
112
|
+
| `APIKEYS_MAX_LOGIN_ATTEMPTS` | 5 | Failed attempts per window |
|
|
113
|
+
| `APIKEYS_LOGIN_WINDOW_SECONDS` | 300 | Length of that window |
|
|
114
|
+
|
|
115
|
+
All three have working defaults, so the feature needs no `.env` entry.
|
|
116
|
+
|
|
117
|
+
## Install
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
splent feature:install splent-io/splent_feature_apikeys
|
|
121
|
+
splent db:migrate splent_feature_apikeys
|
|
122
|
+
splent db:upgrade
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Tests
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
splent feature:test splent_feature_apikeys
|
|
129
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/splent_feature_apikeys.egg-info/PKG-INFO
|
|
5
|
+
src/splent_feature_apikeys.egg-info/SOURCES.txt
|
|
6
|
+
src/splent_feature_apikeys.egg-info/dependency_links.txt
|
|
7
|
+
src/splent_feature_apikeys.egg-info/top_level.txt
|
|
8
|
+
src/splent_io/splent_feature_apikeys/__init__.py
|
|
9
|
+
src/splent_io/splent_feature_apikeys/config.py
|
|
10
|
+
src/splent_io/splent_feature_apikeys/decorators.py
|
|
11
|
+
src/splent_io/splent_feature_apikeys/forms.py
|
|
12
|
+
src/splent_io/splent_feature_apikeys/hooks.py
|
|
13
|
+
src/splent_io/splent_feature_apikeys/models.py
|
|
14
|
+
src/splent_io/splent_feature_apikeys/repositories.py
|
|
15
|
+
src/splent_io/splent_feature_apikeys/routes.py
|
|
16
|
+
src/splent_io/splent_feature_apikeys/services.py
|
|
17
|
+
src/splent_io/splent_feature_apikeys/throttle.py
|
|
18
|
+
src/splent_io/splent_feature_apikeys/migrations/alembic.ini
|
|
19
|
+
src/splent_io/splent_feature_apikeys/migrations/env.py
|
|
20
|
+
src/splent_io/splent_feature_apikeys/migrations/script.py.mako
|
|
21
|
+
src/splent_io/splent_feature_apikeys/migrations/versions/089274640134_splent_feature_apikeys.py
|
|
22
|
+
src/splent_io/splent_feature_apikeys/templates/apikeys/created.html
|
|
23
|
+
src/splent_io/splent_feature_apikeys/templates/apikeys/index.html
|
|
24
|
+
src/splent_io/splent_feature_apikeys/templates/apikeys/new.html
|
|
25
|
+
src/splent_io/splent_feature_apikeys/templates/hooks/sidebar_tokens.html
|
|
26
|
+
src/splent_io/splent_feature_apikeys/translations/es/LC_MESSAGES/messages.mo
|
|
27
|
+
src/splent_io/splent_feature_apikeys/translations/es/LC_MESSAGES/messages.po
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
splent_io
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from splent_framework.blueprints.base_blueprint import create_blueprint
|
|
2
|
+
from splent_framework.services.service_locator import register_service
|
|
3
|
+
|
|
4
|
+
from splent_io.splent_feature_apikeys.services import ApiTokenService
|
|
5
|
+
|
|
6
|
+
apikeys_bp = create_blueprint(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def init_feature(app):
|
|
10
|
+
from splent_io.splent_feature_apikeys.throttle import configure_from_app
|
|
11
|
+
|
|
12
|
+
register_service(app, "ApiTokenService", ApiTokenService)
|
|
13
|
+
# config.inject_config has already run, so the throttle picks up the
|
|
14
|
+
# product's budget instead of the module defaults.
|
|
15
|
+
configure_from_app(app)
|
|
16
|
+
_exempt_token_api_from_csrf(app)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _exempt_token_api_from_csrf(app):
|
|
20
|
+
"""Machine API: trades email+password for a token, or revokes the bearer
|
|
21
|
+
token it is called with. Neither rides on the session cookie."""
|
|
22
|
+
csrf = app.extensions.get("splent_csrf")
|
|
23
|
+
if csrf is None:
|
|
24
|
+
return
|
|
25
|
+
from splent_io.splent_feature_apikeys.routes import (
|
|
26
|
+
api_create_token,
|
|
27
|
+
api_revoke_current_token,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
csrf.exempt(api_create_token)
|
|
31
|
+
csrf.exempt(api_revoke_current_token)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def inject_context_vars(app):
|
|
35
|
+
return {}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
apikeys feature configuration.
|
|
3
|
+
|
|
4
|
+
Injects environment variables into Flask app.config.
|
|
5
|
+
Every value has a working default, so the feature needs no .env entry to run.
|
|
6
|
+
|
|
7
|
+
To regenerate from source code: splent feature:inject-config splent_feature_apikeys
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def inject_config(app):
|
|
14
|
+
app.config.update(
|
|
15
|
+
{
|
|
16
|
+
# Life given to a token when the caller does not ask for one.
|
|
17
|
+
"APIKEYS_DEFAULT_TTL_DAYS": int(
|
|
18
|
+
os.getenv("APIKEYS_DEFAULT_TTL_DAYS", "90")
|
|
19
|
+
),
|
|
20
|
+
# Failed credential attempts allowed per caller and window.
|
|
21
|
+
"APIKEYS_MAX_LOGIN_ATTEMPTS": int(
|
|
22
|
+
os.getenv("APIKEYS_MAX_LOGIN_ATTEMPTS", "5")
|
|
23
|
+
),
|
|
24
|
+
"APIKEYS_LOGIN_WINDOW_SECONDS": int(
|
|
25
|
+
os.getenv("APIKEYS_LOGIN_WINDOW_SECONDS", "300")
|
|
26
|
+
),
|
|
27
|
+
# Failed attempts allowed against ONE account from every address
|
|
28
|
+
# put together, for the distributed case a per address budget
|
|
29
|
+
# cannot see. Higher than the per address one on purpose, so the
|
|
30
|
+
# single attacker is caught by their own budget first.
|
|
31
|
+
"APIKEYS_MAX_ACCOUNT_ATTEMPTS": int(
|
|
32
|
+
os.getenv("APIKEYS_MAX_ACCOUNT_ATTEMPTS", "20")
|
|
33
|
+
),
|
|
34
|
+
# Hard ceiling on how many throttle buckets one worker holds. The
|
|
35
|
+
# store is the only thing an unauthenticated caller can make this
|
|
36
|
+
# process allocate, so it is bounded rather than trusted.
|
|
37
|
+
"APIKEYS_THROTTLE_MAX_KEYS": int(
|
|
38
|
+
os.getenv("APIKEYS_THROTTLE_MAX_KEYS", "10000")
|
|
39
|
+
),
|
|
40
|
+
# Largest body POST /api/v1/auth/tokens will read, in bytes. It
|
|
41
|
+
# carries an e-mail, a password and a label.
|
|
42
|
+
"APIKEYS_MAX_BODY_BYTES": int(os.getenv("APIKEYS_MAX_BODY_BYTES", "8192")),
|
|
43
|
+
}
|
|
44
|
+
)
|