1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

feat(accounts): API tokens for scripts and tools

The REST API took only the login session, so a script could not use it
without a browser. A user now makes API tokens on the profile page (API
Tokens tab): a name, and an expiry of 30, 90 or 365 days or none. The
token is shown once, on a page that is not cached, and then kept only as
a SHA-512 digest; the list shows its name, first characters, creation
and expiry, and a token is deleted there. Tools send it as
"Authorization: Token wvc_...", and act as its user, with that user's
permissions. Making and deleting a token is logged, without the token.

A password alone (HTTP Basic) still does not reach the API, as it would
skip the OTP second factor: a token is made in a session that passed the
login and its OTP. The token class comes first, so a refused request gets
401 with its challenge instead of 403.

django-rest-knox 5.1.0 provides the tokens (accounts.ApiToken adds the
name); settings.py takes "knox", KNOX_TOKEN_MODEL and REST_KNOX from the
template.
This commit is contained in:
catborise 2026-10-24 23:05:27 +03:00
parent ba51b3e4d6
commit 1c60696ea1
14 changed files with 290 additions and 13 deletions

View file

@ -629,11 +629,16 @@ See [doc/permissions.md](doc/permissions.md).
### REST API
The REST API (Django REST Framework, documented with `drf-spectacular`) uses the login session. After logging in:
The REST API (Django REST Framework, documented with `drf-spectacular`) takes the login session or an API token. After logging in:
* **Swagger UI:** `http://<server>/swagger/`
* **ReDoc UI:** `http://<server>/redoc/`
* **OpenAPI 3.0 schema:** `http://<server>/api/schema/` (JSON or YAML)
* **API tokens**, for scripts and tools: **Profile → API Tokens**. A token acts as its user and is shown once; send it as a header:
```bash
curl -H "Authorization: Token wvc_..." https://<server>/api/v1/instances/
```
### Screenshots

View file

@ -2,7 +2,7 @@ from appsettings.settings import app_settings
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import AuthenticationForm
from django.core.validators import MaxLengthValidator
from django.forms import EmailField, Form, ModelForm, ValidationError
from django.forms import CharField, EmailField, Form, ModelForm, TypedChoiceField, ValidationError
from django.utils.translation import gettext_lazy as _
from .models import UserInstance, UserSSHKey
@ -74,6 +74,14 @@ class UserSSHKeyForm(ModelForm):
fields = ("keyname", "keypublic")
class ApiTokenForm(Form):
name = CharField(label=_("Name"), max_length=64, help_text=_("What uses it, such as a script or a host"))
days = TypedChoiceField(
label=_("Expires"), coerce=int, empty_value=None, required=False, initial=90,
choices=[(30, _("in 30 days")), (90, _("in 90 days")), (365, _("in a year")), ("", _("never"))],
)
class EmailOTPForm(Form):
email = EmailField(label=_("Email"))

View file

@ -0,0 +1,30 @@
# Generated by Django 5.2.18 on 2026-10-24 19:58
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0008_apply_show_profile_edit_password_once'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ApiToken',
fields=[
('digest', models.CharField(max_length=128, primary_key=True, serialize=False)),
('token_key', models.CharField(db_index=True, max_length=25)),
('created', models.DateTimeField(auto_now_add=True)),
('expiry', models.DateTimeField(blank=True, null=True)),
('name', models.CharField(max_length=64, verbose_name='name')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_token_set', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created'],
},
),
]

View file

@ -3,6 +3,7 @@ from django.core.validators import MinValueValidator
from django.db import models
from django.utils.translation import gettext_lazy as _
from instances.models import Instance
from knox.models import AbstractAuthToken
class UserInstanceManager(models.Manager):
@ -81,3 +82,12 @@ class PermissionSet(models.Model):
permissions = (("change_password", _("Can change password")),)
managed = False
class ApiToken(AbstractAuthToken):
"""A token for the REST API, kept as a digest (django-rest-knox); the
name says what uses it."""
name = models.CharField(_("name"), max_length=64)
class Meta:
ordering = ["-created"]

