From a70363bd9550e73b492ed4761cda86f5fcb7c9e0 Mon Sep 17 00:00:00 2001 From: Jean-Michel DILLY <48059109+jmdilly@users.noreply.github.com> Date: Thu, 11 Apr 2024 13:05:05 +0200 Subject: [PATCH] core: add user settable token durations (#7410) * core: add support for user settable token duration * web: add support for user settable token duration * website: add documentation for user settable token duration * core : fix locales * web: fix tokenIntent when updating * core: fix linting * website: Update website/docs/user-group-role/user/user_ref.md Co-authored-by: Tana M Berry Signed-off-by: Jean-Michel DILLY <48059109+jmdilly@users.noreply.github.com> * make token duration system-wide configurable Signed-off-by: Marc 'risson' Schmitt * small fixup Signed-off-by: Jens Langhammer * migrate token configs to tenants Signed-off-by: Marc 'risson' Schmitt * add release notes Signed-off-by: Marc 'risson' Schmitt * make website Signed-off-by: Marc 'risson' Schmitt * lint-fix Signed-off-by: Marc 'risson' Schmitt * fix migrations Signed-off-by: Marc 'risson' Schmitt * nosec Signed-off-by: Marc 'risson' Schmitt * lint-fix Signed-off-by: Marc 'risson' Schmitt * fix migrations for real this time Signed-off-by: Marc 'risson' Schmitt * trying with no model using default_token_key Signed-off-by: Marc 'risson' Schmitt * lint-fix Signed-off-by: Marc 'risson' Schmitt * fix save Signed-off-by: Marc 'risson' Schmitt * lint-fix Signed-off-by: Marc 'risson' Schmitt * use signal instead of overriding save Signed-off-by: Marc 'risson' Schmitt * fix tests Signed-off-by: Marc 'risson' Schmitt --------- Signed-off-by: Jean-Michel DILLY <48059109+jmdilly@users.noreply.github.com> Signed-off-by: Marc 'risson' Schmitt Signed-off-by: Jens Langhammer Co-authored-by: Tana M Berry Co-authored-by: Marc 'risson' Schmitt Co-authored-by: Jens Langhammer --- authentik/core/api/tokens.py | 35 +++++++- ...3_1737_squashed_0016_auto_20201202_2234.py | 7 +- ...r_authenticatedsession_expires_and_more.py | 31 +++++++ authentik/core/models.py | 36 ++++++--- authentik/core/signals.py | 18 ++++- authentik/core/tests/test_token_api.py | 80 ++++++++++++++++++- .../0004_alter_connectiontoken_expires.py | 18 +++++ .../0006_alter_systemtask_expires.py | 21 +++++ authentik/lib/default.yml | 1 - authentik/providers/oauth2/id_token.py | 5 +- ...0018_alter_accesstoken_expires_and_more.py | 36 +++++++++ .../0006_alter_userconsent_expires.py | 18 +++++ .../0008_alter_invitation_expires.py | 18 +++++ authentik/tenants/api/settings.py | 2 + ..._tenant_default_token_duration_and_more.py | 35 ++++++++ authentik/tenants/models.py | 14 ++++ blueprints/schema.json | 15 +++- schema.yml | 36 +++++++++ .../admin/admin-settings/AdminSettingsForm.ts | 19 +++++ .../user-settings/tokens/UserTokenForm.ts | 21 ++++- .../user-settings/tokens/UserTokenList.ts | 6 +- website/docs/installation/configuration.mdx | 8 -- website/docs/releases/2024/next.md | 60 ++++++++++++++ website/docs/user-group-role/user/user_ref.md | 8 ++ 24 files changed, 520 insertions(+), 28 deletions(-) create mode 100644 authentik/core/migrations/0034_alter_authenticatedsession_expires_and_more.py create mode 100644 authentik/enterprise/providers/rac/migrations/0004_alter_connectiontoken_expires.py create mode 100644 authentik/events/migrations/0006_alter_systemtask_expires.py create mode 100644 authentik/providers/oauth2/migrations/0018_alter_accesstoken_expires_and_more.py create mode 100644 authentik/stages/consent/migrations/0006_alter_userconsent_expires.py create mode 100644 authentik/stages/invitation/migrations/0008_alter_invitation_expires.py create mode 100644 authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py create mode 100644 website/docs/releases/2024/next.md diff --git a/authentik/core/api/tokens.py b/authentik/core/api/tokens.py index 9c94992c57..5fb185cacb 100644 --- a/authentik/core/api/tokens.py +++ b/authentik/core/api/tokens.py @@ -20,9 +20,18 @@ from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.core.api.used_by import UsedByMixin from authentik.core.api.users import UserSerializer from authentik.core.api.utils import PassiveSerializer -from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents +from authentik.core.models import ( + USER_ATTRIBUTE_TOKEN_EXPIRING, + USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, + Token, + TokenIntents, + User, + default_token_duration, + token_expires_from_timedelta, +) from authentik.events.models import Event, EventAction from authentik.events.utils import model_to_dict +from authentik.lib.utils.time import timedelta_from_string from authentik.rbac.decorators import permission_required @@ -49,6 +58,30 @@ class TokenSerializer(ManagedSerializer, ModelSerializer): attrs.setdefault("intent", TokenIntents.INTENT_API) if attrs.get("intent") not in [TokenIntents.INTENT_API, TokenIntents.INTENT_APP_PASSWORD]: raise ValidationError({"intent": f"Invalid intent {attrs.get('intent')}"}) + + if attrs.get("intent") == TokenIntents.INTENT_APP_PASSWORD: + # user IS in attrs + user: User = attrs.get("user") + max_token_lifetime = user.group_attributes(request).get( + USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, + ) + max_token_lifetime_dt = default_token_duration() + if max_token_lifetime is not None: + try: + max_token_lifetime_dt = timedelta_from_string(max_token_lifetime) + except ValueError: + max_token_lifetime_dt = default_token_duration() + + if "expires" in attrs and attrs.get("expires") > token_expires_from_timedelta( + max_token_lifetime_dt + ): + raise ValidationError( + {"expires": f"Token expires exceeds maximum lifetime ({max_token_lifetime})."} + ) + elif attrs.get("intent") == TokenIntents.INTENT_API: + # For API tokens, expires cannot be overridden + attrs["expires"] = default_token_duration() + return attrs class Meta: diff --git a/authentik/core/migrations/0012_auto_20201003_1737_squashed_0016_auto_20201202_2234.py b/authentik/core/migrations/0012_auto_20201003_1737_squashed_0016_auto_20201202_2234.py index 8d1d0dd97f..5b8cb38ab2 100644 --- a/authentik/core/migrations/0012_auto_20201003_1737_squashed_0016_auto_20201202_2234.py +++ b/authentik/core/migrations/0012_auto_20201003_1737_squashed_0016_auto_20201202_2234.py @@ -5,6 +5,7 @@ from django.db import migrations, models from django.db.backends.base.schema import BaseDatabaseSchemaEditor import authentik.core.models +from authentik.lib.generators import generate_id def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): @@ -16,6 +17,10 @@ def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): token.save() +def default_token_key(): + return generate_id(60) + + class Migration(migrations.Migration): replaces = [ ("authentik_core", "0012_auto_20201003_1737"), @@ -62,7 +67,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="token", name="key", - field=models.TextField(default=authentik.core.models.default_token_key), + field=models.TextField(default=default_token_key), ), migrations.AlterUniqueTogether( name="token", diff --git a/authentik/core/migrations/0034_alter_authenticatedsession_expires_and_more.py b/authentik/core/migrations/0034_alter_authenticatedsession_expires_and_more.py new file mode 100644 index 0000000000..dd62bcf852 --- /dev/null +++ b/authentik/core/migrations/0034_alter_authenticatedsession_expires_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.2 on 2024-02-29 10:15 + +from django.db import migrations, models + +import authentik.core.models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_core", "0033_alter_user_options"), + ("authentik_tenants", "0002_tenant_default_token_duration_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="authenticatedsession", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + migrations.AlterField( + model_name="token", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + migrations.AlterField( + model_name="token", + name="key", + field=models.TextField(default=authentik.core.models.default_token_key), + ), + ] diff --git a/authentik/core/models.py b/authentik/core/models.py index 7153d72680..7ec61a306d 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -1,6 +1,6 @@ """authentik core models""" -from datetime import timedelta +from datetime import datetime, timedelta from hashlib import sha256 from typing import Any, Optional, Self from uuid import uuid4 @@ -25,15 +25,16 @@ from authentik.blueprints.models import ManagedModel from authentik.core.exceptions import PropertyMappingExpressionException from authentik.core.types import UILoginButton, UserSettingSerializer from authentik.lib.avatars import get_avatar -from authentik.lib.config import CONFIG from authentik.lib.generators import generate_id from authentik.lib.models import ( CreatedUpdatedModel, DomainlessFormattedURLValidator, SerializerModel, ) +from authentik.lib.utils.time import timedelta_from_string from authentik.policies.models import PolicyBindingModel -from authentik.tenants.utils import get_unique_identifier +from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGTH +from authentik.tenants.utils import get_current_tenant, get_unique_identifier LOGGER = get_logger() USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" @@ -42,13 +43,13 @@ USER_ATTRIBUTE_EXPIRES = "goauthentik.io/user/expires" USER_ATTRIBUTE_DELETE_ON_LOGOUT = "goauthentik.io/user/delete-on-logout" USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec +USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME = "goauthentik.io/user/token-maximum-lifetime" # nosec USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username" USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name" USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email" USER_PATH_SYSTEM_PREFIX = "goauthentik.io" USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts" - options.DEFAULT_NAMES = options.DEFAULT_NAMES + ( # used_by API that allows models to specify if they shadow an object # for example the proxy provider which is built on top of an oauth provider @@ -59,16 +60,33 @@ options.DEFAULT_NAMES = options.DEFAULT_NAMES + ( ) -def default_token_duration(): +def default_token_duration() -> datetime: """Default duration a Token is valid""" - return now() + timedelta(minutes=30) + current_tenant = get_current_tenant() + token_duration = ( + current_tenant.default_token_duration + if hasattr(current_tenant, "default_token_duration") + else DEFAULT_TOKEN_DURATION + ) + return now() + timedelta_from_string(token_duration) -def default_token_key(): +def token_expires_from_timedelta(dt: timedelta) -> datetime: + """Return a `datetime.datetime` object with the duration of the Token""" + return now() + dt + + +def default_token_key() -> str: """Default token key""" + current_tenant = get_current_tenant() + token_length = ( + current_tenant.default_token_length + if hasattr(current_tenant, "default_token_length") + else DEFAULT_TOKEN_LENGTH + ) # We use generate_id since the chars in the key should be easy # to use in Emails (for verification) and URLs (for recovery) - return generate_id(CONFIG.get_int("default_token_length")) + return generate_id(token_length) class UserTypes(models.TextChoices): @@ -627,7 +645,7 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel): class ExpiringModel(models.Model): """Base Model which can expire, and is automatically cleaned up.""" - expires = models.DateTimeField(default=default_token_duration) + expires = models.DateTimeField(default=None, null=True) expiring = models.BooleanField(default=True) class Meta: diff --git a/authentik/core/signals.py b/authentik/core/signals.py index 75695a135c..228d59ce4c 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -10,7 +10,14 @@ from django.dispatch import receiver from django.http.request import HttpRequest from structlog.stdlib import get_logger -from authentik.core.models import Application, AuthenticatedSession, BackchannelProvider, User +from authentik.core.models import ( + Application, + AuthenticatedSession, + BackchannelProvider, + ExpiringModel, + User, + default_token_duration, +) # Arguments: user: User, password: str password_changed = Signal() @@ -61,3 +68,12 @@ def backchannel_provider_pre_save(sender: type[Model], instance: Model, **_): if not isinstance(instance, BackchannelProvider): return instance.is_backchannel = True + + +@receiver(pre_save) +def expiring_model_pre_save(sender: type[Model], instance: Model, **_): + """Ensure expires is set on ExpiringModels that are set to expire""" + if not issubclass(sender, ExpiringModel): + return + if instance.expiring and instance.expires is None: + instance.expires = default_token_duration() diff --git a/authentik/core/tests/test_token_api.py b/authentik/core/tests/test_token_api.py index 26e6740604..c70d1b98d3 100644 --- a/authentik/core/tests/test_token_api.py +++ b/authentik/core/tests/test_token_api.py @@ -1,5 +1,6 @@ """Test token API""" +from datetime import datetime, timedelta from json import loads from django.urls.base import reverse @@ -7,7 +8,13 @@ from guardian.shortcuts import get_anonymous_user from rest_framework.test import APITestCase from authentik.core.api.tokens import TokenSerializer -from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User +from authentik.core.models import ( + USER_ATTRIBUTE_TOKEN_EXPIRING, + USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME, + Token, + TokenIntents, + User, +) from authentik.core.tests.utils import create_test_admin_user from authentik.lib.generators import generate_id @@ -76,6 +83,77 @@ class TestTokenAPI(APITestCase): self.assertEqual(token.intent, TokenIntents.INTENT_API) self.assertEqual(token.expiring, False) + def test_token_create_expiring(self): + """Test token creation endpoint""" + self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True + self.user.save() + response = self.client.post( + reverse("authentik_api:token-list"), {"identifier": "test-token"} + ) + self.assertEqual(response.status_code, 201) + token = Token.objects.get(identifier="test-token") + self.assertEqual(token.user, self.user) + self.assertEqual(token.intent, TokenIntents.INTENT_API) + self.assertEqual(token.expiring, True) + + def test_token_create_expiring_custom_ok(self): + """Test token creation endpoint""" + self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True + self.user.attributes[USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME] = "hours=2" + self.user.save() + expires = datetime.now() + timedelta(hours=1) + response = self.client.post( + reverse("authentik_api:token-list"), + { + "identifier": "test-token", + "expires": expires, + "intent": TokenIntents.INTENT_APP_PASSWORD, + }, + ) + self.assertEqual(response.status_code, 201) + token = Token.objects.get(identifier="test-token") + self.assertEqual(token.user, self.user) + self.assertEqual(token.intent, TokenIntents.INTENT_APP_PASSWORD) + self.assertEqual(token.expiring, True) + self.assertEqual(token.expires.timestamp(), expires.timestamp()) + + def test_token_create_expiring_custom_nok(self): + """Test token creation endpoint""" + self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True + self.user.attributes[USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME] = "hours=2" + self.user.save() + expires = datetime.now() + timedelta(hours=3) + response = self.client.post( + reverse("authentik_api:token-list"), + { + "identifier": "test-token", + "expires": expires, + "intent": TokenIntents.INTENT_APP_PASSWORD, + }, + ) + self.assertEqual(response.status_code, 400) + + def test_token_create_expiring_custom_api(self): + """Test token creation endpoint""" + self.user.attributes[USER_ATTRIBUTE_TOKEN_EXPIRING] = True + self.user.attributes[USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME] = "hours=2" + self.user.save() + expires = datetime.now() + timedelta(seconds=3) + response = self.client.post( + reverse("authentik_api:token-list"), + { + "identifier": "test-token", + "expires": expires, + "intent": TokenIntents.INTENT_API, + }, + ) + self.assertEqual(response.status_code, 201) + token = Token.objects.get(identifier="test-token") + self.assertEqual(token.user, self.user) + self.assertEqual(token.intent, TokenIntents.INTENT_API) + self.assertEqual(token.expiring, True) + self.assertNotEqual(token.expires.timestamp(), expires.timestamp()) + def test_list(self): """Test Token List (Test normal authentication)""" Token.objects.all().delete() diff --git a/authentik/enterprise/providers/rac/migrations/0004_alter_connectiontoken_expires.py b/authentik/enterprise/providers/rac/migrations/0004_alter_connectiontoken_expires.py new file mode 100644 index 0000000000..60d1f7a253 --- /dev/null +++ b/authentik/enterprise/providers/rac/migrations/0004_alter_connectiontoken_expires.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-02-29 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_providers_rac", "0001_squashed_0003_alter_connectiontoken_options_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="connectiontoken", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/authentik/events/migrations/0006_alter_systemtask_expires.py b/authentik/events/migrations/0006_alter_systemtask_expires.py new file mode 100644 index 0000000000..9b5c2738ca --- /dev/null +++ b/authentik/events/migrations/0006_alter_systemtask_expires.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.2 on 2024-02-29 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_events", + "0004_systemtask_squashed_0005_remove_systemtask_finish_timestamp_and_more", + ), + ] + + operations = [ + migrations.AlterField( + model_name="systemtask", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 22eda58ae8..f4a05d8e15 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -110,7 +110,6 @@ events: asn: "/geoip/GeoLite2-ASN.mmdb" cert_discovery_dir: /certs -default_token_length: 60 tenants: enabled: false diff --git a/authentik/providers/oauth2/id_token.py b/authentik/providers/oauth2/id_token.py index d21979a636..7b92804c62 100644 --- a/authentik/providers/oauth2/id_token.py +++ b/authentik/providers/oauth2/id_token.py @@ -8,6 +8,7 @@ from django.http import HttpRequest from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from authentik.core.models import default_token_duration from authentik.events.signals import get_login_event from authentik.lib.generators import generate_id from authentik.providers.oauth2.constants import ( @@ -87,7 +88,9 @@ class IDToken: ) -> "IDToken": """Create ID Token""" id_token = IDToken(provider, token, **kwargs) - id_token.exp = int(token.expires.timestamp()) + id_token.exp = int( + (token.expires if token.expires is not None else default_token_duration()).timestamp() + ) id_token.iss = provider.get_issuer(request) id_token.aud = provider.client_id id_token.claims = {} diff --git a/authentik/providers/oauth2/migrations/0018_alter_accesstoken_expires_and_more.py b/authentik/providers/oauth2/migrations/0018_alter_accesstoken_expires_and_more.py new file mode 100644 index 0000000000..249f2f3778 --- /dev/null +++ b/authentik/providers/oauth2/migrations/0018_alter_accesstoken_expires_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.2 on 2024-02-29 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "authentik_providers_oauth2", + "0017_accesstoken_session_id_authorizationcode_session_id_and_more", + ), + ] + + operations = [ + migrations.AlterField( + model_name="accesstoken", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + migrations.AlterField( + model_name="authorizationcode", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + migrations.AlterField( + model_name="devicetoken", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + migrations.AlterField( + model_name="refreshtoken", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/authentik/stages/consent/migrations/0006_alter_userconsent_expires.py b/authentik/stages/consent/migrations/0006_alter_userconsent_expires.py new file mode 100644 index 0000000000..7f186e7965 --- /dev/null +++ b/authentik/stages/consent/migrations/0006_alter_userconsent_expires.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-02-29 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_consent", "0005_alter_consentstage_mode"), + ] + + operations = [ + migrations.AlterField( + model_name="userconsent", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/authentik/stages/invitation/migrations/0008_alter_invitation_expires.py b/authentik/stages/invitation/migrations/0008_alter_invitation_expires.py new file mode 100644 index 0000000000..45a04df432 --- /dev/null +++ b/authentik/stages/invitation/migrations/0008_alter_invitation_expires.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-02-29 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_stages_invitation", "0007_invitation_flow"), + ] + + operations = [ + migrations.AlterField( + model_name="invitation", + name="expires", + field=models.DateTimeField(default=None, null=True), + ), + ] diff --git a/authentik/tenants/api/settings.py b/authentik/tenants/api/settings.py index 1c0a63670c..60a37225b3 100644 --- a/authentik/tenants/api/settings.py +++ b/authentik/tenants/api/settings.py @@ -23,6 +23,8 @@ class SettingsSerializer(ModelSerializer): "footer_links", "gdpr_compliance", "impersonation", + "default_token_duration", + "default_token_length", ] diff --git a/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py b/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py new file mode 100644 index 0000000000..0aa7accfc8 --- /dev/null +++ b/authentik/tenants/migrations/0002_tenant_default_token_duration_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 5.0.2 on 2024-02-20 08:26 + +import django.core.validators +from django.db import migrations, models + +import authentik.lib.utils.time +from authentik.lib.config import CONFIG + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_tenants", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="tenant", + name="default_token_duration", + field=models.TextField( + default=CONFIG.get("default_token_duration", "minutes=30"), + help_text="Default token duration", + validators=[authentik.lib.utils.time.timedelta_string_validator], + ), + ), + migrations.AddField( + model_name="tenant", + name="default_token_length", + field=models.PositiveIntegerField( + default=CONFIG.get_int("default_token_length", 60), + help_text="Default token length", + validators=[django.core.validators.MinValueValidator(1)], + ), + ), + ] diff --git a/authentik/tenants/models.py b/authentik/tenants/models.py index 1fe41bf115..4dbf8684d8 100644 --- a/authentik/tenants/models.py +++ b/authentik/tenants/models.py @@ -5,6 +5,7 @@ from uuid import uuid4 from django.apps import apps from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator from django.db import models from django.db.utils import IntegrityError from django.dispatch import receiver @@ -22,6 +23,9 @@ LOGGER = get_logger() VALID_SCHEMA_NAME = re.compile(r"^t_[a-z0-9]{1,61}$") +DEFAULT_TOKEN_DURATION = "minutes=30" # nosec +DEFAULT_TOKEN_LENGTH = 60 + def _validate_schema_name(name): if not VALID_SCHEMA_NAME.match(name): @@ -81,6 +85,16 @@ class Tenant(TenantMixin, SerializerModel): impersonation = models.BooleanField( help_text=_("Globally enable/disable impersonation."), default=True ) + default_token_duration = models.TextField( + help_text=_("Default token duration"), + default=DEFAULT_TOKEN_DURATION, + validators=[timedelta_string_validator], + ) + default_token_length = models.PositiveIntegerField( + help_text=_("Default token length"), + default=DEFAULT_TOKEN_LENGTH, + validators=[MinValueValidator(1)], + ) def save(self, *args, **kwargs): if self.schema_name == "template": diff --git a/blueprints/schema.json b/blueprints/schema.json index 757f71cd64..7663333cc4 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -6025,7 +6025,10 @@ "type": "object", "properties": { "expires": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "date-time", "title": "Expires" }, @@ -6794,7 +6797,10 @@ "title": "Name" }, "expires": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "date-time", "title": "Expires" }, @@ -7985,7 +7991,10 @@ "title": "Description" }, "expires": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "date-time", "title": "Expires" }, diff --git a/schema.yml b/schema.yml index 27e3a56977..351b7f3434 100644 --- a/schema.yml +++ b/schema.yml @@ -29976,6 +29976,7 @@ components: expires: type: string format: date-time + nullable: true required: - asn - current @@ -32674,6 +32675,7 @@ components: expires: type: string format: date-time + nullable: true scope: type: array items: @@ -33774,6 +33776,7 @@ components: expires: type: string format: date-time + nullable: true fixed_data: type: object additionalProperties: {} @@ -33810,6 +33813,7 @@ components: expires: type: string format: date-time + nullable: true fixed_data: type: object additionalProperties: {} @@ -38121,6 +38125,7 @@ components: expires: type: string format: date-time + nullable: true fixed_data: type: object additionalProperties: {} @@ -39421,6 +39426,15 @@ components: impersonation: type: boolean description: Globally enable/disable impersonation. + default_token_duration: + type: string + minLength: 1 + description: Default token duration + default_token_length: + type: integer + maximum: 2147483647 + minimum: 1 + description: Default token length PatchedSourceStageRequest: type: object description: SourceStage Serializer @@ -39498,6 +39512,7 @@ components: expires: type: string format: date-time + nullable: true expiring: type: boolean PatchedUserDeleteStageRequest: @@ -42450,6 +42465,14 @@ components: impersonation: type: boolean description: Globally enable/disable impersonation. + default_token_duration: + type: string + description: Default token duration + default_token_length: + type: integer + maximum: 2147483647 + minimum: 1 + description: Default token length SettingsRequest: type: object description: Settings Serializer @@ -42481,6 +42504,15 @@ components: impersonation: type: boolean description: Globally enable/disable impersonation. + default_token_duration: + type: string + minLength: 1 + description: Default token duration + default_token_length: + type: integer + maximum: 2147483647 + minimum: 1 + description: Default token length SeverityEnum: enum: - notice @@ -43148,6 +43180,7 @@ components: expires: type: string format: date-time + nullable: true expiring: type: boolean required: @@ -43173,6 +43206,7 @@ components: expires: type: string format: date-time + nullable: true scope: type: array items: @@ -43217,6 +43251,7 @@ components: expires: type: string format: date-time + nullable: true expiring: type: boolean required: @@ -43455,6 +43490,7 @@ components: expires: type: string format: date-time + nullable: true expiring: type: boolean user: diff --git a/web/src/admin/admin-settings/AdminSettingsForm.ts b/web/src/admin/admin-settings/AdminSettingsForm.ts index 62b9161827..b459945e1e 100644 --- a/web/src/admin/admin-settings/AdminSettingsForm.ts +++ b/web/src/admin/admin-settings/AdminSettingsForm.ts @@ -1,5 +1,6 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; +import "@goauthentik/components/ak-number-input"; import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-text-input"; import "@goauthentik/elements/CodeMirror"; @@ -192,6 +193,24 @@ export class AdminSettingsForm extends Form { help=${msg("Globally enable/disable impersonation.")} > + + ${msg("Default duration for generated tokens")} +

+ `} + > +
+ `; } } diff --git a/web/src/user/user-settings/tokens/UserTokenForm.ts b/web/src/user/user-settings/tokens/UserTokenForm.ts index 78ce3413df..b3d2088b6f 100644 --- a/web/src/user/user-settings/tokens/UserTokenForm.ts +++ b/web/src/user/user-settings/tokens/UserTokenForm.ts @@ -28,6 +28,7 @@ export class UserTokenForm extends ModelForm { async send(data: Token): Promise { if (this.instance) { + data.intent = this.instance.intent; return new CoreApi(DEFAULT_CONFIG).coreTokensUpdate({ identifier: this.instance.identifier, tokenRequest: data, @@ -41,6 +42,14 @@ export class UserTokenForm extends ModelForm { } renderForm(): TemplateResult { + const now = new Date(); + const expiringDate = this.instance?.expires + ? new Date( + this.instance.expires.getTime() - + this.instance.expires.getTimezoneOffset() * 60000, + ) + : new Date(now.getTime() + 30 * 60000 - now.getTimezoneOffset() * 60000); + return html` { value="${ifDefined(this.instance?.description)}" class="pf-c-form-control" /> - `; + + ${this.intent == IntentEnum.AppPassword + ? html` + + ` + : html``}`; } } diff --git a/web/src/user/user-settings/tokens/UserTokenList.ts b/web/src/user/user-settings/tokens/UserTokenList.ts index e58eee893e..9cc8e588ea 100644 --- a/web/src/user/user-settings/tokens/UserTokenList.ts +++ b/web/src/user/user-settings/tokens/UserTokenList.ts @@ -160,7 +160,11 @@ export class UserTokenList extends Table { ${msg("Update")} ${msg("Update Token")} - +