diff --git a/docs/source/python-api.rst b/docs/source/python-api.rst index c6d4b23ab..eca07b942 100644 --- a/docs/source/python-api.rst +++ b/docs/source/python-api.rst @@ -21,8 +21,11 @@ password: * Possibility to revoke an authentication token without changing every script The API allows authentication of an application either via public-key -cryptography (ssh-keys) or pre shared keys (passwords). Using the new -public-key style authentication has even more benefits: +cryptography (ssh-keys) or pre shared keys (passwords). A freshly created +application has neither: add a public key to it, or select it in the admin +list and run the action "Generate auth token for applications without one" +to get a pre shared key. Using the new public-key style authentication has +even more benefits: * An application can have multiple public keys, making it easier to change them * Adminapi can sign requests via keys in your local or forwarded ssh-agent diff --git a/packages/serveradmin/serveradmin/api/decorators.py b/packages/serveradmin/serveradmin/api/decorators.py index dc29f2903..72cd86d9e 100644 --- a/packages/serveradmin/serveradmin/api/decorators.py +++ b/packages/serveradmin/serveradmin/api/decorators.py @@ -178,6 +178,9 @@ def authenticate_app_psk(app_id, security_token, timestamp, body): except Application.DoesNotExist as error: raise PermissionDenied(error) + if not app.auth_token: + raise PermissionDenied('Application has no auth token') + expected_proof = calc_security_token(app.auth_token, timestamp, body) if not constant_time_compare(expected_proof, security_token): raise PermissionDenied('Invalid security token') diff --git a/packages/serveradmin/serveradmin/apps/admin.py b/packages/serveradmin/serveradmin/apps/admin.py index c199b369a..5d6b11052 100644 --- a/packages/serveradmin/serveradmin/apps/admin.py +++ b/packages/serveradmin/serveradmin/apps/admin.py @@ -1,6 +1,7 @@ -from django.contrib import admin +from django.contrib import admin, messages from serveradmin.apps.models import Application, PublicKey +from serveradmin.common.utils import random_alnum_string class PublicKeyInline(admin.TabularInline): @@ -37,6 +38,68 @@ class ApplicationAdmin(admin.ModelAdmin): inlines = [ PublicKeyInline ] + actions = [ + 'generate_auth_token', + 'revoke_auth_token', + ] + + @admin.action( + description='Generate auth token for applications without one', + permissions=['change'], + ) + def generate_auth_token(self, request, queryset): + """Give a random auth token to every selected application without one + + Applications that already have a token are left untouched so the + action never rotates a credential that is in use. Revoke first if you + want a fresh token. + """ + generated = 0 + skipped = 0 + for app in queryset: + if app.auth_token: + skipped += 1 + continue + app.auth_token = random_alnum_string(24) + # save() triggers the pre_save signal which derives app_id + app.save() + generated += 1 + + if generated: + self.message_user( + request, f'Generated auth token for {generated} application(s).', + messages.SUCCESS, + ) + if skipped: + self.message_user( + request, + f'Skipped {skipped} application(s) that already have a token.', + messages.WARNING, + ) + + @admin.action( + description='Revoke auth token (public keys keep working)', + permissions=['change'], + ) + def revoke_auth_token(self, request, queryset): + """Remove the auth token from the selected applications + + Clients using the token get "Application has no auth token" from + the API afterwards. Public key authentication is unaffected. + """ + revoked = 0 + for app in queryset: + if not app.auth_token: + continue + app.auth_token = None + # save() triggers the pre_save signal which clears app_id + app.save() + revoked += 1 + + self.message_user( + request, f'Revoked auth token of {revoked} application(s).', + messages.SUCCESS, + ) @admin.display(description='Public Keys') def get_public_keys(self, obj): diff --git a/packages/serveradmin/serveradmin/apps/management/commands/createapp.py b/packages/serveradmin/serveradmin/apps/management/commands/createapp.py index 2a006044f..0482a3d1c 100644 --- a/packages/serveradmin/serveradmin/apps/management/commands/createapp.py +++ b/packages/serveradmin/serveradmin/apps/management/commands/createapp.py @@ -10,6 +10,7 @@ from django.core.management import BaseCommand, CommandError from serveradmin.apps.models import Application +from serveradmin.common.utils import random_alnum_string class Command(BaseCommand): @@ -23,7 +24,8 @@ def add_arguments(self, parser): 'Gather values from SERVERADMIN_TOKEN_OWNER, SERVERADMIN_TOKEN ' 'and SERVERADMIN_TOKEN_SUPERUSER env variables. ' 'Any value for SERVERADMIN_TOKEN_SUPERUSER means yes. ' - 'Empty string or absence means no.' + 'Empty string or absence means no. ' + 'An empty or absent SERVERADMIN_TOKEN generates a random one.' )) def handle(self, *args, **options): @@ -52,10 +54,22 @@ def handle(self, *args, **options): f'Default app for {owner} already exists - skipping.')) return + # Applications don't get an auth token by default anymore, but this + # command is the bootstrap path for scripts and docker and promises + # a token. Generate one if none was given and tell the user about it. + generated = not token + if generated: + token = random_alnum_string(24) + app = Application() app.name = f'default app for {owner}' app.owner = user.get() app.superuser = superuser - if token: - app.auth_token = token + app.auth_token = token app.save() + + if generated: + self.stdout.write(self.style.SUCCESS( + f'Created {app.name} with generated auth token: {token}')) + else: + self.stdout.write(self.style.SUCCESS(f'Created {app.name}.')) diff --git a/packages/serveradmin/serveradmin/apps/migrations/0005_make_app_id_and_auth_token_nullable.py b/packages/serveradmin/serveradmin/apps/migrations/0005_make_app_id_and_auth_token_nullable.py new file mode 100644 index 000000000..3579e016a --- /dev/null +++ b/packages/serveradmin/serveradmin/apps/migrations/0005_make_app_id_and_auth_token_nullable.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.17 on 2026-09-26 09:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('apps', '0004_application_last_login'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='app_id', + field=models.CharField(default=None, editable=False, max_length=64, null=True, unique=True), + ), + migrations.AlterField( + model_name='application', + name='auth_token', + field=models.CharField(default=None, editable=False, max_length=64, null=True, unique=True), + ), + ] diff --git a/packages/serveradmin/serveradmin/apps/models.py b/packages/serveradmin/serveradmin/apps/models.py index fc36d3b67..d87656777 100644 --- a/packages/serveradmin/serveradmin/apps/models.py +++ b/packages/serveradmin/serveradmin/apps/models.py @@ -23,13 +23,12 @@ from adminapi.request import calc_app_id -from serveradmin.common.utils import random_alnum_string class Application(models.Model): name = models.CharField(max_length=80, unique=True) - app_id = models.CharField(max_length=64, unique=True, editable=False) - auth_token = models.CharField(max_length=64, unique=True, editable=False) + app_id = models.CharField(max_length=64, unique=True, editable=False, null=True, default=None) + auth_token = models.CharField(max_length=64, unique=True, editable=False, null=True, default=None) owner = models.ForeignKey(User, on_delete=models.CASCADE) location = models.CharField(max_length=150) disabled = models.BooleanField(default=False) @@ -42,10 +41,15 @@ def __str__(self): @receiver(pre_save, sender=Application) -def set_auth_token(sender, instance, **kwargs): - if not instance.auth_token: - instance.auth_token = random_alnum_string(24) - instance.app_id = calc_app_id(instance.auth_token) +def set_app_id(sender, instance, **kwargs): + """Derive app_id from auth_token + + The auth token is optional. Normalise an empty string to NULL so that + "no token" has exactly one representation and the unique constraint is + not hit by a second token-less application. + """ + instance.auth_token = instance.auth_token or None + instance.app_id = calc_app_id(instance.auth_token) if instance.auth_token else None @receiver(post_save, sender=User) diff --git a/packages/serveradmin/serveradmin/apps/tests/__init__.py b/packages/serveradmin/serveradmin/apps/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/packages/serveradmin/serveradmin/apps/tests/test_auth_token.py b/packages/serveradmin/serveradmin/apps/tests/test_auth_token.py new file mode 100644 index 000000000..0886d8d5a --- /dev/null +++ b/packages/serveradmin/serveradmin/apps/tests/test_auth_token.py @@ -0,0 +1,201 @@ +"""Serveradmin - Tests for optional application auth tokens + +Copyright (c) 2026 InnoGames GmbH +""" +from io import StringIO +from unittest.mock import patch + +from django.contrib.admin.sites import AdminSite +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied +from django.core.management import call_command +from django.test import RequestFactory, TestCase + +from adminapi.request import calc_app_id, calc_security_token +from serveradmin.api.decorators import authenticate_app_psk +from serveradmin.apps.admin import ApplicationAdmin +from serveradmin.apps.models import Application + + +class ApplicationModelTest(TestCase): + def setUp(self): + self.user = User.objects.create_user('alice') + + def _create(self, name, **kwargs): + return Application.objects.create( + name=name, owner=self.user, location='', **kwargs + ) + + def test_no_token_by_default(self): + app = self._create('app') + + self.assertIsNone(app.auth_token) + self.assertIsNone(app.app_id) + + def test_app_id_derived_from_token(self): + app = self._create('app', auth_token='secret') + + self.assertEqual('secret', app.auth_token) + self.assertEqual(calc_app_id('secret'), app.app_id) + + def test_empty_token_is_stored_as_null(self): + app = self._create('app', auth_token='') + + app.refresh_from_db() + self.assertIsNone(app.auth_token) + self.assertIsNone(app.app_id) + + def test_multiple_apps_without_token_can_coexist(self): + self._create('app-a') + self._create('app-b') + self._create('app-c', auth_token='') + + self.assertEqual(3, Application.objects.filter( + auth_token__isnull=True, app_id__isnull=True + ).count()) + + def test_clearing_token_clears_app_id(self): + app = self._create('app', auth_token='secret') + + app.auth_token = None + app.save() + + app.refresh_from_db() + self.assertIsNone(app.auth_token) + self.assertIsNone(app.app_id) + + +class AuthenticatePskTest(TestCase): + def setUp(self): + self.user = User.objects.create_user('alice') + self.timestamp = '1700000000' + self.body = '{"hostname": "test0"}' + + def test_app_without_token_is_denied(self): + app = Application.objects.create( + name='app', owner=self.user, location='' + ) + # Simulate a database row with an app_id but no token, e.g. edited + # by hand. The authentication must not crash on a None token. + Application.objects.filter(pk=app.pk).update(app_id='orphan') + + with self.assertRaisesMessage( + PermissionDenied, 'Application has no auth token' + ): + authenticate_app_psk( + 'orphan', + calc_security_token('anything', self.timestamp, self.body), + self.timestamp, + self.body, + ) + + def test_unknown_app_id_is_denied(self): + with self.assertRaises(PermissionDenied): + authenticate_app_psk( + 'unknown', 'token', self.timestamp, self.body + ) + + def test_app_with_token_authenticates(self): + app = Application.objects.create( + name='app', owner=self.user, location='', auth_token='secret' + ) + + authenticated = authenticate_app_psk( + app.app_id, + calc_security_token('secret', self.timestamp, self.body), + self.timestamp, + self.body, + ) + + self.assertEqual(app, authenticated) + + def test_wrong_token_is_denied(self): + app = Application.objects.create( + name='app', owner=self.user, location='', auth_token='secret' + ) + + with self.assertRaisesMessage( + PermissionDenied, 'Invalid security token' + ): + authenticate_app_psk( + app.app_id, + calc_security_token('wrong', self.timestamp, self.body), + self.timestamp, + self.body, + ) + + +class ApplicationAdminActionsTest(TestCase): + def setUp(self): + self.user = User.objects.create_user('alice') + self.admin = ApplicationAdmin(Application, AdminSite()) + self.request = RequestFactory().post('/admin/apps/application/') + self.request.user = self.user + self.with_token = Application.objects.create( + name='with-token', owner=self.user, location='', + auth_token='secret', + ) + self.without_token = Application.objects.create( + name='without-token', owner=self.user, location='', + ) + + def test_generate_only_for_apps_without_token(self): + with patch.object(self.admin, 'message_user'): + self.admin.generate_auth_token( + self.request, Application.objects.all() + ) + + self.with_token.refresh_from_db() + self.without_token.refresh_from_db() + self.assertEqual('secret', self.with_token.auth_token) + self.assertIsNotNone(self.without_token.auth_token) + self.assertEqual( + calc_app_id(self.without_token.auth_token), + self.without_token.app_id, + ) + + def test_revoke_clears_token_and_app_id(self): + with patch.object(self.admin, 'message_user'): + self.admin.revoke_auth_token( + self.request, Application.objects.all() + ) + + self.with_token.refresh_from_db() + self.without_token.refresh_from_db() + self.assertIsNone(self.with_token.auth_token) + self.assertIsNone(self.with_token.app_id) + self.assertIsNone(self.without_token.auth_token) + + +class CreateAppCommandTest(TestCase): + def setUp(self): + self.user = User.objects.create_user('alice') + + def _call(self, env): + out = StringIO() + with patch.dict('os.environ', env, clear=False): + call_command('createapp', non_interactive=True, stdout=out) + return out.getvalue() + + def test_generates_token_when_none_given(self): + output = self._call({ + 'SERVERADMIN_TOKEN_OWNER': 'alice', + 'SERVERADMIN_TOKEN': '', + }) + + app = Application.objects.get(name='default app for alice') + self.assertIsNotNone(app.auth_token) + self.assertEqual(calc_app_id(app.auth_token), app.app_id) + self.assertIn(app.auth_token, output) + + def test_uses_given_token(self): + output = self._call({ + 'SERVERADMIN_TOKEN_OWNER': 'alice', + 'SERVERADMIN_TOKEN': 'secret', + 'SERVERADMIN_TOKEN_SUPERUSER': 'yes', + }) + + app = Application.objects.get(name='default app for alice') + self.assertEqual('secret', app.auth_token) + self.assertTrue(app.superuser) + self.assertNotIn('secret', output)