Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,4 @@ __pycache__/
# Local docker-compose overlay (e.g. to mount/install a private app bundle)
/docker-compose.override.yml

/Makefile
3 changes: 3 additions & 0 deletions packages/serveradmin/serveradmin/api/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
65 changes: 64 additions & 1 deletion packages/serveradmin/serveradmin/apps/admin.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
9 changes: 3 additions & 6 deletions packages/serveradmin/serveradmin/apps/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading