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`Enabling this toggle will create a group named after the user, with the user as member.`}

+ + +

+ ${t`If this is selected, the token will expire. Upon expiration, the token will be rotated.`} +

+
+ + + `; }