Skip to content

Commit

Permalink
Merge branch 'main' into dev
Browse files Browse the repository at this point in the history
* main:
  website/docs: add more info and links about enforciing unique email addresses (#9154)
  core: bump goauthentik.io/api/v3 from 3.2024022.7 to 3.2024022.8 (#9215)
  web: bump API Client version (#9214)
  stages/authenticator_validate: add ability to limit webauthn device types (#9180)
  web: bump API Client version (#9213)
  core: add user settable token durations (#7410)
  core, web: update translations (#9205)
  web: bump typescript from 5.4.4 to 5.4.5 in /tests/wdio (#9206)
  web: bump chromedriver from 123.0.2 to 123.0.3 in /tests/wdio (#9207)
  core: bump sentry-sdk from 1.44.1 to 1.45.0 (#9208)
  web: bump typescript from 5.4.4 to 5.4.5 in /web (#9209)
  website: bump typescript from 5.4.4 to 5.4.5 in /website (#9210)
  core: bump python from 3.12.2-slim-bookworm to 3.12.3-slim-bookworm (#9211)
  • Loading branch information
kensternberg-authentik committed Apr 11, 2024
2 parents 23665d1 + c89b7b7 commit cacdf64
Show file tree
Hide file tree
Showing 50 changed files with 978 additions and 169 deletions.
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"

# Stage 5: Python dependencies
FROM docker.io/python:3.12.2-slim-bookworm AS python-deps
FROM docker.io/python:3.12.3-slim-bookworm AS python-deps

WORKDIR /ak-root/poetry

Expand All @@ -110,7 +110,7 @@ RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
poetry install --only=main --no-ansi --no-interaction --no-root"

# Stage 6: Run
FROM docker.io/python:3.12.2-slim-bookworm AS final-image
FROM docker.io/python:3.12.3-slim-bookworm AS final-image

ARG GIT_BUILD_HASH
ARG VERSION
Expand Down
35 changes: 34 additions & 1 deletion authentik/core/api/tokens.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,18 @@
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


Expand All @@ -49,6 +58,30 @@ def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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):
Expand All @@ -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"),
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
36 changes: 27 additions & 9 deletions authentik/core/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -25,15 +25,16 @@
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"
Expand All @@ -42,13 +43,13 @@
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
Expand All @@ -59,16 +60,33 @@
)


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 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():

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):
Expand Down Expand Up @@ -627,7 +645,7 @@ class Meta:
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:
Expand Down
18 changes: 17 additions & 1 deletion authentik/core/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
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()
Expand Down Expand Up @@ -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()
80 changes: 79 additions & 1 deletion authentik/core/tests/test_token_api.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
"""Test token API"""

from datetime import datetime, timedelta
from json import loads

from django.urls.base import reverse
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

Expand Down Expand Up @@ -76,6 +83,77 @@ def test_token_create_non_expiring(self):
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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
21 changes: 21 additions & 0 deletions authentik/events/migrations/0006_alter_systemtask_expires.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 0 additions & 1 deletion authentik/lib/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ events:
asn: "/geoip/GeoLite2-ASN.mmdb"

cert_discovery_dir: /certs
default_token_length: 60

tenants:
enabled: false
Expand Down
Loading

0 comments on commit cacdf64

Please sign in to comment.