mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-10 12:16:49 +00:00
The API Tokens tab gets a "Last used" column, so an unused token can be told from one a script still needs. The token's last use is written when it authenticates, at most once a minute (accounts.auth.TokenAuthentication, knox's class with that one addition; the API schema describes it as knox's). A user has at most ten tokens; a new one is refused until one is deleted. Expired tokens do not count, and are deleted when a new token is made.
131 lines
6.3 KiB
Python
131 lines
6.3 KiB
Python
"""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 unittest.mock import patch
|
|
|
|
from accounts.models import ApiToken
|
|
from django.contrib.auth import get_user_model
|
|
from django.db import connection
|
|
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}")
|
|
|
|
def test_knox_makes_no_table_of_its_own(self):
|
|
# accounts.ApiToken replaces knox's AuthToken; a knox_authtoken table
|
|
# would reference auth_user unseen, and PostgreSQL could not empty
|
|
# auth_user between tests
|
|
self.assertNotIn("knox_authtoken", connection.introspection.table_names())
|
|
def test_a_token_shows_when_it_was_last_used(self):
|
|
token = self.make()
|
|
self.assertIsNone(ApiToken.objects.get().last_used)
|
|
self.api(token)
|
|
first = ApiToken.objects.get().last_used
|
|
self.assertAlmostEqual(first, timezone.now(), delta=timedelta(seconds=5))
|
|
# within a minute a request writes nothing
|
|
self.api(token)
|
|
self.assertEqual(ApiToken.objects.get().last_used, first)
|
|
later = timezone.now() + timedelta(minutes=2)
|
|
with patch("accounts.auth.timezone.now", return_value=later):
|
|
self.api(token)
|
|
self.assertEqual(ApiToken.objects.get().last_used, later)
|
|
self.client.force_login(self.user)
|
|
self.assertIn("Last used", self.client.get(reverse("accounts:profile")).content.decode())
|
|
|
|
def test_a_user_has_at_most_ten_tokens(self):
|
|
for number in range(10):
|
|
self.make(name=f"t{number}")
|
|
response = self.client.post(reverse("accounts:api_token_create"), {"name": "one more", "days": "30"})
|
|
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 10)
|
|
self.assertNotRegex(response.content.decode(), r"wvc_[0-9a-f]{64}")
|
|
self.assertIn("at most 10", response.content.decode())
|
|
|
|
def test_expired_tokens_do_not_count_toward_the_limit(self):
|
|
for number in range(10):
|
|
self.make(name=f"t{number}")
|
|
ApiToken.objects.filter(name="t0").update(expiry=timezone.now() - timedelta(days=1))
|
|
self.make(name="new")
|
|
self.assertEqual(sorted(ApiToken.objects.values_list("name", flat=True))[:2], ["new", "t1"])
|