diff --git a/.gitignore b/.gitignore index 3ed1c2d57..f40283c08 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ __pycache__/ # Local docker-compose overlay (e.g. to mount/install a private app bundle) /docker-compose.override.yml +/Makefile 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/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..aeac43db7 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) @@ -43,9 +42,7 @@ 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) + instance.app_id = calc_app_id(instance.auth_token) if instance.auth_token else None @receiver(post_save, sender=User)