1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 19:26:48 +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

@ -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()