From c4e24c04f6c8d7cc8c2dd521455fdb09473ea0cd Mon Sep 17 00:00:00 2001
From: sdimovv <36302090+sdimovv@users.noreply.github.com>
Date: Wed, 22 Feb 2023 14:19:01 +0200
Subject: [PATCH] core: Improve service account creation (#4751)
* Added ability to select service account token expiration on creation
* Added call to user.set_unusable_password on service account creation
* Added forgotten call to save()
* Added and improved existsing tests
* Added accidentally deleted help text
* Fix lint
---
authentik/core/api/users.py | 19 ++++-
authentik/core/tests/test_users_api.py | 90 ++++++++++++++++++++++-
schema.yml | 7 ++
web/src/admin/users/ServiceAccountForm.ts | 23 ++++++
4 files changed, 133 insertions(+), 6 deletions(-)
diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py
index 5ec51aa4e..6765641f6 100644
--- a/authentik/core/api/users.py
+++ b/authentik/core/api/users.py
@@ -38,6 +38,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
BooleanField,
+ DateTimeField,
ListSerializer,
ModelSerializer,
PrimaryKeyRelatedField,
@@ -353,6 +354,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
{
"name": CharField(required=True),
"create_group": BooleanField(default=False),
+ "expiring": BooleanField(default=True),
+ "expires": DateTimeField(
+ required=False,
+ help_text="If not provided, valid for 360 days",
+ ),
},
),
responses={
@@ -373,14 +379,20 @@ class UserViewSet(UsedByMixin, ModelViewSet):
"""Create a new user account that is marked as a service account"""
username = request.data.get("name")
create_group = request.data.get("create_group", False)
+ expiring = request.data.get("expiring", True)
+ expires = request.data.get("expires", now() + timedelta(days=360))
+
with atomic():
try:
- user = User.objects.create(
+ user: User = User.objects.create(
username=username,
name=username,
- attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
+ attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: expiring},
path=USER_PATH_SERVICE_ACCOUNT,
)
+ user.set_unusable_password()
+ user.save()
+
response = {
"username": user.username,
"user_uid": user.uid,
@@ -396,7 +408,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
identifier=slugify(f"service-account-{username}-password"),
intent=TokenIntents.INTENT_APP_PASSWORD,
user=user,
- expires=now() + timedelta(days=360),
+ expires=expires,
+ expiring=expiring,
)
response["token"] = token.key
return Response(response)
diff --git a/authentik/core/tests/test_users_api.py b/authentik/core/tests/test_users_api.py
index ab76de49b..79e60335e 100644
--- a/authentik/core/tests/test_users_api.py
+++ b/authentik/core/tests/test_users_api.py
@@ -1,11 +1,19 @@
"""Test Users API"""
+from datetime import datetime
+
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.urls.base import reverse
from rest_framework.test import APITestCase
-from authentik.core.models import AuthenticatedSession, User
+from authentik.core.models import (
+ USER_ATTRIBUTE_SA,
+ USER_ATTRIBUTE_TOKEN_EXPIRING,
+ AuthenticatedSession,
+ Token,
+ User,
+)
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
from authentik.flows.models import FlowDesignation
from authentik.lib.generators import generate_id, generate_key
@@ -130,7 +138,71 @@ class TestUsersAPI(APITestCase):
},
)
self.assertEqual(response.status_code, 200)
- self.assertTrue(User.objects.filter(username="test-sa").exists())
+
+ user_filter = User.objects.filter(
+ username="test-sa",
+ attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True},
+ )
+ self.assertTrue(user_filter.exists())
+ user: User = user_filter.first()
+ self.assertFalse(user.has_usable_password())
+
+ token_filter = Token.objects.filter(user=user)
+ self.assertTrue(token_filter.exists())
+ self.assertTrue(token_filter.first().expiring)
+
+ def test_service_account_no_expire(self):
+ """Service account creation without token expiration"""
+ self.client.force_login(self.admin)
+ response = self.client.post(
+ reverse("authentik_api:user-service-account"),
+ data={
+ "name": "test-sa",
+ "create_group": True,
+ "expiring": False,
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+
+ user_filter = User.objects.filter(
+ username="test-sa",
+ attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: False, USER_ATTRIBUTE_SA: True},
+ )
+ self.assertTrue(user_filter.exists())
+ user: User = user_filter.first()
+ self.assertFalse(user.has_usable_password())
+
+ token_filter = Token.objects.filter(user=user)
+ self.assertTrue(token_filter.exists())
+ self.assertFalse(token_filter.first().expiring)
+
+ def test_service_account_with_custom_expire(self):
+ """Service account creation with custom token expiration date"""
+ self.client.force_login(self.admin)
+ expire_on = datetime(2050, 11, 11, 11, 11, 11).astimezone()
+ response = self.client.post(
+ reverse("authentik_api:user-service-account"),
+ data={
+ "name": "test-sa",
+ "create_group": True,
+ "expires": expire_on.isoformat(),
+ },
+ )
+ self.assertEqual(response.status_code, 200)
+
+ user_filter = User.objects.filter(
+ username="test-sa",
+ attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True},
+ )
+ self.assertTrue(user_filter.exists())
+ user: User = user_filter.first()
+ self.assertFalse(user.has_usable_password())
+
+ token_filter = Token.objects.filter(user=user)
+ self.assertTrue(token_filter.exists())
+ token = token_filter.first()
+ self.assertTrue(token.expiring)
+ self.assertEqual(token.expires, expire_on)
def test_service_account_invalid(self):
"""Service account creation (twice with same name, expect error)"""
@@ -143,7 +215,19 @@ class TestUsersAPI(APITestCase):
},
)
self.assertEqual(response.status_code, 200)
- self.assertTrue(User.objects.filter(username="test-sa").exists())
+
+ user_filter = User.objects.filter(
+ username="test-sa",
+ attributes={USER_ATTRIBUTE_TOKEN_EXPIRING: True, USER_ATTRIBUTE_SA: True},
+ )
+ self.assertTrue(user_filter.exists())
+ user: User = user_filter.first()
+ self.assertFalse(user.has_usable_password())
+
+ token_filter = Token.objects.filter(user=user)
+ self.assertTrue(token_filter.exists())
+ self.assertTrue(token_filter.first().expiring)
+
response = self.client.post(
reverse("authentik_api:user-service-account"),
data={
diff --git a/schema.yml b/schema.yml
index 7e8c11da0..d274d834c 100644
--- a/schema.yml
+++ b/schema.yml
@@ -38310,6 +38310,13 @@ components:
create_group:
type: boolean
default: false
+ expiring:
+ type: boolean
+ default: true
+ expires:
+ type: string
+ format: date-time
+ description: If not provided, valid for 360 days
required:
- name
UserServiceAccountResponse:
diff --git a/web/src/admin/users/ServiceAccountForm.ts b/web/src/admin/users/ServiceAccountForm.ts
index 5a1507663..66af4be9b 100644
--- a/web/src/admin/users/ServiceAccountForm.ts
+++ b/web/src/admin/users/ServiceAccountForm.ts
@@ -1,4 +1,5 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
+import { dateTimeLocal } from "@goauthentik/common/utils";
import { Form } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModalForm } from "@goauthentik/elements/forms/ModalForm";
@@ -56,6 +57,28 @@ export class ServiceAccountForm extends Form
+ ${t`If this is selected, the token will expire. Upon expiration, the token will be rotated.`} +
+