Skip to content
Merged
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
7 changes: 5 additions & 2 deletions docs/source/python-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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
Expand Up @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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}.'))
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),
),
]
18 changes: 11 additions & 7 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 @@ -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)
Expand Down
Empty file.
Loading
Loading