View file

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "API token created" %}{% endblock %}
{% block page_heading %}{% trans "API token created" %}: {{ name }}{% endblock page_heading %}
{% block content %}
<div class="alert alert-warning" role="alert">
{% trans "Copy the token now: it is not shown again. Anyone who has it can use the REST API as you." %}
</div>
<label class="form-label" for="api-token">{% trans "Token" %}</label>
<input type="text" class="form-control font-monospace mb-3" id="api-token" value="{{ key }}" readonly>
<p>{% trans "Use it like this:" %}</p>
<pre class="bg-body-tertiary p-2 text-break" style="white-space: pre-wrap"><code>curl -H "Authorization: Token {{ key }}" {{ request.scheme }}://{{ request.get_host }}{% url 'instance-list' %}</code></pre>
<a class="btn btn-primary" href="{% url 'accounts:profile' %}#apitokens">{% trans "Back to the profile" %}</a>
{% endblock content %}

View file

@ -20,6 +20,11 @@
{% trans "SSH Keys" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#apitokens" type="button" role="tab" aria-controls="apitokens" aria-selected="false">
{% trans "API Tokens" %}
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane tab-pane-bordered active" id="editprofile">
@ -80,5 +85,63 @@
</div>
</div>
</div>
<div class="tab-pane tab-pane-bordered fade" id="apitokens">
<p class="text-body-secondary">{% blocktrans %}A token lets a script or a tool use the REST API as you, without your password or OTP: send it as <code>Authorization: Token &lt;token&gt;</code>. A password change does not delete tokens: delete a token you no longer use, or one that may have leaked.{% endblocktrans %}</p>
{% if api_tokens %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th scope="col">{% trans "Name" %}</th>
<th scope="col">{% trans "Starts with" %}</th>
<th scope="col">{% trans "Created" %}</th>
<th scope="col">{% trans "Expires" %}</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for token in api_tokens %}
<tr>
<td>{{ token.name }}</td>
<td><code>{{ token.token_key }}</code></td>
<td>{{ token.created|date:"Y-m-d H:i" }}</td>
<td>{% if token.expiry %}{{ token.expiry|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</td>
<td class="text-end">
<a href="{% url 'accounts:api_token_delete' token.token_key %}" title="{% trans "Delete" %}" class="btn btn-sm btn-secondary">
{% bs_icon 'trash' %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<div class="card">
<div class="card-header">
{% trans "Create an API token" %}
</div>
<div class="card-body">
<form method="post" action="{% url 'accounts:api_token_create' %}" role="form" aria-label="{% trans "Create an API token" %}">
{% csrf_token %}
{% bootstrap_form api_token_form layout='horizontal' %}
<div class="mb-0 float-end">
<button type="submit" class="btn btn-primary">
{% bs_icon 'plus-circle-fill' %} {% trans "Create" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
// a link to a tab (#apitokens) opens it
var linked = location.hash.length > 1 && document.querySelector('[data-bs-target="#' + CSS.escape(location.hash.slice(1)) + '"]');
if (linked) {
bootstrap.Tab.getOrCreateInstance(linked).show();
}
</script>
{% endblock %}

View file

@ -0,0 +1,93 @@
"""API tokens: a user makes them on the profile page, in a session that
passed the login (and its OTP), and scripts use them on the REST API."""
import re
from datetime import timedelta
from accounts.models import ApiToken
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from logs.models import Logs
class ApiTokenTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user("tok-user", password="pw")
self.other = get_user_model().objects.create_user("tok-other", password="pw")
def make(self, name="ansible", days="90"):
"""The token the creation page shows once."""
self.client.force_login(self.user)
response = self.client.post(reverse("accounts:api_token_create"), {"name": name, "days": days})
self.assertEqual(response.status_code, 200)
return re.search(r"wvc_[0-9a-f]{64}", response.content.decode()).group(0)
def api(self, token):
self.client.logout()
return self.client.get(reverse("instance-list"), HTTP_AUTHORIZATION=f"Token {token}")
def test_a_token_opens_the_api_without_a_session(self):
token = self.make()
response = self.api(token)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), []) # the user's own VMs: none
def test_a_token_is_kept_only_as_a_hash_and_shown_once(self):
token = self.make(name="ci")
(stored,) = ApiToken.objects.filter(user=self.user)
self.assertEqual((stored.name, stored.token_key), ("ci", token[:15]))
self.assertNotIn(token, stored.digest)
page = self.client.get(reverse("accounts:profile")).content.decode()
self.assertIn("ci", page)
self.assertNotIn(token, page)
def test_the_page_showing_the_token_is_not_cached(self):
self.client.force_login(self.user)
response = self.client.post(reverse("accounts:api_token_create"), {"name": "ci", "days": "30"})
self.assertIn("no-store", response["Cache-Control"])
def test_a_token_lasts_the_days_chosen_or_until_deleted(self):
self.make(name="month", days="30")
self.make(name="always", days="")
expiry = dict(ApiToken.objects.values_list("name", "expiry"))
self.assertAlmostEqual(expiry["month"], timezone.now() + timedelta(days=30), delta=timedelta(minutes=1))
self.assertIsNone(expiry["always"])
def test_an_expired_token_or_a_wrong_one_is_refused(self):
token = self.make()
ApiToken.objects.update(expiry=timezone.now() - timedelta(seconds=1))
self.assertEqual(self.api(token).status_code, 401)
self.assertEqual(self.api("wvc_" + "0" * 64).status_code, 401)
def test_an_inactive_users_token_is_refused(self):
token = self.make()
self.user.is_active = False
self.user.save()
self.assertEqual(self.api(token).status_code, 401)
def test_a_deleted_token_stops_working_and_only_its_owner_deletes_it(self):
token = self.make()
stored = ApiToken.objects.get(user=self.user)
url = reverse("accounts:api_token_delete", args=[stored.token_key])
self.client.force_login(self.other)
self.assertEqual(self.client.post(url).status_code, 404)
self.client.force_login(self.user)
self.assertRedirects(self.client.post(url), reverse("accounts:profile") + "#apitokens",
fetch_redirect_response=False)
self.assertEqual(self.api(token).status_code, 401)
def test_making_and_deleting_a_token_is_logged_without_the_token(self):
token = self.make(name="backup")
stored = ApiToken.objects.get(user=self.user)
self.client.post(reverse("accounts:api_token_delete", args=[stored.token_key]))
rows = list(Logs.objects.order_by("id").values_list("message", "success"))
self.assertEqual(rows[-2:], [("API token backup: add", True), ("API token backup: delete", True)])
self.assertFalse(Logs.objects.filter(message__contains=token[4:20]).exists())
def test_a_token_needs_a_name(self):
self.client.force_login(self.user)
response = self.client.post(reverse("accounts:api_token_create"), {"name": " ", "days": "30"})
self.assertFalse(ApiToken.objects.exists())
self.assertNotRegex(response.content.decode(), r"wvc_[0-9a-f]{64}")

View file

@ -30,6 +30,8 @@ urlpatterns = [
),
path("ssh_key/create/", views.ssh_key_create, name="ssh_key_create"),
path("ssh_key/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
path("api_token/create/", views.api_token_create, name="api_token_create"),
path("api_token/<str:token_key>/delete/", views.api_token_delete, name="api_token_delete"),
path("email_otp/", views.email_otp, name="email_otp"),
path(
"admin_email_otp/<int:user_id>/",

View file

@ -1,5 +1,6 @@
import logging
import os
from datetime import timedelta
from admin.decorators import superuser_only
from django.conf import settings
@ -15,14 +16,15 @@ from django.core.cache import cache
from django.http import Http404
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.translation import gettext_lazy as _
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_POST
from accounts.apps import admin_password_path
from instances.models import Instance
from logs.audit import audit, audit_vm, changed_fields, failed, snapshot
from accounts.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm
from accounts.models import User, UserAttributes, UserInstance, UserSSHKey
from accounts.forms import ApiTokenForm, EmailOTPForm, ProfileForm, UserSSHKeyForm
from accounts.models import ApiToken, User, UserAttributes, UserInstance, UserSSHKey
from . import forms
from .utils import get_existing_totp_device, get_user_totp_device, send_email_with_otp
@ -50,6 +52,41 @@ def profile(request):
"publickeys": publickeys,
"profile_form": profile_form,
"ssh_key_form": ssh_key_form,
"api_tokens": ApiToken.objects.filter(user=request.user),
"api_token_form": ApiTokenForm(),
},
)
@require_POST
@never_cache
def api_token_create(request):
"""A REST API token, shown once: only its digest is kept. It is made in
a session that passed the login, and its OTP when OTP is on."""
form = ApiTokenForm(request.POST)
if not form.is_valid():
return render(request, "common/form.html", {"form": form, "title": _("Create an API token")})
name, days = form.cleaned_data["name"], form.cleaned_data["days"]
with audit(request, None, f"API token {name}", "add"):
_token, key = ApiToken.objects.create(request.user, expiry=timedelta(days=days) if days else None, name=name)
return render(request, "api_token_created.html", {"name": name, "key": key})
def api_token_delete(request, token_key):
token = get_object_or_404(ApiToken, token_key=token_key, user=request.user)
if request.method == "POST":
with audit(request, None, f"API token {token.name}", "delete"):
token.delete()
messages.success(request, _("API token deleted"))
return redirect(reverse("accounts:profile") + "#apitokens")
return render(
request,
"common/confirm_delete.html",
{
"object": token.name,
"title": _("Delete API token"),
"consequence": _("Scripts and tools that use it can no longer reach the API."),
},
)

View file

@ -9,6 +9,7 @@ requests>=2.33.0
urllib3>=2.8.0
django-axes==8.3.1
djangorestframework==3.18.3
django-rest-knox==5.1.0
drf-nested-routers==0.95.3
drf-spectacular[sidecar]==0.30.0
gunicorn==26.2.0

View file

@ -82,6 +82,7 @@ INSTALLED_APPS = [
"logs",
"operations",
"rest_framework",
"knox",
"axes",
]
@ -362,9 +363,12 @@ OTP_ENABLED = False
# REST Framework & OpenAPI (drf-spectacular)
REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
# Session only: a password alone (HTTP Basic) must not reach the API, as it
# would skip the OTP second factor the login form enforces.
# An API token, or a session: a password alone (HTTP Basic) must not reach
# the API, as it would skip the OTP second factor the login form enforces;
# tokens are made in a session. The token class comes first, so a refused
# request gets 401 with its challenge, not 403.
"DEFAULT_AUTHENTICATION_CLASSES": [
"knox.auth.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
],
# A view that forgets its own permission_classes is not public.
@ -373,6 +377,11 @@ REST_FRAMEWORK = {
],
}
# API tokens (django-rest-knox): kept as SHA-512 digests, sent as
# "Authorization: Token wvc_..."; made and deleted on the profile page
KNOX_TOKEN_MODEL = "accounts.ApiToken"
REST_KNOX = {"TOKEN_PREFIX": "wvc_"}
SPECTACULAR_SETTINGS = {
"TITLE": "Webvirtcloud REST-API",
"DESCRIPTION": "Webvirtcloud REST API",

View file

@ -26,7 +26,7 @@ class SchemaPermissionTestCase(TestCase):
def test_schema_requires_login(self):
client = APIClient()
for url in SCHEMA_URLS:
self.assertEqual(client.get(url).status_code, 403, url)
self.assertEqual(client.get(url).status_code, 401, url)
def test_any_user_may_read_the_schema(self):
user = get_user_model().objects.create_user("schema_reader", password="pw")
@ -41,4 +41,4 @@ class SchemaPermissionTestCase(TestCase):
return Response({})
response = Probe.as_view()(APIRequestFactory().get("/probe/"))
self.assertEqual(response.status_code, 403)
self.assertEqual(response.status_code, 401)

View file

@ -38,6 +38,7 @@ ALLOWED_FOR_ANY_USER = {
"accounts:profile": "the user's own profile",
"accounts:change_password": "the user's own password; new users get the permission by default",
"accounts:ssh_key_create": "the user's own SSH keys",
"accounts:api_token_create": "the user's own API tokens",
"set_language": "UI language switch",
"console": "renders an error without a valid token; access is checked per VM",
"ds_openstack_index": "cloud-init datasource, fetched by VMs",

View file

@ -12,11 +12,13 @@ from rest_framework.settings import api_settings
class BasicAuthDisabledTestCase(TestCase):
def test_session_is_the_only_default_authentication(self):
# exactly SessionAuthentication: not Basic (a password-only door) and
# not Token/JWT either, which would be another password-free door
names = [c.__name__ for c in api_settings.DEFAULT_AUTHENTICATION_CLASSES]
self.assertEqual(names, ["SessionAuthentication"])
def test_a_token_or_a_session_are_the_only_default_authentication(self):
# exactly knox's tokens and sessions: not Basic (a password-only door).
# A token is no other door: it is made in a session that passed the
# login and its OTP (accounts.views.api_token_create)
classes = [f"{c.__module__}.{c.__name__}" for c in api_settings.DEFAULT_AUTHENTICATION_CLASSES]
self.assertEqual(classes, ["knox.auth.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication"])
def test_api_rejects_http_basic_credentials(self):
User = get_user_model()