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
This commit is contained in:
parent
47e663f48c
commit
c4e24c04f6
|
@ -38,6 +38,7 @@ from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.serializers import (
|
from rest_framework.serializers import (
|
||||||
BooleanField,
|
BooleanField,
|
||||||
|
DateTimeField,
|
||||||
ListSerializer,
|
ListSerializer,
|
||||||
ModelSerializer,
|
ModelSerializer,
|
||||||
PrimaryKeyRelatedField,
|
PrimaryKeyRelatedField,
|
||||||
|
@ -353,6 +354,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||||
{
|
{
|
||||||
"name": CharField(required=True),
|
"name": CharField(required=True),
|
||||||
"create_group": BooleanField(default=False),
|
"create_group": BooleanField(default=False),
|
||||||
|
"expiring": BooleanField(default=True),
|
||||||
|
"expires": DateTimeField(
|
||||||
|
required=False,
|
||||||
|
help_text="If not provided, valid for 360 days",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
responses={
|
responses={
|
||||||
|
@ -373,14 +379,20 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||||
"""Create a new user account that is marked as a service account"""
|
"""Create a new user account that is marked as a service account"""
|
||||||
username = request.data.get("name")
|
username = request.data.get("name")
|
||||||
create_group = request.data.get("create_group", False)
|
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():
|
with atomic():
|
||||||
try:
|
try:
|
||||||
user = User.objects.create(
|
user: User = User.objects.create(
|
||||||
username=username,
|
username=username,
|
||||||
name=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,
|
path=USER_PATH_SERVICE_ACCOUNT,
|
||||||
)
|
)
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save()
|
||||||
|
|
||||||
response = {
|
response = {
|
||||||
"username": user.username,
|
"username": user.username,
|
||||||
"user_uid": user.uid,
|
"user_uid": user.uid,
|
||||||
|
@ -396,7 +408,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||||
identifier=slugify(f"service-account-{username}-password"),
|
identifier=slugify(f"service-account-{username}-password"),
|
||||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||||
user=user,
|
user=user,
|
||||||
expires=now() + timedelta(days=360),
|
expires=expires,
|
||||||
|
expiring=expiring,
|
||||||
)
|
)
|
||||||
response["token"] = token.key
|
response["token"] = token.key
|
||||||
return Response(response)
|
return Response(response)
|
||||||
|
|
|
@ -1,11 +1,19 @@
|
||||||
"""Test Users API"""
|
"""Test Users API"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from rest_framework.test import APITestCase
|
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.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||||
from authentik.flows.models import FlowDesignation
|
from authentik.flows.models import FlowDesignation
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
|
@ -130,7 +138,71 @@ class TestUsersAPI(APITestCase):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
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):
|
def test_service_account_invalid(self):
|
||||||
"""Service account creation (twice with same name, expect error)"""
|
"""Service account creation (twice with same name, expect error)"""
|
||||||
|
@ -143,7 +215,19 @@ class TestUsersAPI(APITestCase):
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
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(
|
response = self.client.post(
|
||||||
reverse("authentik_api:user-service-account"),
|
reverse("authentik_api:user-service-account"),
|
||||||
data={
|
data={
|
||||||
|
|
|
@ -38310,6 +38310,13 @@ components:
|
||||||
create_group:
|
create_group:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
|
expiring:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
expires:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: If not provided, valid for 360 days
|
||||||
required:
|
required:
|
||||||
- name
|
- name
|
||||||
UserServiceAccountResponse:
|
UserServiceAccountResponse:
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||||
|
import { dateTimeLocal } from "@goauthentik/common/utils";
|
||||||
import { Form } from "@goauthentik/elements/forms/Form";
|
import { Form } from "@goauthentik/elements/forms/Form";
|
||||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||||
import { ModalForm } from "@goauthentik/elements/forms/ModalForm";
|
import { ModalForm } from "@goauthentik/elements/forms/ModalForm";
|
||||||
|
@ -56,6 +57,28 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
|
||||||
${t`Enabling this toggle will create a group named after the user, with the user as member.`}
|
${t`Enabling this toggle will create a group named after the user, with the user as member.`}
|
||||||
</p>
|
</p>
|
||||||
</ak-form-element-horizontal>
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal name="expiring">
|
||||||
|
<label class="pf-c-switch">
|
||||||
|
<input class="pf-c-switch__input" type="checkbox" ?checked=${true} />
|
||||||
|
<span class="pf-c-switch__toggle">
|
||||||
|
<span class="pf-c-switch__toggle-icon">
|
||||||
|
<i class="fas fa-check" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="pf-c-switch__label">${t`Expiring`}</span>
|
||||||
|
</label>
|
||||||
|
<p class="pf-c-form__helper-text">
|
||||||
|
${t`If this is selected, the token will expire. Upon expiration, the token will be rotated.`}
|
||||||
|
</p>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
|
<ak-form-element-horizontal label=${t`Expires on`} name="expires">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
data-type="datetime-local"
|
||||||
|
value="${dateTimeLocal(new Date(Date.now() + 1000 * 60 ** 2 * 24 * 360))}"
|
||||||
|
class="pf-c-form-control"
|
||||||
|
/>
|
||||||
|
</ak-form-element-horizontal>
|
||||||
</form>`;
|
</form>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Reference in New Issue