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")}
-
+