Compare commits
	
		
			2 Commits
		
	
	
		
			website/do
			...
			events/imp
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1fcef476c3 | |||
| e8b6b3366b | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2025.6.3 | ||||
| current_version = 2025.6.2 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | ||||
|  | ||||
| @ -38,8 +38,6 @@ jobs: | ||||
|       # Needed for attestation | ||||
|       id-token: write | ||||
|       attestations: write | ||||
|       # Needed for checkout | ||||
|       contents: read | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - uses: docker/setup-qemu-action@v3.6.0 | ||||
|  | ||||
							
								
								
									
										3
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/workflows/ci-main-daily.yml
									
									
									
									
										vendored
									
									
								
							| @ -9,15 +9,14 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   test-container: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         version: | ||||
|           - docs | ||||
|           - version-2025-4 | ||||
|           - version-2025-2 | ||||
|           - version-2024-12 | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|       - run: | | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -247,13 +247,11 @@ jobs: | ||||
|       # Needed for attestation | ||||
|       id-token: write | ||||
|       attestations: write | ||||
|       # Needed for checkout | ||||
|       contents: read | ||||
|     needs: ci-core-mark | ||||
|     uses: ./.github/workflows/_reusable-docker-build.yaml | ||||
|     secrets: inherit | ||||
|     with: | ||||
|       image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }} | ||||
|       image_name: ghcr.io/goauthentik/dev-server | ||||
|       release: false | ||||
|   pr-comment: | ||||
|     needs: | ||||
|  | ||||
							
								
								
									
										1
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -59,7 +59,6 @@ jobs: | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|   build-container: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     timeout-minutes: 120 | ||||
|     needs: | ||||
|       - ci-outpost-mark | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -63,7 +63,6 @@ jobs: | ||||
|         working-directory: website/ | ||||
|         run: npm run ${{ matrix.job }} | ||||
|   build-container: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       # Needed to upload container images to ghcr.io | ||||
| @ -123,4 +122,3 @@ jobs: | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|           allowed-skips: ${{ github.repository == 'goauthentik/authentik-internal' && 'build-container' || '[]' }} | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @ -2,7 +2,7 @@ name: "CodeQL" | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: [main, next, version*] | ||||
|     branches: [main, "*", next, version*] | ||||
|   pull_request: | ||||
|     branches: [main] | ||||
|   schedule: | ||||
|  | ||||
							
								
								
									
										21
									
								
								.github/workflows/repo-mirror-cleanup.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										21
									
								
								.github/workflows/repo-mirror-cleanup.yml
									
									
									
									
										vendored
									
									
								
							| @ -1,21 +0,0 @@ | ||||
| name: "authentik-repo-mirror-cleanup" | ||||
|  | ||||
| on: | ||||
|   workflow_dispatch: | ||||
|  | ||||
| jobs: | ||||
|   to_internal: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v4 | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|       - if: ${{ env.MIRROR_KEY != '' }} | ||||
|         uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb | ||||
|         with: | ||||
|           target_repo_url: git@github.com:goauthentik/authentik-internal.git | ||||
|           ssh_private_key: ${{ secrets.GH_MIRROR_KEY }} | ||||
|           args: --tags --force --prune | ||||
|         env: | ||||
|           MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }} | ||||
							
								
								
									
										9
									
								
								.github/workflows/repo-mirror.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/repo-mirror.yml
									
									
									
									
										vendored
									
									
								
							| @ -11,10 +11,11 @@ jobs: | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|       - if: ${{ env.MIRROR_KEY != '' }} | ||||
|         uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb | ||||
|         uses: pixta-dev/repository-mirroring-action@v1 | ||||
|         with: | ||||
|           target_repo_url: git@github.com:goauthentik/authentik-internal.git | ||||
|           ssh_private_key: ${{ secrets.GH_MIRROR_KEY }} | ||||
|           args: --tags --force | ||||
|           target_repo_url: | ||||
|             git@github.com:goauthentik/authentik-internal.git | ||||
|           ssh_private_key: | ||||
|             ${{ secrets.GH_MIRROR_KEY }} | ||||
|         env: | ||||
|           MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }} | ||||
|  | ||||
| @ -16,7 +16,6 @@ env: | ||||
|  | ||||
| jobs: | ||||
|   compile: | ||||
|     if: ${{ github.repository != 'goauthentik/authentik-internal' }} | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - id: generate_token | ||||
|  | ||||
							
								
								
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -6,15 +6,13 @@ | ||||
|         "!Context scalar", | ||||
|         "!Enumerate sequence", | ||||
|         "!Env scalar", | ||||
|         "!Env sequence", | ||||
|         "!Find sequence", | ||||
|         "!Format sequence", | ||||
|         "!If sequence", | ||||
|         "!Index scalar", | ||||
|         "!KeyOf scalar", | ||||
|         "!Value scalar", | ||||
|         "!AtIndex scalar", | ||||
|         "!ParseJSON scalar" | ||||
|         "!AtIndex scalar" | ||||
|     ], | ||||
|     "typescript.preferences.importModuleSpecifier": "non-relative", | ||||
|     "typescript.preferences.importModuleSpecifierEnding": "index", | ||||
|  | ||||
| @ -75,7 +75,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \ | ||||
|     /bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" | ||||
|  | ||||
| # Stage 4: Download uv | ||||
| FROM ghcr.io/astral-sh/uv:0.7.17 AS uv | ||||
| FROM ghcr.io/astral-sh/uv:0.7.13 AS uv | ||||
| # Stage 5: Base python image | ||||
| FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base | ||||
|  | ||||
|  | ||||
							
								
								
									
										6
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								Makefile
									
									
									
									
									
								
							| @ -150,9 +150,9 @@ gen-client-ts: gen-clean-ts  ## Build and install the authentik API for Typescri | ||||
| 		--additional-properties=npmVersion=${NPM_VERSION} \ | ||||
| 		--git-repo-id authentik \ | ||||
| 		--git-user-id goauthentik | ||||
|  | ||||
| 	cd ${PWD}/${GEN_API_TS} && npm link | ||||
| 	cd ${PWD}/web && npm link @goauthentik/api | ||||
| 	mkdir -p web/node_modules/@goauthentik/api | ||||
| 	cd ${PWD}/${GEN_API_TS} && npm i | ||||
| 	\cp -rf ${PWD}/${GEN_API_TS}/* web/node_modules/@goauthentik/api | ||||
|  | ||||
| gen-client-py: gen-clean-py ## Build and install the authentik API for Python | ||||
| 	docker run \ | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|  | ||||
| from os import environ | ||||
|  | ||||
| __version__ = "2025.6.3" | ||||
| __version__ = "2025.6.2" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -37,7 +37,6 @@ entries: | ||||
|     - attrs: | ||||
|           attributes: | ||||
|               env_null: !Env [bar-baz, null] | ||||
|               json_parse: !ParseJSON '{"foo": "bar"}' | ||||
|               policy_pk1: | ||||
|                   !Format [ | ||||
|                       "%s-%s", | ||||
|  | ||||
| @ -35,6 +35,6 @@ def blueprint_tester(file_name: Path) -> Callable: | ||||
|  | ||||
|  | ||||
| for blueprint_file in Path("blueprints/").glob("**/*.yaml"): | ||||
|     if "local" in str(blueprint_file) or "testing" in str(blueprint_file): | ||||
|     if "local" in str(blueprint_file): | ||||
|         continue | ||||
|     setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file)) | ||||
|  | ||||
| @ -5,6 +5,7 @@ from collections.abc import Callable | ||||
| from django.apps import apps | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.blueprints.v1.importer import is_model_allowed | ||||
| from authentik.lib.models import SerializerModel | ||||
| from authentik.providers.oauth2.models import RefreshToken | ||||
|  | ||||
| @ -21,13 +22,10 @@ def serializer_tester_factory(test_model: type[SerializerModel]) -> Callable: | ||||
|             return | ||||
|         model_class = test_model() | ||||
|         self.assertTrue(isinstance(model_class, SerializerModel)) | ||||
|         # Models that have subclasses don't have to have a serializer | ||||
|         if len(test_model.__subclasses__()) > 0: | ||||
|             return | ||||
|         self.assertIsNotNone(model_class.serializer) | ||||
|         if model_class.serializer.Meta().model == RefreshToken: | ||||
|             return | ||||
|         self.assertTrue(issubclass(test_model, model_class.serializer.Meta().model)) | ||||
|         self.assertEqual(model_class.serializer.Meta().model, test_model) | ||||
|  | ||||
|     return tester | ||||
|  | ||||
| @ -36,6 +34,6 @@ for app in apps.get_app_configs(): | ||||
|     if not app.label.startswith("authentik"): | ||||
|         continue | ||||
|     for model in app.get_models(): | ||||
|         if not issubclass(model, SerializerModel): | ||||
|         if not is_model_allowed(model): | ||||
|             continue | ||||
|         setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model)) | ||||
|  | ||||
| @ -215,7 +215,6 @@ class TestBlueprintsV1(TransactionTestCase): | ||||
|                     }, | ||||
|                     "nested_context": "context-nested-value", | ||||
|                     "env_null": None, | ||||
|                     "json_parse": {"foo": "bar"}, | ||||
|                     "at_index_sequence": "foo", | ||||
|                     "at_index_sequence_default": "non existent", | ||||
|                     "at_index_mapping": 2, | ||||
|  | ||||
| @ -6,7 +6,6 @@ from copy import copy | ||||
| from dataclasses import asdict, dataclass, field, is_dataclass | ||||
| from enum import Enum | ||||
| from functools import reduce | ||||
| from json import JSONDecodeError, loads | ||||
| from operator import ixor | ||||
| from os import getenv | ||||
| from typing import Any, Literal, Union | ||||
| @ -292,22 +291,6 @@ class Context(YAMLTag): | ||||
|         return value | ||||
|  | ||||
|  | ||||
| class ParseJSON(YAMLTag): | ||||
|     """Parse JSON from context/env/etc value""" | ||||
|  | ||||
|     raw: str | ||||
|  | ||||
|     def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None: | ||||
|         super().__init__() | ||||
|         self.raw = node.value | ||||
|  | ||||
|     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||
|         try: | ||||
|             return loads(self.raw) | ||||
|         except JSONDecodeError as exc: | ||||
|             raise EntryInvalidError.from_entry(exc, entry) from exc | ||||
|  | ||||
|  | ||||
| class Format(YAMLTag): | ||||
|     """Format a string""" | ||||
|  | ||||
| @ -683,7 +666,6 @@ class BlueprintLoader(SafeLoader): | ||||
|         self.add_constructor("!Value", Value) | ||||
|         self.add_constructor("!Index", Index) | ||||
|         self.add_constructor("!AtIndex", AtIndex) | ||||
|         self.add_constructor("!ParseJSON", ParseJSON) | ||||
|  | ||||
|  | ||||
| class EntryInvalidError(SentryIgnoredException): | ||||
|  | ||||
| @ -1,6 +1,8 @@ | ||||
| """Authenticator Devices API Views""" | ||||
|  | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import OpenApiParameter, extend_schema | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from rest_framework.fields import ( | ||||
|     BooleanField, | ||||
| @ -13,7 +15,6 @@ from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.viewsets import ViewSet | ||||
|  | ||||
| from authentik.core.api.users import ParamUserSerializer | ||||
| from authentik.core.api.utils import MetaNameSerializer | ||||
| from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice | ||||
| from authentik.stages.authenticator import device_classes, devices_for_user | ||||
| @ -22,7 +23,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice | ||||
|  | ||||
|  | ||||
| class DeviceSerializer(MetaNameSerializer): | ||||
|     """Serializer for authenticator devices""" | ||||
|     """Serializer for Duo authenticator devices""" | ||||
|  | ||||
|     pk = CharField() | ||||
|     name = CharField() | ||||
| @ -32,27 +33,22 @@ class DeviceSerializer(MetaNameSerializer): | ||||
|     last_updated = DateTimeField(read_only=True) | ||||
|     last_used = DateTimeField(read_only=True, allow_null=True) | ||||
|     extra_description = SerializerMethodField() | ||||
|     external_id = SerializerMethodField() | ||||
|  | ||||
|     def get_type(self, instance: Device) -> str: | ||||
|         """Get type of device""" | ||||
|         return instance._meta.label | ||||
|  | ||||
|     def get_extra_description(self, instance: Device) -> str | None: | ||||
|     def get_extra_description(self, instance: Device) -> str: | ||||
|         """Get extra description""" | ||||
|         if isinstance(instance, WebAuthnDevice): | ||||
|             return instance.device_type.description if instance.device_type else None | ||||
|             return ( | ||||
|                 instance.device_type.description | ||||
|                 if instance.device_type | ||||
|                 else _("Extra description not available") | ||||
|             ) | ||||
|         if isinstance(instance, EndpointDevice): | ||||
|             return instance.data.get("deviceSignals", {}).get("deviceModel") | ||||
|         return None | ||||
|  | ||||
|     def get_external_id(self, instance: Device) -> str | None: | ||||
|         """Get external Device ID""" | ||||
|         if isinstance(instance, WebAuthnDevice): | ||||
|             return instance.device_type.aaguid if instance.device_type else None | ||||
|         if isinstance(instance, EndpointDevice): | ||||
|             return instance.data.get("deviceSignals", {}).get("deviceModel") | ||||
|         return None | ||||
|         return "" | ||||
|  | ||||
|  | ||||
| class DeviceViewSet(ViewSet): | ||||
| @ -61,6 +57,7 @@ class DeviceViewSet(ViewSet): | ||||
|     serializer_class = DeviceSerializer | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     @extend_schema(responses={200: DeviceSerializer(many=True)}) | ||||
|     def list(self, request: Request) -> Response: | ||||
|         """Get all devices for current user""" | ||||
|         devices = devices_for_user(request.user) | ||||
| @ -82,11 +79,18 @@ class AdminDeviceViewSet(ViewSet): | ||||
|             yield from device_set | ||||
|  | ||||
|     @extend_schema( | ||||
|         parameters=[ParamUserSerializer], | ||||
|         parameters=[ | ||||
|             OpenApiParameter( | ||||
|                 name="user", | ||||
|                 location=OpenApiParameter.QUERY, | ||||
|                 type=OpenApiTypes.INT, | ||||
|             ) | ||||
|         ], | ||||
|         responses={200: DeviceSerializer(many=True)}, | ||||
|     ) | ||||
|     def list(self, request: Request) -> Response: | ||||
|         """Get all devices for current user""" | ||||
|         args = ParamUserSerializer(data=request.query_params) | ||||
|         args.is_valid(raise_exception=True) | ||||
|         return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data) | ||||
|         kwargs = {} | ||||
|         if "user" in request.query_params: | ||||
|             kwargs = {"user": request.query_params["user"]} | ||||
|         return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data) | ||||
|  | ||||
| @ -90,12 +90,6 @@ from authentik.stages.email.utils import TemplateEmailMessage | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| class ParamUserSerializer(PassiveSerializer): | ||||
|     """Partial serializer for query parameters to select a user""" | ||||
|  | ||||
|     user = PrimaryKeyRelatedField(queryset=User.objects.all().exclude_anonymous(), required=False) | ||||
|  | ||||
|  | ||||
| class UserGroupSerializer(ModelSerializer): | ||||
|     """Simplified Group Serializer for user's groups""" | ||||
|  | ||||
| @ -407,7 +401,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|             StrField(User, "path"), | ||||
|             BoolField(User, "is_active", nullable=True), | ||||
|             ChoiceSearchField(User, "type"), | ||||
|             JSONSearchField(User, "attributes", suggest_nested=False), | ||||
|             JSONSearchField(User, "attributes"), | ||||
|         ] | ||||
|  | ||||
|     def get_queryset(self): | ||||
|  | ||||
| @ -2,7 +2,6 @@ | ||||
|  | ||||
| from typing import Any | ||||
|  | ||||
| from django.db import models | ||||
| from django.db.models import Model | ||||
| from drf_spectacular.extensions import OpenApiSerializerFieldExtension | ||||
| from drf_spectacular.plumbing import build_basic_type | ||||
| @ -31,27 +30,7 @@ def is_dict(value: Any): | ||||
|     raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") | ||||
|  | ||||
|  | ||||
| class JSONDictField(JSONField): | ||||
|     """JSON Field which only allows dictionaries""" | ||||
|  | ||||
|     default_validators = [is_dict] | ||||
|  | ||||
|  | ||||
| class JSONExtension(OpenApiSerializerFieldExtension): | ||||
|     """Generate API Schema for JSON fields as""" | ||||
|  | ||||
|     target_class = "authentik.core.api.utils.JSONDictField" | ||||
|  | ||||
|     def map_serializer_field(self, auto_schema, direction): | ||||
|         return build_basic_type(OpenApiTypes.OBJECT) | ||||
|  | ||||
|  | ||||
| class ModelSerializer(BaseModelSerializer): | ||||
|  | ||||
|     # By default, JSON fields we have are used to store dictionaries | ||||
|     serializer_field_mapping = BaseModelSerializer.serializer_field_mapping.copy() | ||||
|     serializer_field_mapping[models.JSONField] = JSONDictField | ||||
|  | ||||
|     def create(self, validated_data): | ||||
|         instance = super().create(validated_data) | ||||
|  | ||||
| @ -92,6 +71,21 @@ class ModelSerializer(BaseModelSerializer): | ||||
|         return instance | ||||
|  | ||||
|  | ||||
| class JSONDictField(JSONField): | ||||
|     """JSON Field which only allows dictionaries""" | ||||
|  | ||||
|     default_validators = [is_dict] | ||||
|  | ||||
|  | ||||
| class JSONExtension(OpenApiSerializerFieldExtension): | ||||
|     """Generate API Schema for JSON fields as""" | ||||
|  | ||||
|     target_class = "authentik.core.api.utils.JSONDictField" | ||||
|  | ||||
|     def map_serializer_field(self, auto_schema, direction): | ||||
|         return build_basic_type(OpenApiTypes.OBJECT) | ||||
|  | ||||
|  | ||||
| class PassiveSerializer(Serializer): | ||||
|     """Base serializer class which doesn't implement create/update methods""" | ||||
|  | ||||
|  | ||||
| @ -11,7 +11,6 @@ from authentik.core.expression.exceptions import SkipObjectException | ||||
| from authentik.core.models import User | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.expression.evaluator import BaseEvaluator | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.policies.types import PolicyRequest | ||||
|  | ||||
| PROPERTY_MAPPING_TIME = Histogram( | ||||
| @ -69,12 +68,11 @@ class PropertyMappingEvaluator(BaseEvaluator): | ||||
|         # For dry-run requests we don't save exceptions | ||||
|         if self.dry_run: | ||||
|             return | ||||
|         error_string = exception_to_string(exc) | ||||
|         event = Event.new( | ||||
|             EventAction.PROPERTY_MAPPING_EXCEPTION, | ||||
|             expression=expression_source, | ||||
|             message=error_string, | ||||
|         ) | ||||
|             message="Failed to execute property mapping", | ||||
|         ).with_exception(exc) | ||||
|         if "request" in self._context: | ||||
|             req: PolicyRequest = self._context["request"] | ||||
|             if req.http_request: | ||||
|  | ||||
| @ -13,6 +13,7 @@ class Command(TenantCommand): | ||||
|         parser.add_argument("usernames", nargs="*", type=str) | ||||
|  | ||||
|     def handle_per_tenant(self, **options): | ||||
|         print(options) | ||||
|         new_type = UserTypes(options["type"]) | ||||
|         qs = ( | ||||
|             User.objects.exclude_anonymous() | ||||
|  | ||||
| @ -1082,12 +1082,6 @@ class AuthenticatedSession(SerializerModel): | ||||
|  | ||||
|     user = models.ForeignKey(User, on_delete=models.CASCADE) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[Serializer]: | ||||
|         from authentik.core.api.authenticated_sessions import AuthenticatedSessionSerializer | ||||
|  | ||||
|         return AuthenticatedSessionSerializer | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Authenticated Session") | ||||
|         verbose_name_plural = _("Authenticated Sessions") | ||||
|  | ||||
| @ -1,8 +1,10 @@ | ||||
| from hashlib import sha256 | ||||
|  | ||||
| from django.contrib.auth.signals import user_logged_out | ||||
| from django.db.models import Model | ||||
| from django.db.models.signals import post_delete, post_save, pre_delete | ||||
| from django.dispatch import receiver | ||||
| from django.http.request import HttpRequest | ||||
| from guardian.shortcuts import assign_perm | ||||
|  | ||||
| from authentik.core.models import ( | ||||
| @ -60,6 +62,31 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created: | ||||
|             instance.save() | ||||
|  | ||||
|  | ||||
| @receiver(user_logged_out) | ||||
| def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User, **_): | ||||
|     """Session revoked trigger (user logged out)""" | ||||
|     if not request.session or not request.session.session_key or not user: | ||||
|         return | ||||
|     send_ssf_event( | ||||
|         EventTypes.CAEP_SESSION_REVOKED, | ||||
|         { | ||||
|             "initiating_entity": "user", | ||||
|         }, | ||||
|         sub_id={ | ||||
|             "format": "complex", | ||||
|             "session": { | ||||
|                 "format": "opaque", | ||||
|                 "id": sha256(request.session.session_key.encode("ascii")).hexdigest(), | ||||
|             }, | ||||
|             "user": { | ||||
|                 "format": "email", | ||||
|                 "email": user.email, | ||||
|             }, | ||||
|         }, | ||||
|         request=request, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @receiver(pre_delete, sender=AuthenticatedSession) | ||||
| def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_): | ||||
|     """Session revoked trigger (users' session has been deleted) | ||||
|  | ||||
| @ -6,7 +6,7 @@ from djangoql.ast import Name | ||||
| from djangoql.exceptions import DjangoQLError | ||||
| from djangoql.queryset import apply_search | ||||
| from djangoql.schema import DjangoQLSchema | ||||
| from rest_framework.filters import BaseFilterBackend, SearchFilter | ||||
| from rest_framework.filters import SearchFilter | ||||
| from rest_framework.request import Request | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| @ -39,21 +39,19 @@ class BaseSchema(DjangoQLSchema): | ||||
|         return super().resolve_name(name) | ||||
|  | ||||
|  | ||||
| class QLSearch(BaseFilterBackend): | ||||
| class QLSearch(SearchFilter): | ||||
|     """rest_framework search filter which uses DjangoQL""" | ||||
|  | ||||
|     def __init__(self): | ||||
|         super().__init__() | ||||
|         self._fallback = SearchFilter() | ||||
|  | ||||
|     @property | ||||
|     def enabled(self): | ||||
|         return apps.get_app_config("authentik_enterprise").enabled() | ||||
|  | ||||
|     def get_search_terms(self, request: Request) -> str: | ||||
|         """Search terms are set by a ?search=... query parameter, | ||||
|         and may be comma and/or whitespace delimited.""" | ||||
|         params = request.query_params.get("search", "") | ||||
|     def get_search_terms(self, request) -> str: | ||||
|         """ | ||||
|         Search terms are set by a ?search=... query parameter, | ||||
|         and may be comma and/or whitespace delimited. | ||||
|         """ | ||||
|         params = request.query_params.get(self.search_param, "") | ||||
|         params = params.replace("\x00", "")  # strip null characters | ||||
|         return params | ||||
|  | ||||
| @ -72,9 +70,9 @@ class QLSearch(BaseFilterBackend): | ||||
|         search_query = self.get_search_terms(request) | ||||
|         schema = self.get_schema(request, view) | ||||
|         if len(search_query) == 0 or not self.enabled: | ||||
|             return self._fallback.filter_queryset(request, queryset, view) | ||||
|             return super().filter_queryset(request, queryset, view) | ||||
|         try: | ||||
|             return apply_search(queryset, search_query, schema=schema) | ||||
|         except DjangoQLError as exc: | ||||
|             LOGGER.debug("Failed to parse search expression", exc=exc) | ||||
|             return self._fallback.filter_queryset(request, queryset, view) | ||||
|             return super().filter_queryset(request, queryset, view) | ||||
|  | ||||
| @ -57,7 +57,7 @@ class QLTest(APITestCase): | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         content = loads(res.content) | ||||
|         self.assertEqual(content["pagination"]["count"], 1) | ||||
|         self.assertGreaterEqual(content["pagination"]["count"], 1) | ||||
|         self.assertEqual(content["results"][0]["username"], self.user.username) | ||||
|  | ||||
|     def test_search_json(self): | ||||
|  | ||||
| @ -97,7 +97,6 @@ class SourceStageFinal(StageView): | ||||
|         token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN) | ||||
|         self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug) | ||||
|         plan = token.plan | ||||
|         plan.context.update(self.executor.plan.context) | ||||
|         plan.context[PLAN_CONTEXT_IS_RESTORED] = token | ||||
|         response = plan.to_redirect(self.request, token.flow) | ||||
|         token.delete() | ||||
|  | ||||
| @ -90,17 +90,14 @@ class TestSourceStage(FlowTestCase): | ||||
|         plan: FlowPlan = session[SESSION_KEY_PLAN] | ||||
|         plan.insert_stage(in_memory_stage(SourceStageFinal), index=0) | ||||
|         plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token | ||||
|         plan.context["foo"] = "bar" | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         # Pretend we've just returned from the source | ||||
|         with self.assertFlowFinishes() as ff: | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True | ||||
|             ) | ||||
|             self.assertEqual(response.status_code, 200) | ||||
|             self.assertStageRedirects( | ||||
|                 response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||
|             ) | ||||
|         self.assertEqual(ff().context["foo"], "bar") | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertStageRedirects( | ||||
|             response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||
|         ) | ||||
|  | ||||
| @ -19,8 +19,8 @@ from authentik.blueprints.v1.importer import excluded_models | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.events.models import Event, EventAction, Notification | ||||
| from authentik.events.utils import model_to_dict | ||||
| from authentik.lib.sentry import should_ignore_exception | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.sentry import before_send | ||||
| from authentik.lib.utils.errors import exception_to_dict | ||||
| from authentik.stages.authenticator_static.models import StaticToken | ||||
|  | ||||
| IGNORED_MODELS = tuple( | ||||
| @ -170,14 +170,16 @@ class AuditMiddleware: | ||||
|             thread = EventNewThread( | ||||
|                 EventAction.SUSPICIOUS_REQUEST, | ||||
|                 request, | ||||
|                 message=exception_to_string(exception), | ||||
|                 message=str(exception), | ||||
|                 exception=exception_to_dict(exception), | ||||
|             ) | ||||
|             thread.run() | ||||
|         elif not should_ignore_exception(exception): | ||||
|         elif before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||
|             thread = EventNewThread( | ||||
|                 EventAction.SYSTEM_EXCEPTION, | ||||
|                 request, | ||||
|                 message=exception_to_string(exception), | ||||
|                 message=str(exception), | ||||
|                 exception=exception_to_dict(exception), | ||||
|             ) | ||||
|             thread.run() | ||||
|  | ||||
|  | ||||
| @ -38,6 +38,7 @@ from authentik.events.utils import ( | ||||
| ) | ||||
| from authentik.lib.models import DomainlessURLValidator, SerializerModel | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.lib.utils.errors import exception_to_dict | ||||
| from authentik.lib.utils.http import get_http_session | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.policies.models import PolicyBindingModel | ||||
| @ -163,6 +164,12 @@ class Event(SerializerModel, ExpiringModel): | ||||
|         event = Event(action=action, app=app, context=cleaned_kwargs) | ||||
|         return event | ||||
|  | ||||
|     def with_exception(self, exc: Exception) -> "Event": | ||||
|         """Add data from 'exc' to the event in a database-saveable format""" | ||||
|         self.context.setdefault("message", str(exc)) | ||||
|         self.context["exception"] = exception_to_dict(exc) | ||||
|         return self | ||||
|  | ||||
|     def set_user(self, user: User) -> "Event": | ||||
|         """Set `.user` based on user, ensuring the correct attributes are copied. | ||||
|         This should only be used when self.from_http is *not* used.""" | ||||
| @ -193,32 +200,17 @@ class Event(SerializerModel, ExpiringModel): | ||||
|             brand: Brand = request.brand | ||||
|             self.brand = sanitize_dict(model_to_dict(brand)) | ||||
|         if hasattr(request, "user"): | ||||
|             self.user = get_user(request.user) | ||||
|             original_user = None | ||||
|             if hasattr(request, "session"): | ||||
|                 original_user = request.session.get(SESSION_KEY_IMPERSONATE_ORIGINAL_USER, None) | ||||
|             self.user = get_user(request.user, original_user) | ||||
|         if user: | ||||
|             self.user = get_user(user) | ||||
|         # Check if we're currently impersonating, and add that user | ||||
|         if hasattr(request, "session"): | ||||
|             from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
|  | ||||
|             # Check if we're currently impersonating, and add that user | ||||
|             if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session: | ||||
|                 self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]) | ||||
|                 self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER]) | ||||
|             # Special case for events that happen during a flow, the user might not be authenticated | ||||
|             # yet but is a pending user instead | ||||
|             if SESSION_KEY_PLAN in request.session: | ||||
|                 from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan | ||||
|  | ||||
|                 plan: FlowPlan = request.session[SESSION_KEY_PLAN] | ||||
|                 pending_user = plan.context.get(PLAN_CONTEXT_PENDING_USER, None) | ||||
|                 # Only save `authenticated_as` if there's a different pending user in the flow | ||||
|                 # than the user that is authenticated | ||||
|                 if pending_user and ( | ||||
|                     (pending_user.pk and pending_user.pk != self.user.get("pk")) | ||||
|                     or (not pending_user.pk) | ||||
|                 ): | ||||
|                     orig_user = self.user.copy() | ||||
|  | ||||
|                     self.user = {"authenticated_as": orig_user, **get_user(pending_user)} | ||||
|         # User 255.255.255.255 as fallback if IP cannot be determined | ||||
|         self.client_ip = ClientIPMiddleware.get_client_ip(request) | ||||
|         # Enrich event data | ||||
|  | ||||
| @ -127,8 +127,8 @@ class SystemTask(TenantTask): | ||||
|         ) | ||||
|         Event.new( | ||||
|             EventAction.SYSTEM_TASK_EXCEPTION, | ||||
|             message=f"Task {self.__name__} encountered an error: {exception_to_string(exc)}", | ||||
|         ).save() | ||||
|             message=f"Task {self.__name__} encountered an error", | ||||
|         ).with_exception(exc).save() | ||||
|  | ||||
|     def run(self, *args, **kwargs): | ||||
|         raise NotImplementedError | ||||
|  | ||||
| @ -2,9 +2,7 @@ | ||||
|  | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.events.context_processors.base import get_context_processors | ||||
| from authentik.events.context_processors.geoip import GeoIPContextProcessor | ||||
| from authentik.events.models import Event, EventAction | ||||
|  | ||||
|  | ||||
| class TestGeoIP(TestCase): | ||||
| @ -15,7 +13,8 @@ class TestGeoIP(TestCase): | ||||
|  | ||||
|     def test_simple(self): | ||||
|         """Test simple city wrapper""" | ||||
|         # IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json | ||||
|         # IPs from | ||||
|         # https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json | ||||
|         self.assertEqual( | ||||
|             self.reader.city_dict("2.125.160.216"), | ||||
|             { | ||||
| @ -26,12 +25,3 @@ class TestGeoIP(TestCase): | ||||
|                 "long": -1.25, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_special_chars(self): | ||||
|         """Test city name with special characters""" | ||||
|         # IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json | ||||
|         event = Event.new(EventAction.LOGIN) | ||||
|         event.client_ip = "89.160.20.112" | ||||
|         for processor in get_context_processors(): | ||||
|             processor.enrich_event(event) | ||||
|         event.save() | ||||
|  | ||||
| @ -8,11 +8,9 @@ from django.views.debug import SafeExceptionReporterFilter | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
|  | ||||
| from authentik.brands.models import Brand | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.core.tests.utils import create_test_user | ||||
| from authentik.core.models import Group | ||||
| from authentik.events.models import Event | ||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan | ||||
| from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN | ||||
| from authentik.flows.views.executor import QS_QUERY | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.policies.dummy.models import DummyPolicy | ||||
|  | ||||
| @ -118,92 +116,3 @@ class TestEvents(TestCase): | ||||
|                 "pk": brand.pk.hex, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_from_http_flow_pending_user(self): | ||||
|         """Test request from flow request with a pending user""" | ||||
|         user = create_test_user() | ||||
|  | ||||
|         session = self.client.session | ||||
|         plan = FlowPlan(generate_id()) | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = user | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         request = self.factory.get("/") | ||||
|         request.session = session | ||||
|         request.user = user | ||||
|  | ||||
|         event = Event.new("unittest").from_http(request) | ||||
|         self.assertEqual( | ||||
|             event.user, | ||||
|             { | ||||
|                 "email": user.email, | ||||
|                 "pk": user.pk, | ||||
|                 "username": user.username, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_from_http_flow_pending_user_anon(self): | ||||
|         """Test request from flow request with a pending user""" | ||||
|         user = create_test_user() | ||||
|         anon = get_anonymous_user() | ||||
|  | ||||
|         session = self.client.session | ||||
|         plan = FlowPlan(generate_id()) | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = user | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         request = self.factory.get("/") | ||||
|         request.session = session | ||||
|         request.user = anon | ||||
|  | ||||
|         event = Event.new("unittest").from_http(request) | ||||
|         self.assertEqual( | ||||
|             event.user, | ||||
|             { | ||||
|                 "authenticated_as": { | ||||
|                     "pk": anon.pk, | ||||
|                     "is_anonymous": True, | ||||
|                     "username": "AnonymousUser", | ||||
|                     "email": "", | ||||
|                 }, | ||||
|                 "email": user.email, | ||||
|                 "pk": user.pk, | ||||
|                 "username": user.username, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_from_http_flow_pending_user_fake(self): | ||||
|         """Test request from flow request with a pending user""" | ||||
|         user = User( | ||||
|             username=generate_id(), | ||||
|             email=generate_id(), | ||||
|         ) | ||||
|         anon = get_anonymous_user() | ||||
|  | ||||
|         session = self.client.session | ||||
|         plan = FlowPlan(generate_id()) | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = user | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         request = self.factory.get("/") | ||||
|         request.session = session | ||||
|         request.user = anon | ||||
|  | ||||
|         event = Event.new("unittest").from_http(request) | ||||
|         self.assertEqual( | ||||
|             event.user, | ||||
|             { | ||||
|                 "authenticated_as": { | ||||
|                     "pk": anon.pk, | ||||
|                     "is_anonymous": True, | ||||
|                     "username": "AnonymousUser", | ||||
|                     "email": "", | ||||
|                 }, | ||||
|                 "email": user.email, | ||||
|                 "pk": user.pk, | ||||
|                 "username": user.username, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @ -74,8 +74,8 @@ def model_to_dict(model: Model) -> dict[str, Any]: | ||||
|     } | ||||
|  | ||||
|  | ||||
| def get_user(user: User | AnonymousUser) -> dict[str, Any]: | ||||
|     """Convert user object to dictionary""" | ||||
| def get_user(user: User | AnonymousUser, original_user: User | None = None) -> dict[str, Any]: | ||||
|     """Convert user object to dictionary, optionally including the original user""" | ||||
|     if isinstance(user, AnonymousUser): | ||||
|         try: | ||||
|             user = get_anonymous_user() | ||||
| @ -88,6 +88,10 @@ def get_user(user: User | AnonymousUser) -> dict[str, Any]: | ||||
|     } | ||||
|     if user.username == settings.ANONYMOUS_USER_NAME: | ||||
|         user_data["is_anonymous"] = True | ||||
|     if original_user: | ||||
|         original_data = get_user(original_user) | ||||
|         original_data["on_behalf_of"] = user_data | ||||
|         return original_data | ||||
|     return user_data | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -4,10 +4,8 @@ from unittest.mock import MagicMock, PropertyMock, patch | ||||
| from urllib.parse import urlencode | ||||
|  | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.test import override_settings | ||||
| from django.test.client import RequestFactory | ||||
| from django.urls import reverse | ||||
| from rest_framework.exceptions import ParseError | ||||
|  | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.core.tests.utils import create_test_flow, create_test_user | ||||
| @ -650,25 +648,3 @@ class TestFlowExecutor(FlowTestCase): | ||||
|             self.assertStageResponse(response, flow, component="ak-stage-identification") | ||||
|             response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True) | ||||
|             self.assertStageResponse(response, flow, component="ak-stage-access-denied") | ||||
|  | ||||
|     @patch( | ||||
|         "authentik.flows.views.executor.to_stage_response", | ||||
|         TO_STAGE_RESPONSE_MOCK, | ||||
|     ) | ||||
|     def test_invalid_json(self): | ||||
|         """Test invalid JSON body""" | ||||
|         flow = create_test_flow() | ||||
|         FlowStageBinding.objects.create( | ||||
|             target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0 | ||||
|         ) | ||||
|         url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}) | ||||
|  | ||||
|         with override_settings(TEST=False, DEBUG=False): | ||||
|             self.client.logout() | ||||
|             response = self.client.post(url, data="{", content_type="application/json") | ||||
|             self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         with self.assertRaises(ParseError): | ||||
|             self.client.logout() | ||||
|             response = self.client.post(url, data="{", content_type="application/json") | ||||
|             self.assertEqual(response.status_code, 200) | ||||
|  | ||||
| @ -55,8 +55,7 @@ from authentik.flows.planner import ( | ||||
|     FlowPlanner, | ||||
| ) | ||||
| from authentik.flows.stage import AccessDeniedStage, StageView | ||||
| from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.lib.utils.reflection import all_subclasses, class_to_path | ||||
| from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| @ -234,13 +233,12 @@ class FlowExecutorView(APIView): | ||||
|         """Handle exception in stage execution""" | ||||
|         if settings.DEBUG or settings.TEST: | ||||
|             raise exc | ||||
|         capture_exception(exc) | ||||
|         self._logger.warning(exc) | ||||
|         if not should_ignore_exception(exc): | ||||
|             capture_exception(exc) | ||||
|             Event.new( | ||||
|                 action=EventAction.SYSTEM_EXCEPTION, | ||||
|                 message=exception_to_string(exc), | ||||
|             ).from_http(self.request) | ||||
|         Event.new( | ||||
|             action=EventAction.SYSTEM_EXCEPTION, | ||||
|             message="System exception during flow execution.", | ||||
|         ).with_exception(exc).from_http(self.request) | ||||
|         challenge = FlowErrorChallenge(self.request, exc) | ||||
|         challenge.is_valid(raise_exception=True) | ||||
|         return to_stage_response(self.request, HttpChallengeResponse(challenge)) | ||||
|  | ||||
| @ -14,7 +14,6 @@ from django_redis.exceptions import ConnectionInterrupted | ||||
| from docker.errors import DockerException | ||||
| from h11 import LocalProtocolError | ||||
| from ldap3.core.exceptions import LDAPException | ||||
| from psycopg.errors import Error | ||||
| from redis.exceptions import ConnectionError as RedisConnectionError | ||||
| from redis.exceptions import RedisError, ResponseError | ||||
| from rest_framework.exceptions import APIException | ||||
| @ -45,49 +44,6 @@ class SentryIgnoredException(Exception): | ||||
|     """Base Class for all errors that are suppressed, and not sent to sentry.""" | ||||
|  | ||||
|  | ||||
| ignored_classes = ( | ||||
|     # Inbuilt types | ||||
|     KeyboardInterrupt, | ||||
|     ConnectionResetError, | ||||
|     OSError, | ||||
|     PermissionError, | ||||
|     # Django Errors | ||||
|     Error, | ||||
|     ImproperlyConfigured, | ||||
|     DatabaseError, | ||||
|     OperationalError, | ||||
|     InternalError, | ||||
|     ProgrammingError, | ||||
|     SuspiciousOperation, | ||||
|     ValidationError, | ||||
|     # Redis errors | ||||
|     RedisConnectionError, | ||||
|     ConnectionInterrupted, | ||||
|     RedisError, | ||||
|     ResponseError, | ||||
|     # websocket errors | ||||
|     ChannelFull, | ||||
|     WebSocketException, | ||||
|     LocalProtocolError, | ||||
|     # rest_framework error | ||||
|     APIException, | ||||
|     # celery errors | ||||
|     WorkerLostError, | ||||
|     CeleryError, | ||||
|     SoftTimeLimitExceeded, | ||||
|     # custom baseclass | ||||
|     SentryIgnoredException, | ||||
|     # ldap errors | ||||
|     LDAPException, | ||||
|     # Docker errors | ||||
|     DockerException, | ||||
|     # End-user errors | ||||
|     Http404, | ||||
|     # AsyncIO | ||||
|     CancelledError, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class SentryTransport(HttpTransport): | ||||
|     """Custom sentry transport with custom user-agent""" | ||||
|  | ||||
| @ -145,17 +101,56 @@ def traces_sampler(sampling_context: dict) -> float: | ||||
|     return float(CONFIG.get("error_reporting.sample_rate", 0.1)) | ||||
|  | ||||
|  | ||||
| def should_ignore_exception(exc: Exception) -> bool: | ||||
|     """Check if an exception should be dropped""" | ||||
|     return isinstance(exc, ignored_classes) | ||||
|  | ||||
|  | ||||
| def before_send(event: dict, hint: dict) -> dict | None: | ||||
|     """Check if error is database error, and ignore if so""" | ||||
|  | ||||
|     from psycopg.errors import Error | ||||
|  | ||||
|     ignored_classes = ( | ||||
|         # Inbuilt types | ||||
|         KeyboardInterrupt, | ||||
|         ConnectionResetError, | ||||
|         OSError, | ||||
|         PermissionError, | ||||
|         # Django Errors | ||||
|         Error, | ||||
|         ImproperlyConfigured, | ||||
|         DatabaseError, | ||||
|         OperationalError, | ||||
|         InternalError, | ||||
|         ProgrammingError, | ||||
|         SuspiciousOperation, | ||||
|         ValidationError, | ||||
|         # Redis errors | ||||
|         RedisConnectionError, | ||||
|         ConnectionInterrupted, | ||||
|         RedisError, | ||||
|         ResponseError, | ||||
|         # websocket errors | ||||
|         ChannelFull, | ||||
|         WebSocketException, | ||||
|         LocalProtocolError, | ||||
|         # rest_framework error | ||||
|         APIException, | ||||
|         # celery errors | ||||
|         WorkerLostError, | ||||
|         CeleryError, | ||||
|         SoftTimeLimitExceeded, | ||||
|         # custom baseclass | ||||
|         SentryIgnoredException, | ||||
|         # ldap errors | ||||
|         LDAPException, | ||||
|         # Docker errors | ||||
|         DockerException, | ||||
|         # End-user errors | ||||
|         Http404, | ||||
|         # AsyncIO | ||||
|         CancelledError, | ||||
|     ) | ||||
|     exc_value = None | ||||
|     if "exc_info" in hint: | ||||
|         _, exc_value, _ = hint["exc_info"] | ||||
|         if should_ignore_exception(exc_value): | ||||
|         if isinstance(exc_value, ignored_classes): | ||||
|             LOGGER.debug("dropping exception", exc=exc_value) | ||||
|             return None | ||||
|     if "logger" in event: | ||||
|  | ||||
| @ -14,7 +14,6 @@ from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.expression.exceptions import ControlFlowException | ||||
| from authentik.lib.sync.mapper import PropertyMappingManager | ||||
| from authentik.lib.sync.outgoing.exceptions import NotFoundSyncException, StopSync | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from django.db.models import Model | ||||
| @ -106,9 +105,9 @@ class BaseOutgoingSyncClient[ | ||||
|             # Value error can be raised when assigning invalid data to an attribute | ||||
|             Event.new( | ||||
|                 EventAction.CONFIGURATION_ERROR, | ||||
|                 message=f"Failed to evaluate property-mapping {exception_to_string(exc)}", | ||||
|                 message="Failed to evaluate property-mapping", | ||||
|                 mapping=exc.mapping, | ||||
|             ).save() | ||||
|             ).with_exception(exc).save() | ||||
|             raise StopSync(exc, obj, exc.mapping) from exc | ||||
|         if not raw_final_object: | ||||
|             raise StopSync(ValueError("No mappings configured"), obj) | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|  | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception | ||||
| from authentik.lib.sentry import SentryIgnoredException, before_send | ||||
|  | ||||
|  | ||||
| class TestSentry(TestCase): | ||||
| @ -10,8 +10,8 @@ class TestSentry(TestCase): | ||||
|  | ||||
|     def test_error_not_sent(self): | ||||
|         """Test SentryIgnoredError not sent""" | ||||
|         self.assertTrue(should_ignore_exception(SentryIgnoredException())) | ||||
|         self.assertIsNone(before_send({}, {"exc_info": (0, SentryIgnoredException(), 0)})) | ||||
|  | ||||
|     def test_error_sent(self): | ||||
|         """Test error sent""" | ||||
|         self.assertFalse(should_ignore_exception(ValueError())) | ||||
|         self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)})) | ||||
|  | ||||
| @ -2,6 +2,8 @@ | ||||
|  | ||||
| from traceback import extract_tb | ||||
|  | ||||
| from structlog.tracebacks import ExceptionDictTransformer | ||||
|  | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
|  | ||||
| TRACEBACK_HEADER = "Traceback (most recent call last):" | ||||
| @ -17,3 +19,8 @@ def exception_to_string(exc: Exception) -> str: | ||||
|             f"{class_to_path(exc.__class__)}: {str(exc)}", | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def exception_to_dict(exc: Exception) -> dict: | ||||
|     """Format exception as a dictionary""" | ||||
|     return ExceptionDictTransformer()((type(exc), exc, exc.__traceback__)) | ||||
|  | ||||
| @ -35,7 +35,6 @@ from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.models import InheritanceForeignKey, SerializerModel | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.outposts.controllers.k8s.utils import get_namespace | ||||
|  | ||||
| OUR_VERSION = parse(__version__) | ||||
| @ -326,9 +325,8 @@ class Outpost(SerializerModel, ManagedModel): | ||||
|                                 "While setting the permissions for the service-account, a " | ||||
|                                 "permission was not found: Check " | ||||
|                                 "https://goauthentik.io/docs/troubleshooting/missing_permission" | ||||
|                             ) | ||||
|                             + exception_to_string(exc), | ||||
|                         ).set_user(user).save() | ||||
|                             ), | ||||
|                         ).with_exception(exc).set_user(user).save() | ||||
|                 else: | ||||
|                     app_label, perm = model_or_perm.split(".") | ||||
|                     permission = Permission.objects.filter( | ||||
|  | ||||
| @ -1,13 +1,15 @@ | ||||
| """authentik outpost signals""" | ||||
|  | ||||
| from django.contrib.auth.signals import user_logged_out | ||||
| from django.core.cache import cache | ||||
| from django.db.models import Model | ||||
| from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save | ||||
| from django.dispatch import receiver | ||||
| from django.http import HttpRequest | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.brands.models import Brand | ||||
| from authentik.core.models import AuthenticatedSession, Provider | ||||
| from authentik.core.models import AuthenticatedSession, Provider, User | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | ||||
| @ -80,6 +82,14 @@ def pre_delete_cleanup(sender, instance: Outpost, **_): | ||||
|     outpost_controller.delay(instance.pk.hex, action="down", from_cache=True) | ||||
|  | ||||
|  | ||||
| @receiver(user_logged_out) | ||||
| def logout_revoke_direct(sender: type[User], request: HttpRequest, **_): | ||||
|     """Catch logout by direct logout and forward to providers""" | ||||
|     if not request.session or not request.session.session_key: | ||||
|         return | ||||
|     outpost_session_end.delay(request.session.session_key) | ||||
|  | ||||
|  | ||||
| @receiver(pre_delete, sender=AuthenticatedSession) | ||||
| def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_): | ||||
|     """Catch logout by expiring sessions being deleted""" | ||||
|  | ||||
| @ -10,7 +10,7 @@ from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.errors import exception_to_dict | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
| from authentik.policies.apps import HIST_POLICIES_EXECUTION_TIME | ||||
| from authentik.policies.exceptions import PolicyException | ||||
| @ -95,10 +95,13 @@ class PolicyProcess(PROCESS_CLASS): | ||||
|         except PolicyException as exc: | ||||
|             # Either use passed original exception or whatever we have | ||||
|             src_exc = exc.src_exc if exc.src_exc else exc | ||||
|             error_string = exception_to_string(src_exc) | ||||
|             # Create policy exception event, only when we're not debugging | ||||
|             if not self.request.debug: | ||||
|                 self.create_event(EventAction.POLICY_EXCEPTION, message=error_string) | ||||
|                 self.create_event( | ||||
|                     EventAction.POLICY_EXCEPTION, | ||||
|                     message="Policy failed to execute", | ||||
|                     exception=exception_to_dict(src_exc), | ||||
|                 ) | ||||
|             LOGGER.debug("P_ENG(proc): error, using failure result", exc=src_exc) | ||||
|             policy_result = PolicyResult(self.binding.failure_result, str(src_exc)) | ||||
|         policy_result.source_binding = self.binding | ||||
| @ -143,5 +146,5 @@ class PolicyProcess(PROCESS_CLASS): | ||||
|         try: | ||||
|             self.connection.send(self.profiling_wrapper()) | ||||
|         except Exception as exc: | ||||
|             LOGGER.warning("Policy failed to run", exc=exception_to_string(exc)) | ||||
|             LOGGER.warning("Policy failed to run", exc=exc) | ||||
|             self.connection.send(PolicyResult(False, str(exc))) | ||||
|  | ||||
| @ -237,4 +237,4 @@ class TestPolicyProcess(TestCase): | ||||
|         self.assertEqual(len(events), 1) | ||||
|         event = events.first() | ||||
|         self.assertEqual(event.user["username"], self.user.username) | ||||
|         self.assertIn("division by zero", event.context["message"]) | ||||
|         self.assertIn("Policy failed to execute", event.context["message"]) | ||||
|  | ||||
| @ -1,10 +1,23 @@ | ||||
| from django.contrib.auth.signals import user_logged_out | ||||
| from django.db.models.signals import post_save, pre_delete | ||||
| from django.dispatch import receiver | ||||
| from django.http import HttpRequest | ||||
|  | ||||
| from authentik.core.models import AuthenticatedSession, User | ||||
| from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken | ||||
|  | ||||
|  | ||||
| @receiver(user_logged_out) | ||||
| def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_): | ||||
|     """Revoke tokens upon user logout""" | ||||
|     if not request.session or not request.session.session_key: | ||||
|         return | ||||
|     AccessToken.objects.filter( | ||||
|         user=user, | ||||
|         session__session__session_key=request.session.session_key, | ||||
|     ).delete() | ||||
|  | ||||
|  | ||||
| @receiver(pre_delete, sender=AuthenticatedSession) | ||||
| def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_): | ||||
|     """Revoke tokens upon user logout""" | ||||
|  | ||||
| @ -66,10 +66,7 @@ class RACClientConsumer(AsyncWebsocketConsumer): | ||||
|     def init_outpost_connection(self): | ||||
|         """Initialize guac connection settings""" | ||||
|         self.token = ( | ||||
|             ConnectionToken.filter_not_expired( | ||||
|                 token=self.scope["url_route"]["kwargs"]["token"], | ||||
|                 session__session__session_key=self.scope["session"].session_key, | ||||
|             ) | ||||
|             ConnectionToken.filter_not_expired(token=self.scope["url_route"]["kwargs"]["token"]) | ||||
|             .select_related("endpoint", "provider", "session", "session__user") | ||||
|             .first() | ||||
|         ) | ||||
|  | ||||
| @ -2,11 +2,13 @@ | ||||
|  | ||||
| from asgiref.sync import async_to_sync | ||||
| from channels.layers import get_channel_layer | ||||
| from django.contrib.auth.signals import user_logged_out | ||||
| from django.core.cache import cache | ||||
| from django.db.models.signals import post_delete, post_save, pre_delete | ||||
| from django.dispatch import receiver | ||||
| from django.http import HttpRequest | ||||
|  | ||||
| from authentik.core.models import AuthenticatedSession | ||||
| from authentik.core.models import AuthenticatedSession, User | ||||
| from authentik.providers.rac.api.endpoints import user_endpoint_cache_key | ||||
| from authentik.providers.rac.consumer_client import ( | ||||
|     RAC_CLIENT_GROUP_SESSION, | ||||
| @ -15,6 +17,21 @@ from authentik.providers.rac.consumer_client import ( | ||||
| from authentik.providers.rac.models import ConnectionToken, Endpoint | ||||
|  | ||||
|  | ||||
| @receiver(user_logged_out) | ||||
| def user_logged_out_session(sender, request: HttpRequest, user: User, **_): | ||||
|     """Disconnect any open RAC connections""" | ||||
|     if not request.session or not request.session.session_key: | ||||
|         return | ||||
|     layer = get_channel_layer() | ||||
|     async_to_sync(layer.group_send)( | ||||
|         RAC_CLIENT_GROUP_SESSION | ||||
|         % { | ||||
|             "session": request.session.session_key, | ||||
|         }, | ||||
|         {"type": "event.disconnect", "reason": "session_logout"}, | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @receiver(pre_delete, sender=AuthenticatedSession) | ||||
| def user_session_deleted(sender, instance: AuthenticatedSession, **_): | ||||
|     layer = get_channel_layer() | ||||
|  | ||||
| @ -87,22 +87,3 @@ class TestRACViews(APITestCase): | ||||
|         ) | ||||
|         body = loads(flow_response.content) | ||||
|         self.assertEqual(body["component"], "ak-stage-access-denied") | ||||
|  | ||||
|     def test_different_session(self): | ||||
|         """Test request""" | ||||
|         self.client.force_login(self.user) | ||||
|         response = self.client.get( | ||||
|             reverse( | ||||
|                 "authentik_providers_rac:start", | ||||
|                 kwargs={"app": self.app.slug, "endpoint": str(self.endpoint.pk)}, | ||||
|             ) | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         flow_response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|         body = loads(flow_response.content) | ||||
|         next_url = body["to"] | ||||
|         self.client.logout() | ||||
|         final_response = self.client.get(next_url) | ||||
|         self.assertEqual(final_response.url, reverse("authentik_core:if-user")) | ||||
|  | ||||
| @ -68,10 +68,7 @@ class RACInterface(InterfaceView): | ||||
|  | ||||
|     def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: | ||||
|         # Early sanity check to ensure token still exists | ||||
|         token = ConnectionToken.filter_not_expired( | ||||
|             token=self.kwargs["token"], | ||||
|             session__session__session_key=request.session.session_key, | ||||
|         ).first() | ||||
|         token = ConnectionToken.filter_not_expired(token=self.kwargs["token"]).first() | ||||
|         if not token: | ||||
|             return redirect("authentik_core:if-user") | ||||
|         self.token = token | ||||
|  | ||||
| @ -23,7 +23,6 @@ from authentik.core.models import Application | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.expression.exceptions import ControlFlowException | ||||
| from authentik.lib.sync.mapper import PropertyMappingManager | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.policies.api.exec import PolicyTestResultSerializer | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.types import PolicyResult | ||||
| @ -142,9 +141,9 @@ class RadiusOutpostConfigViewSet(ListModelMixin, GenericViewSet): | ||||
|             # Value error can be raised when assigning invalid data to an attribute | ||||
|             Event.new( | ||||
|                 EventAction.CONFIGURATION_ERROR, | ||||
|                 message=f"Failed to evaluate property-mapping {exception_to_string(exc)}", | ||||
|                 message="Failed to evaluate property-mapping", | ||||
|                 mapping=exc.mapping, | ||||
|             ).save() | ||||
|             ).with_exception(exc).save() | ||||
|             return None | ||||
|         return b64encode(packet.RequestPacket()).decode() | ||||
|  | ||||
|  | ||||
| @ -5,6 +5,7 @@ from itertools import batched | ||||
| from django.db import transaction | ||||
| from pydantic import ValidationError | ||||
| from pydanticscim.group import GroupMember | ||||
| from pydanticscim.responses import PatchOp | ||||
|  | ||||
| from authentik.core.models import Group | ||||
| from authentik.lib.sync.mapper import PropertyMappingManager | ||||
| @ -19,12 +20,7 @@ from authentik.providers.scim.clients.base import SCIMClient | ||||
| from authentik.providers.scim.clients.exceptions import ( | ||||
|     SCIMRequestException, | ||||
| ) | ||||
| from authentik.providers.scim.clients.schema import ( | ||||
|     SCIM_GROUP_SCHEMA, | ||||
|     PatchOp, | ||||
|     PatchOperation, | ||||
|     PatchRequest, | ||||
| ) | ||||
| from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest | ||||
| from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema | ||||
| from authentik.providers.scim.models import ( | ||||
|     SCIMMapping, | ||||
|  | ||||
| @ -1,7 +1,5 @@ | ||||
| """Custom SCIM schemas""" | ||||
|  | ||||
| from enum import Enum | ||||
|  | ||||
| from pydantic import Field | ||||
| from pydanticscim.group import Group as BaseGroup | ||||
| from pydanticscim.responses import PatchOperation as BasePatchOperation | ||||
| @ -67,21 +65,6 @@ class ServiceProviderConfiguration(BaseServiceProviderConfiguration): | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class PatchOp(str, Enum): | ||||
|  | ||||
|     replace = "replace" | ||||
|     remove = "remove" | ||||
|     add = "add" | ||||
|  | ||||
|     @classmethod | ||||
|     def _missing_(cls, value): | ||||
|         value = value.lower() | ||||
|         for member in cls: | ||||
|             if member.lower() == value: | ||||
|                 return member | ||||
|         return None | ||||
|  | ||||
|  | ||||
| class PatchRequest(BasePatchRequest): | ||||
|     """PatchRequest which correctly sets schemas""" | ||||
|  | ||||
| @ -91,7 +74,6 @@ class PatchRequest(BasePatchRequest): | ||||
| class PatchOperation(BasePatchOperation): | ||||
|     """PatchOperation with optional path""" | ||||
|  | ||||
|     op: PatchOp | ||||
|     path: str | None | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -27,8 +27,7 @@ from structlog.stdlib import get_logger | ||||
| from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp | ||||
|  | ||||
| from authentik import get_full_version | ||||
| from authentik.lib.sentry import should_ignore_exception | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.sentry import before_send | ||||
|  | ||||
| # set the default Django settings module for the 'celery' program. | ||||
| os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings") | ||||
| @ -81,10 +80,10 @@ def task_error_hook(task_id: str, exception: Exception, traceback, *args, **kwar | ||||
|  | ||||
|     LOGGER.warning("Task failure", task_id=task_id.replace("-", ""), exc=exception) | ||||
|     CTX_TASK_ID.set(...) | ||||
|     if not should_ignore_exception(exception): | ||||
|     if before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||
|         Event.new( | ||||
|             EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception), task_id=task_id | ||||
|         ).save() | ||||
|             EventAction.SYSTEM_EXCEPTION, message="Failed to execute task", task_id=task_id | ||||
|         ).with_exception(exception).save() | ||||
|  | ||||
|  | ||||
| def _get_startup_tasks_default_tenant() -> list[Callable]: | ||||
|  | ||||
| @ -1,49 +1,13 @@ | ||||
| """authentik database backend""" | ||||
|  | ||||
| from django.core.checks import Warning | ||||
| from django.db.backends.base.validation import BaseDatabaseValidation | ||||
| from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper | ||||
|  | ||||
| from authentik.lib.config import CONFIG | ||||
|  | ||||
|  | ||||
| class DatabaseValidation(BaseDatabaseValidation): | ||||
|  | ||||
|     def check(self, **kwargs): | ||||
|         return self._check_encoding() | ||||
|  | ||||
|     def _check_encoding(self): | ||||
|         """Throw a warning when the server_encoding is not UTF-8 or | ||||
|         server_encoding and client_encoding are mismatched""" | ||||
|         messages = [] | ||||
|         with self.connection.cursor() as cursor: | ||||
|             cursor.execute("SHOW server_encoding;") | ||||
|             server_encoding = cursor.fetchone()[0] | ||||
|             cursor.execute("SHOW client_encoding;") | ||||
|             client_encoding = cursor.fetchone()[0] | ||||
|             if server_encoding != client_encoding: | ||||
|                 messages.append( | ||||
|                     Warning( | ||||
|                         "PostgreSQL Server and Client encoding are mismatched: Server: " | ||||
|                         f"{server_encoding}, Client: {client_encoding}", | ||||
|                         id="ak.db.W001", | ||||
|                     ) | ||||
|                 ) | ||||
|             if server_encoding != "UTF8": | ||||
|                 messages.append( | ||||
|                     Warning( | ||||
|                         f"PostgreSQL Server encoding is not UTF8: {server_encoding}", | ||||
|                         id="ak.db.W002", | ||||
|                     ) | ||||
|                 ) | ||||
|         return messages | ||||
|  | ||||
|  | ||||
| class DatabaseWrapper(BaseDatabaseWrapper): | ||||
|     """database backend which supports rotating credentials""" | ||||
|  | ||||
|     validation_class = DatabaseValidation | ||||
|  | ||||
|     def get_connection_params(self): | ||||
|         """Refresh DB credentials before getting connection params""" | ||||
|         conn_params = super().get_connection_params() | ||||
|  | ||||
| @ -8,7 +8,6 @@ from authentik.events.models import TaskStatus | ||||
| from authentik.events.system_tasks import SystemTask | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.sync.outgoing.exceptions import StopSync | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.root.celery import CELERY_APP | ||||
| from authentik.sources.kerberos.models import KerberosSource | ||||
| from authentik.sources.kerberos.sync import KerberosSync | ||||
| @ -64,5 +63,5 @@ def kerberos_sync_single(self, source_pk: str): | ||||
|             syncer.sync() | ||||
|             self.set_status(TaskStatus.SUCCESSFUL, *syncer.messages) | ||||
|     except StopSync as exc: | ||||
|         LOGGER.warning(exception_to_string(exc)) | ||||
|         LOGGER.warning("Error syncing kerberos", exc=exc, source=source) | ||||
|         self.set_error(exc) | ||||
|  | ||||
| @ -12,7 +12,6 @@ from authentik.events.models import TaskStatus | ||||
| from authentik.events.system_tasks import SystemTask | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.sync.outgoing.exceptions import StopSync | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.reflection import class_to_path, path_to_class | ||||
| from authentik.root.celery import CELERY_APP | ||||
| from authentik.sources.ldap.models import LDAPSource | ||||
| @ -149,5 +148,5 @@ def ldap_sync(self: SystemTask, source_pk: str, sync_class: str, page_cache_key: | ||||
|         cache.delete(page_cache_key) | ||||
|     except (LDAPException, StopSync) as exc: | ||||
|         # No explicit event is created here as .set_status with an error will do that | ||||
|         LOGGER.warning(exception_to_string(exc)) | ||||
|         LOGGER.warning("Failed to sync LDAP", exc=exc, source=source) | ||||
|         self.set_error(exc) | ||||
|  | ||||
| @ -1,277 +0,0 @@ | ||||
| """Test SCIM Group""" | ||||
|  | ||||
| from json import dumps | ||||
| from uuid import uuid4 | ||||
|  | ||||
| from django.urls import reverse | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import Group | ||||
| from authentik.core.tests.utils import create_test_user | ||||
| from authentik.events.models import Event, EventAction | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema | ||||
| from authentik.sources.scim.models import ( | ||||
|     SCIMSource, | ||||
|     SCIMSourceGroup, | ||||
| ) | ||||
| from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE | ||||
|  | ||||
|  | ||||
| class TestSCIMGroups(APITestCase): | ||||
|     """Test SCIM Group view""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.source = SCIMSource.objects.create(name=generate_id(), slug=generate_id()) | ||||
|  | ||||
|     def test_group_list(self): | ||||
|         """Test full group list""" | ||||
|         response = self.client.get( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                 }, | ||||
|             ), | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_group_list_single(self): | ||||
|         """Test full group list (single group)""" | ||||
|         group = Group.objects.create(name=generate_id()) | ||||
|         user = create_test_user() | ||||
|         group.users.add(user) | ||||
|         SCIMSourceGroup.objects.create( | ||||
|             source=self.source, | ||||
|             group=group, | ||||
|             id=str(uuid4()), | ||||
|         ) | ||||
|         response = self.client.get( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                     "group_id": str(group.pk), | ||||
|                 }, | ||||
|             ), | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, second=200) | ||||
|         SCIMGroupSchema.model_validate_json(response.content, strict=True) | ||||
|  | ||||
|     def test_group_create(self): | ||||
|         """Test group create""" | ||||
|         ext_id = generate_id() | ||||
|         response = self.client.post( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                 }, | ||||
|             ), | ||||
|             data=dumps({"displayName": generate_id(), "externalId": ext_id}), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 201) | ||||
|         self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists()) | ||||
|         self.assertTrue( | ||||
|             Event.objects.filter( | ||||
|                 action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username | ||||
|             ).exists() | ||||
|         ) | ||||
|  | ||||
|     def test_group_create_members(self): | ||||
|         """Test group create""" | ||||
|         user = create_test_user() | ||||
|         ext_id = generate_id() | ||||
|         response = self.client.post( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                 }, | ||||
|             ), | ||||
|             data=dumps( | ||||
|                 { | ||||
|                     "displayName": generate_id(), | ||||
|                     "externalId": ext_id, | ||||
|                     "members": [{"value": str(user.uuid)}], | ||||
|                 } | ||||
|             ), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 201) | ||||
|         self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists()) | ||||
|         self.assertTrue( | ||||
|             Event.objects.filter( | ||||
|                 action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username | ||||
|             ).exists() | ||||
|         ) | ||||
|  | ||||
|     def test_group_create_members_empty(self): | ||||
|         """Test group create""" | ||||
|         ext_id = generate_id() | ||||
|         response = self.client.post( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                 }, | ||||
|             ), | ||||
|             data=dumps({"displayName": generate_id(), "externalId": ext_id, "members": []}), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 201) | ||||
|         self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists()) | ||||
|         self.assertTrue( | ||||
|             Event.objects.filter( | ||||
|                 action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username | ||||
|             ).exists() | ||||
|         ) | ||||
|  | ||||
|     def test_group_create_duplicate(self): | ||||
|         """Test group create (duplicate)""" | ||||
|         group = Group.objects.create(name=generate_id()) | ||||
|         existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||
|         ext_id = generate_id() | ||||
|         response = self.client.post( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                 }, | ||||
|             ), | ||||
|             data=dumps( | ||||
|                 {"displayName": generate_id(), "externalId": ext_id, "id": str(existing.group.pk)} | ||||
|             ), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 409) | ||||
|         self.assertJSONEqual( | ||||
|             response.content, | ||||
|             { | ||||
|                 "detail": "Group with ID exists already.", | ||||
|                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], | ||||
|                 "scimType": "uniqueness", | ||||
|                 "status": 409, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_group_update(self): | ||||
|         """Test group update""" | ||||
|         group = Group.objects.create(name=generate_id()) | ||||
|         existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||
|         ext_id = generate_id() | ||||
|         response = self.client.put( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={"source_slug": self.source.slug, "group_id": group.pk}, | ||||
|             ), | ||||
|             data=dumps( | ||||
|                 {"displayName": generate_id(), "externalId": ext_id, "id": str(existing.pk)} | ||||
|             ), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, second=200) | ||||
|  | ||||
|     def test_group_update_non_existent(self): | ||||
|         """Test group update""" | ||||
|         ext_id = generate_id() | ||||
|         response = self.client.put( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                     "group_id": str(uuid4()), | ||||
|                 }, | ||||
|             ), | ||||
|             data=dumps({"displayName": generate_id(), "externalId": ext_id, "id": ""}), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, second=404) | ||||
|         self.assertJSONEqual( | ||||
|             response.content, | ||||
|             { | ||||
|                 "detail": "Group not found.", | ||||
|                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], | ||||
|                 "status": 404, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_group_patch_add(self): | ||||
|         """Test group patch""" | ||||
|         user = create_test_user() | ||||
|  | ||||
|         group = Group.objects.create(name=generate_id()) | ||||
|         SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||
|         response = self.client.patch( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={"source_slug": self.source.slug, "group_id": group.pk}, | ||||
|             ), | ||||
|             data=dumps( | ||||
|                 { | ||||
|                     "Operations": [ | ||||
|                         { | ||||
|                             "op": "Add", | ||||
|                             "path": "members", | ||||
|                             "value": {"value": str(user.uuid)}, | ||||
|                         } | ||||
|                     ] | ||||
|                 } | ||||
|             ), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, second=200) | ||||
|         self.assertTrue(group.users.filter(pk=user.pk).exists()) | ||||
|  | ||||
|     def test_group_patch_remove(self): | ||||
|         """Test group patch""" | ||||
|         user = create_test_user() | ||||
|  | ||||
|         group = Group.objects.create(name=generate_id()) | ||||
|         group.users.add(user) | ||||
|         SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||
|         response = self.client.patch( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={"source_slug": self.source.slug, "group_id": group.pk}, | ||||
|             ), | ||||
|             data=dumps( | ||||
|                 { | ||||
|                     "Operations": [ | ||||
|                         { | ||||
|                             "op": "remove", | ||||
|                             "path": "members", | ||||
|                             "value": {"value": str(user.uuid)}, | ||||
|                         } | ||||
|                     ] | ||||
|                 } | ||||
|             ), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, second=200) | ||||
|         self.assertFalse(group.users.filter(pk=user.pk).exists()) | ||||
|  | ||||
|     def test_group_delete(self): | ||||
|         """Test group delete""" | ||||
|         group = Group.objects.create(name=generate_id()) | ||||
|         SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4()) | ||||
|         response = self.client.delete( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-groups", | ||||
|                 kwargs={"source_slug": self.source.slug, "group_id": group.pk}, | ||||
|             ), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, second=204) | ||||
| @ -177,51 +177,3 @@ class TestSCIMUsers(APITestCase): | ||||
|             SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"], | ||||
|             "0123456789", | ||||
|         ) | ||||
|  | ||||
|     def test_user_update(self): | ||||
|         """Test user update""" | ||||
|         user = create_test_user() | ||||
|         existing = SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4()) | ||||
|         ext_id = generate_id() | ||||
|         response = self.client.put( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-users", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                     "user_id": str(user.uuid), | ||||
|                 }, | ||||
|             ), | ||||
|             data=dumps( | ||||
|                 { | ||||
|                     "id": str(existing.pk), | ||||
|                     "userName": generate_id(), | ||||
|                     "externalId": ext_id, | ||||
|                     "emails": [ | ||||
|                         { | ||||
|                             "primary": True, | ||||
|                             "value": user.email, | ||||
|                         } | ||||
|                     ], | ||||
|                 } | ||||
|             ), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|     def test_user_delete(self): | ||||
|         """Test user delete""" | ||||
|         user = create_test_user() | ||||
|         SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4()) | ||||
|         response = self.client.delete( | ||||
|             reverse( | ||||
|                 "authentik_sources_scim:v2-users", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                     "user_id": str(user.uuid), | ||||
|                 }, | ||||
|             ), | ||||
|             content_type=SCIM_CONTENT_TYPE, | ||||
|             HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 204) | ||||
|  | ||||
| @ -8,7 +8,6 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_ | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from authentik.core.middleware import CTX_AUTH_VIA | ||||
| from authentik.core.models import Token, TokenIntents, User | ||||
| from authentik.sources.scim.models import SCIMSource | ||||
|  | ||||
| @ -27,7 +26,6 @@ class SCIMTokenAuth(BaseAuthentication): | ||||
|         _username, _, password = b64decode(key.encode()).decode().partition(":") | ||||
|         token = self.check_token(password, source_slug) | ||||
|         if token: | ||||
|             CTX_AUTH_VIA.set("scim_basic") | ||||
|             return (token.user, token) | ||||
|         return None | ||||
|  | ||||
| @ -54,5 +52,4 @@ class SCIMTokenAuth(BaseAuthentication): | ||||
|         token = self.check_token(key, source_slug) | ||||
|         if not token: | ||||
|             return None | ||||
|         CTX_AUTH_VIA.set("scim_token") | ||||
|         return (token.user, token) | ||||
|  | ||||
| @ -1,11 +1,13 @@ | ||||
| """SCIM Utils""" | ||||
|  | ||||
| from typing import Any | ||||
| from urllib.parse import urlparse | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.core.paginator import Page, Paginator | ||||
| from django.db.models import Q, QuerySet | ||||
| from django.http import HttpRequest | ||||
| from django.urls import resolve | ||||
| from rest_framework.parsers import JSONParser | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.renderers import JSONRenderer | ||||
| @ -44,7 +46,7 @@ class SCIMView(APIView): | ||||
|     logger: BoundLogger | ||||
|  | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     parser_classes = [SCIMParser, JSONParser] | ||||
|     parser_classes = [SCIMParser] | ||||
|     renderer_classes = [SCIMRenderer] | ||||
|  | ||||
|     def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: | ||||
| @ -54,6 +56,28 @@ class SCIMView(APIView): | ||||
|     def get_authenticators(self): | ||||
|         return [SCIMTokenAuth(self)] | ||||
|  | ||||
|     def patch_resolve_value(self, raw_value: dict) -> User | Group | None: | ||||
|         """Attempt to resolve a raw `value` attribute of a patch operation into | ||||
|         a database model""" | ||||
|         model = User | ||||
|         query = {} | ||||
|         if "$ref" in raw_value: | ||||
|             url = urlparse(raw_value["$ref"]) | ||||
|             if match := resolve(url.path): | ||||
|                 if match.url_name == "v2-users": | ||||
|                     model = User | ||||
|                     query = {"pk": int(match.kwargs["user_id"])} | ||||
|         elif "type" in raw_value: | ||||
|             match raw_value["type"]: | ||||
|                 case "User": | ||||
|                     model = User | ||||
|                     query = {"pk": int(raw_value["value"])} | ||||
|                 case "Group": | ||||
|                     model = Group | ||||
|         else: | ||||
|             return None | ||||
|         return model.objects.filter(**query).first() | ||||
|  | ||||
|     def filter_parse(self, request: Request): | ||||
|         """Parse the path of a Patch Operation""" | ||||
|         path = request.query_params.get("filter") | ||||
|  | ||||
| @ -1,58 +0,0 @@ | ||||
| from enum import Enum | ||||
|  | ||||
| from pydanticscim.responses import SCIMError as BaseSCIMError | ||||
| from rest_framework.exceptions import ValidationError | ||||
|  | ||||
|  | ||||
| class SCIMErrorTypes(Enum): | ||||
|     invalid_filter = "invalidFilter" | ||||
|     too_many = "tooMany" | ||||
|     uniqueness = "uniqueness" | ||||
|     mutability = "mutability" | ||||
|     invalid_syntax = "invalidSyntax" | ||||
|     invalid_path = "invalidPath" | ||||
|     no_target = "noTarget" | ||||
|     invalid_value = "invalidValue" | ||||
|     invalid_vers = "invalidVers" | ||||
|     sensitive = "sensitive" | ||||
|  | ||||
|  | ||||
| class SCIMError(BaseSCIMError): | ||||
|     scimType: SCIMErrorTypes | None = None | ||||
|     detail: str | None = None | ||||
|  | ||||
|  | ||||
| class SCIMValidationError(ValidationError): | ||||
|     status_code = 400 | ||||
|     default_detail = SCIMError(scimType=SCIMErrorTypes.invalid_syntax, status=400) | ||||
|  | ||||
|     def __init__(self, detail: SCIMError | None): | ||||
|         if detail is None: | ||||
|             detail = self.default_detail | ||||
|         detail.status = self.status_code | ||||
|         self.detail = detail.model_dump(mode="json", exclude_none=True) | ||||
|  | ||||
|  | ||||
| class SCIMConflictError(SCIMValidationError): | ||||
|     status_code = 409 | ||||
|  | ||||
|     def __init__(self, detail: str): | ||||
|         super().__init__( | ||||
|             SCIMError( | ||||
|                 detail=detail, | ||||
|                 scimType=SCIMErrorTypes.uniqueness, | ||||
|                 status=self.status_code, | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class SCIMNotFoundError(SCIMValidationError): | ||||
|     status_code = 404 | ||||
|  | ||||
|     def __init__(self, detail: str): | ||||
|         super().__init__( | ||||
|             SCIMError( | ||||
|                 detail=detail, | ||||
|                 status=self.status_code, | ||||
|             ) | ||||
|         ) | ||||
| @ -4,25 +4,19 @@ from uuid import uuid4 | ||||
|  | ||||
| from django.db.models import Q | ||||
| from django.db.transaction import atomic | ||||
| from django.http import QueryDict | ||||
| from django.http import Http404, QueryDict | ||||
| from django.urls import reverse | ||||
| from pydantic import ValidationError as PydanticValidationError | ||||
| from pydanticscim.group import GroupMember | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
| from scim2_filter_parser.attr_paths import AttrPath | ||||
|  | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation | ||||
| from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA | ||||
| from authentik.providers.scim.clients.schema import Group as SCIMGroupModel | ||||
| from authentik.sources.scim.models import SCIMSourceGroup | ||||
| from authentik.sources.scim.views.v2.base import SCIMObjectView | ||||
| from authentik.sources.scim.views.v2.exceptions import ( | ||||
|     SCIMConflictError, | ||||
|     SCIMNotFoundError, | ||||
|     SCIMValidationError, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class GroupsView(SCIMObjectView): | ||||
| @ -33,7 +27,7 @@ class GroupsView(SCIMObjectView): | ||||
|     def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict: | ||||
|         """Convert Group to SCIM data""" | ||||
|         payload = SCIMGroupModel( | ||||
|             schemas=[SCIM_GROUP_SCHEMA], | ||||
|             schemas=[SCIM_USER_SCHEMA], | ||||
|             id=str(scim_group.group.pk), | ||||
|             externalId=scim_group.id, | ||||
|             displayName=scim_group.group.name, | ||||
| @ -64,7 +58,7 @@ class GroupsView(SCIMObjectView): | ||||
|         if group_id: | ||||
|             connection = base_query.filter(source=self.source, group__group_uuid=group_id).first() | ||||
|             if not connection: | ||||
|                 raise SCIMNotFoundError("Group not found.") | ||||
|                 raise Http404 | ||||
|             return Response(self.group_to_scim(connection)) | ||||
|         connections = ( | ||||
|             base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request)) | ||||
| @ -125,7 +119,7 @@ class GroupsView(SCIMObjectView): | ||||
|         ).first() | ||||
|         if connection: | ||||
|             self.logger.debug("Found existing group") | ||||
|             raise SCIMConflictError("Group with ID exists already.") | ||||
|             return Response(status=409) | ||||
|         connection = self.update_group(None, request.data) | ||||
|         return Response(self.group_to_scim(connection), status=201) | ||||
|  | ||||
| @ -135,44 +129,10 @@ class GroupsView(SCIMObjectView): | ||||
|             source=self.source, group__group_uuid=group_id | ||||
|         ).first() | ||||
|         if not connection: | ||||
|             raise SCIMNotFoundError("Group not found.") | ||||
|             raise Http404 | ||||
|         connection = self.update_group(connection, request.data) | ||||
|         return Response(self.group_to_scim(connection), status=200) | ||||
|  | ||||
|     @atomic | ||||
|     def patch(self, request: Request, group_id: str, **kwargs) -> Response: | ||||
|         """Patch group handler""" | ||||
|         connection = SCIMSourceGroup.objects.filter( | ||||
|             source=self.source, group__group_uuid=group_id | ||||
|         ).first() | ||||
|         if not connection: | ||||
|             raise SCIMNotFoundError("Group not found.") | ||||
|  | ||||
|         for _op in request.data.get("Operations", []): | ||||
|             operation = PatchOperation.model_validate(_op) | ||||
|             if operation.op.lower() not in ["add", "remove", "replace"]: | ||||
|                 raise SCIMValidationError() | ||||
|             attr_path = AttrPath(f'{operation.path} eq ""', {}) | ||||
|             if attr_path.first_path == ("members", None, None): | ||||
|                 # FIXME: this can probably be de-duplicated | ||||
|                 if operation.op == PatchOp.add: | ||||
|                     if not isinstance(operation.value, list): | ||||
|                         operation.value = [operation.value] | ||||
|                     query = Q() | ||||
|                     for member in operation.value: | ||||
|                         query |= Q(uuid=member["value"]) | ||||
|                     if query: | ||||
|                         connection.group.users.add(*User.objects.filter(query)) | ||||
|                 elif operation.op == PatchOp.remove: | ||||
|                     if not isinstance(operation.value, list): | ||||
|                         operation.value = [operation.value] | ||||
|                     query = Q() | ||||
|                     for member in operation.value: | ||||
|                         query |= Q(uuid=member["value"]) | ||||
|                     if query: | ||||
|                         connection.group.users.remove(*User.objects.filter(query)) | ||||
|         return Response(self.group_to_scim(connection), status=200) | ||||
|  | ||||
|     @atomic | ||||
|     def delete(self, request: Request, group_id: str, **kwargs) -> Response: | ||||
|         """Delete group handler""" | ||||
| @ -180,7 +140,7 @@ class GroupsView(SCIMObjectView): | ||||
|             source=self.source, group__group_uuid=group_id | ||||
|         ).first() | ||||
|         if not connection: | ||||
|             raise SCIMNotFoundError("Group not found.") | ||||
|             raise Http404 | ||||
|         connection.group.delete() | ||||
|         connection.delete() | ||||
|         return Response(status=204) | ||||
|  | ||||
| @ -1,11 +1,11 @@ | ||||
| """SCIM Meta views""" | ||||
|  | ||||
| from django.http import Http404 | ||||
| from django.urls import reverse | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from authentik.sources.scim.views.v2.base import SCIMView | ||||
| from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError | ||||
|  | ||||
|  | ||||
| class ResourceTypesView(SCIMView): | ||||
| @ -138,7 +138,7 @@ class ResourceTypesView(SCIMView): | ||||
|             resource = [x for x in resource_types if x.get("id") == resource_type] | ||||
|             if resource: | ||||
|                 return Response(resource[0]) | ||||
|             raise SCIMNotFoundError("Resource not found.") | ||||
|             raise Http404 | ||||
|         return Response( | ||||
|             { | ||||
|                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], | ||||
|  | ||||
| @ -3,12 +3,12 @@ | ||||
| from json import loads | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.http import Http404 | ||||
| from django.urls import reverse | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from authentik.sources.scim.views.v2.base import SCIMView | ||||
| from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError | ||||
|  | ||||
| with open( | ||||
|     settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json", | ||||
| @ -44,7 +44,7 @@ class SchemaView(SCIMView): | ||||
|             schema = [x for x in schemas if x.get("id") == schema_uri] | ||||
|             if schema: | ||||
|                 return Response(schema[0]) | ||||
|             raise SCIMNotFoundError("Schema not found.") | ||||
|             raise Http404 | ||||
|         return Response( | ||||
|             { | ||||
|                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], | ||||
|  | ||||
| @ -33,8 +33,6 @@ class ServiceProviderConfigView(SCIMView): | ||||
|             { | ||||
|                 "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], | ||||
|                 "authenticationSchemes": auth_schemas, | ||||
|                 # We only support patch for groups currently, so don't broadly advertise it. | ||||
|                 # Implementations that require Group patch will use it regardless of this flag. | ||||
|                 "patch": {"supported": False}, | ||||
|                 "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, | ||||
|                 "filter": { | ||||
|  | ||||
| @ -4,7 +4,7 @@ from uuid import uuid4 | ||||
|  | ||||
| from django.db.models import Q | ||||
| from django.db.transaction import atomic | ||||
| from django.http import QueryDict | ||||
| from django.http import Http404, QueryDict | ||||
| from django.urls import reverse | ||||
| from pydanticscim.user import Email, EmailKind, Name | ||||
| from rest_framework.exceptions import ValidationError | ||||
| @ -16,7 +16,6 @@ from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA | ||||
| from authentik.providers.scim.clients.schema import User as SCIMUserModel | ||||
| from authentik.sources.scim.models import SCIMSourceUser | ||||
| from authentik.sources.scim.views.v2.base import SCIMObjectView | ||||
| from authentik.sources.scim.views.v2.exceptions import SCIMConflictError, SCIMNotFoundError | ||||
|  | ||||
|  | ||||
| class UsersView(SCIMObjectView): | ||||
| @ -70,7 +69,7 @@ class UsersView(SCIMObjectView): | ||||
|                 .first() | ||||
|             ) | ||||
|             if not connection: | ||||
|                 raise SCIMNotFoundError("User not found.") | ||||
|                 raise Http404 | ||||
|             return Response(self.user_to_scim(connection)) | ||||
|         connections = ( | ||||
|             SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk") | ||||
| @ -123,7 +122,7 @@ class UsersView(SCIMObjectView): | ||||
|         ).first() | ||||
|         if connection: | ||||
|             self.logger.debug("Found existing user") | ||||
|             raise SCIMConflictError("Group with ID exists already.") | ||||
|             return Response(status=409) | ||||
|         connection = self.update_user(None, request.data) | ||||
|         return Response(self.user_to_scim(connection), status=201) | ||||
|  | ||||
| @ -131,7 +130,7 @@ class UsersView(SCIMObjectView): | ||||
|         """Update user handler""" | ||||
|         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() | ||||
|         if not connection: | ||||
|             raise SCIMNotFoundError("User not found.") | ||||
|             raise Http404 | ||||
|         self.update_user(connection, request.data) | ||||
|         return Response(self.user_to_scim(connection), status=200) | ||||
|  | ||||
| @ -140,7 +139,7 @@ class UsersView(SCIMObjectView): | ||||
|         """Delete user handler""" | ||||
|         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() | ||||
|         if not connection: | ||||
|             raise SCIMNotFoundError("User not found.") | ||||
|             raise Http404 | ||||
|         connection.user.delete() | ||||
|         connection.delete() | ||||
|         return Response(status=204) | ||||
|  | ||||
| @ -13,7 +13,6 @@ from authentik.flows.exceptions import StageInvalidException | ||||
| from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.models import SerializerModel | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.time import timedelta_string_validator | ||||
| from authentik.stages.authenticator.models import SideChannelDevice | ||||
| from authentik.stages.email.utils import TemplateEmailMessage | ||||
| @ -160,9 +159,8 @@ class EmailDevice(SerializerModel, SideChannelDevice): | ||||
|             Event.new( | ||||
|                 EventAction.CONFIGURATION_ERROR, | ||||
|                 message=_("Exception occurred while rendering E-mail template"), | ||||
|                 error=exception_to_string(exc), | ||||
|                 template=stage.template, | ||||
|             ).from_http(self.request) | ||||
|             ).with_exception(exc).from_http(self.request) | ||||
|             raise StageInvalidException from exc | ||||
|  | ||||
|     def __str__(self): | ||||
|  | ||||
| @ -17,7 +17,6 @@ from authentik.flows.challenge import ( | ||||
| from authentik.flows.exceptions import StageInvalidException | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.lib.utils.email import mask_email | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.stages.authenticator_email.models import ( | ||||
|     AuthenticatorEmailStage, | ||||
| @ -100,9 +99,8 @@ class AuthenticatorEmailStageView(ChallengeStageView): | ||||
|             Event.new( | ||||
|                 EventAction.CONFIGURATION_ERROR, | ||||
|                 message=_("Exception occurred while rendering E-mail template"), | ||||
|                 error=exception_to_string(exc), | ||||
|                 template=stage.template, | ||||
|             ).from_http(self.request) | ||||
|             ).with_exception(exc).from_http(self.request) | ||||
|             raise StageInvalidException from exc | ||||
|  | ||||
|     def _has_email(self) -> str | None: | ||||
|  | ||||
| @ -19,7 +19,6 @@ from authentik.events.models import Event, EventAction, NotificationWebhookMappi | ||||
| from authentik.events.utils import sanitize_item | ||||
| from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage | ||||
| from authentik.lib.models import SerializerModel | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.http import get_http_session | ||||
| from authentik.stages.authenticator.models import SideChannelDevice | ||||
|  | ||||
| @ -142,10 +141,9 @@ class AuthenticatorSMSStage(ConfigurableStage, FriendlyNamedStage, Stage): | ||||
|             Event.new( | ||||
|                 EventAction.CONFIGURATION_ERROR, | ||||
|                 message="Error sending SMS", | ||||
|                 exc=exception_to_string(exc), | ||||
|                 status_code=response.status_code, | ||||
|                 body=response.text, | ||||
|             ).set_user(device.user).save() | ||||
|             ).with_exception(exc).set_user(device.user).save() | ||||
|             if response.status_code >= HttpResponseBadRequest.status_code: | ||||
|                 raise ValidationError(response.text) from None | ||||
|             raise | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| """Validation stage challenge checking""" | ||||
|  | ||||
| from json import loads | ||||
| from typing import TYPE_CHECKING | ||||
| from urllib.parse import urlencode | ||||
|  | ||||
| from django.http import HttpRequest | ||||
| @ -37,12 +36,10 @@ from authentik.stages.authenticator_email.models import EmailDevice | ||||
| from authentik.stages.authenticator_sms.models import SMSDevice | ||||
| from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | ||||
| from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice | ||||
| from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView | ||||
|  | ||||
|  | ||||
| class DeviceChallenge(PassiveSerializer): | ||||
| @ -55,11 +52,11 @@ class DeviceChallenge(PassiveSerializer): | ||||
|  | ||||
|  | ||||
| def get_challenge_for_device( | ||||
|     stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage, device: Device | ||||
|     request: HttpRequest, stage: AuthenticatorValidateStage, device: Device | ||||
| ) -> dict: | ||||
|     """Generate challenge for a single device""" | ||||
|     if isinstance(device, WebAuthnDevice): | ||||
|         return get_webauthn_challenge(stage_view, stage, device) | ||||
|         return get_webauthn_challenge(request, stage, device) | ||||
|     if isinstance(device, EmailDevice): | ||||
|         return {"email": mask_email(device.email)} | ||||
|     # Code-based challenges have no hints | ||||
| @ -67,30 +64,26 @@ def get_challenge_for_device( | ||||
|  | ||||
|  | ||||
| def get_webauthn_challenge_without_user( | ||||
|     stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage | ||||
|     request: HttpRequest, stage: AuthenticatorValidateStage | ||||
| ) -> dict: | ||||
|     """Same as `get_webauthn_challenge`, but allows any client device. We can then later check | ||||
|     who the device belongs to.""" | ||||
|     stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) | ||||
|     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) | ||||
|     authentication_options = generate_authentication_options( | ||||
|         rp_id=get_rp_id(stage_view.request), | ||||
|         rp_id=get_rp_id(request), | ||||
|         allow_credentials=[], | ||||
|         user_verification=UserVerificationRequirement(stage.webauthn_user_verification), | ||||
|     ) | ||||
|     stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = ( | ||||
|         authentication_options.challenge | ||||
|     ) | ||||
|     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge | ||||
|  | ||||
|     return loads(options_to_json(authentication_options)) | ||||
|  | ||||
|  | ||||
| def get_webauthn_challenge( | ||||
|     stage_view: "AuthenticatorValidateStageView", | ||||
|     stage: AuthenticatorValidateStage, | ||||
|     device: WebAuthnDevice | None = None, | ||||
|     request: HttpRequest, stage: AuthenticatorValidateStage, device: WebAuthnDevice | None = None | ||||
| ) -> dict: | ||||
|     """Send the client a challenge that we'll check later""" | ||||
|     stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) | ||||
|     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) | ||||
|  | ||||
|     allowed_credentials = [] | ||||
|  | ||||
| @ -101,14 +94,12 @@ def get_webauthn_challenge( | ||||
|             allowed_credentials.append(user_device.descriptor) | ||||
|  | ||||
|     authentication_options = generate_authentication_options( | ||||
|         rp_id=get_rp_id(stage_view.request), | ||||
|         rp_id=get_rp_id(request), | ||||
|         allow_credentials=allowed_credentials, | ||||
|         user_verification=UserVerificationRequirement(stage.webauthn_user_verification), | ||||
|     ) | ||||
|  | ||||
|     stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = ( | ||||
|         authentication_options.challenge | ||||
|     ) | ||||
|     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge | ||||
|  | ||||
|     return loads(options_to_json(authentication_options)) | ||||
|  | ||||
| @ -155,7 +146,7 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev | ||||
| def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device: | ||||
|     """Validate WebAuthn Challenge""" | ||||
|     request = stage_view.request | ||||
|     challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE) | ||||
|     challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE) | ||||
|     stage: AuthenticatorValidateStage = stage_view.executor.current_stage | ||||
|     try: | ||||
|         credential = parse_authentication_credential_json(data) | ||||
|  | ||||
| @ -224,7 +224,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | ||||
|                 data={ | ||||
|                     "device_class": device_class, | ||||
|                     "device_uid": device.pk, | ||||
|                     "challenge": get_challenge_for_device(self, stage, device), | ||||
|                     "challenge": get_challenge_for_device(self.request, stage, device), | ||||
|                     "last_used": device.last_used, | ||||
|                 } | ||||
|             ) | ||||
| @ -243,7 +243,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | ||||
|                 "device_class": DeviceClasses.WEBAUTHN, | ||||
|                 "device_uid": -1, | ||||
|                 "challenge": get_webauthn_challenge_without_user( | ||||
|                     self, | ||||
|                     self.request, | ||||
|                     self.executor.current_stage, | ||||
|                 ), | ||||
|                 "last_used": None, | ||||
|  | ||||
| @ -31,7 +31,7 @@ from authentik.stages.authenticator_webauthn.models import ( | ||||
|     WebAuthnDevice, | ||||
|     WebAuthnDeviceType, | ||||
| ) | ||||
| from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import | ||||
| from authentik.stages.identification.models import IdentificationStage, UserFields | ||||
| from authentik.stages.user_login.models import UserLoginStage | ||||
| @ -103,11 +103,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             device_classes=[DeviceClasses.WEBAUTHN], | ||||
|             webauthn_user_verification=UserVerification.PREFERRED, | ||||
|         ) | ||||
|         plan = FlowPlan("") | ||||
|         stage_view = AuthenticatorValidateStageView( | ||||
|             FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request | ||||
|         ) | ||||
|         challenge = get_challenge_for_device(stage_view, stage, webauthn_device) | ||||
|         challenge = get_challenge_for_device(request, stage, webauthn_device) | ||||
|         del challenge["challenge"] | ||||
|         self.assertEqual( | ||||
|             challenge, | ||||
| @ -126,9 +122,7 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|  | ||||
|         with self.assertRaises(ValidationError): | ||||
|             validate_challenge_webauthn( | ||||
|                 {}, | ||||
|                 StageView(FlowExecutorView(current_stage=stage, plan=plan), request=request), | ||||
|                 self.user, | ||||
|                 {}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user | ||||
|             ) | ||||
|  | ||||
|     def test_device_challenge_webauthn_restricted(self): | ||||
| @ -199,35 +193,22 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             sign_count=0, | ||||
|             rp_id=generate_id(), | ||||
|         ) | ||||
|         plan = FlowPlan("") | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" | ||||
|         ) | ||||
|         stage_view = AuthenticatorValidateStageView( | ||||
|             FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request | ||||
|         ) | ||||
|         challenge = get_challenge_for_device(stage_view, stage, webauthn_device) | ||||
|         challenge = get_challenge_for_device(request, stage, webauthn_device) | ||||
|         webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] | ||||
|         self.assertEqual( | ||||
|             challenge["allowCredentials"], | ||||
|             [ | ||||
|                 { | ||||
|                     "id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU", | ||||
|                     "type": "public-key", | ||||
|                 } | ||||
|             ], | ||||
|         ) | ||||
|         self.assertIsNotNone(challenge["challenge"]) | ||||
|         self.assertEqual( | ||||
|             challenge["rpId"], | ||||
|             "testserver", | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             challenge["timeout"], | ||||
|             60000, | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             challenge["userVerification"], | ||||
|             "preferred", | ||||
|             challenge, | ||||
|             { | ||||
|                 "allowCredentials": [ | ||||
|                     { | ||||
|                         "id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU", | ||||
|                         "type": "public-key", | ||||
|                     } | ||||
|                 ], | ||||
|                 "challenge": bytes_to_base64url(webauthn_challenge), | ||||
|                 "rpId": "testserver", | ||||
|                 "timeout": 60000, | ||||
|                 "userVerification": "preferred", | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_get_challenge_userless(self): | ||||
| @ -247,16 +228,18 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             sign_count=0, | ||||
|             rp_id=generate_id(), | ||||
|         ) | ||||
|         plan = FlowPlan("") | ||||
|         stage_view = AuthenticatorValidateStageView( | ||||
|             FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request | ||||
|         challenge = get_webauthn_challenge_without_user(request, stage) | ||||
|         webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] | ||||
|         self.assertEqual( | ||||
|             challenge, | ||||
|             { | ||||
|                 "allowCredentials": [], | ||||
|                 "challenge": bytes_to_base64url(webauthn_challenge), | ||||
|                 "rpId": "testserver", | ||||
|                 "timeout": 60000, | ||||
|                 "userVerification": "preferred", | ||||
|             }, | ||||
|         ) | ||||
|         challenge = get_webauthn_challenge_without_user(stage_view, stage) | ||||
|         self.assertEqual(challenge["allowCredentials"], []) | ||||
|         self.assertIsNotNone(challenge["challenge"]) | ||||
|         self.assertEqual(challenge["rpId"], "testserver") | ||||
|         self.assertEqual(challenge["timeout"], 60000) | ||||
|         self.assertEqual(challenge["userVerification"], "preferred") | ||||
|  | ||||
|     def test_validate_challenge_unrestricted(self): | ||||
|         """Test webauthn authentication (unrestricted webauthn device)""" | ||||
| @ -292,10 +275,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|                 "last_used": None, | ||||
|             } | ||||
|         ] | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" | ||||
|         ) | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         response = self.client.post( | ||||
| @ -369,10 +352,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|                 "last_used": None, | ||||
|             } | ||||
|         ] | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" | ||||
|         ) | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         response = self.client.post( | ||||
| @ -450,10 +433,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|                 "last_used": None, | ||||
|             } | ||||
|         ] | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" | ||||
|         ) | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         response = self.client.post( | ||||
| @ -513,14 +496,17 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||
|             device_classes=[DeviceClasses.WEBAUTHN], | ||||
|         ) | ||||
|         plan = FlowPlan(flow.pk.hex) | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" | ||||
|         stage_view = AuthenticatorValidateStageView( | ||||
|             FlowExecutorView(flow=flow, current_stage=stage), request=request | ||||
|         ) | ||||
|         request = get_request("/") | ||||
|         request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" | ||||
|         ) | ||||
|         request.session.save() | ||||
|  | ||||
|         stage_view = AuthenticatorValidateStageView( | ||||
|             FlowExecutorView(flow=flow, current_stage=stage, plan=plan), request=request | ||||
|             FlowExecutorView(flow=flow, current_stage=stage), request=request | ||||
|         ) | ||||
|         request.META["SERVER_NAME"] = "localhost" | ||||
|         request.META["SERVER_PORT"] = "9000" | ||||
|  | ||||
| @ -25,7 +25,6 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer): | ||||
|             "resident_key_requirement", | ||||
|             "device_type_restrictions", | ||||
|             "device_type_restrictions_obj", | ||||
|             "max_attempts", | ||||
|         ] | ||||
|  | ||||
|  | ||||
|  | ||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @ -1,21 +0,0 @@ | ||||
| # Generated by Django 5.1.11 on 2025-06-13 22:41 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ( | ||||
|             "authentik_stages_authenticator_webauthn", | ||||
|             "0012_webauthndevice_created_webauthndevice_last_updated_and_more", | ||||
|         ), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="authenticatorwebauthnstage", | ||||
|             name="max_attempts", | ||||
|             field=models.PositiveIntegerField(default=0), | ||||
|         ), | ||||
|     ] | ||||
| @ -84,8 +84,6 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage): | ||||
|  | ||||
|     device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True) | ||||
|  | ||||
|     max_attempts = models.PositiveIntegerField(default=0) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.stages.authenticator_webauthn.api.stages import ( | ||||
|  | ||||
| @ -5,13 +5,12 @@ from uuid import UUID | ||||
|  | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.http.request import QueryDict | ||||
| from django.utils.translation import gettext as __ | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from rest_framework.fields import CharField | ||||
| from rest_framework.serializers import ValidationError | ||||
| from webauthn import options_to_json | ||||
| from webauthn.helpers.bytes_to_base64url import bytes_to_base64url | ||||
| from webauthn.helpers.exceptions import WebAuthnException | ||||
| from webauthn.helpers.exceptions import InvalidRegistrationResponse | ||||
| from webauthn.helpers.structs import ( | ||||
|     AttestationConveyancePreference, | ||||
|     AuthenticatorAttachment, | ||||
| @ -42,8 +41,7 @@ from authentik.stages.authenticator_webauthn.models import ( | ||||
| ) | ||||
| from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id | ||||
|  | ||||
| PLAN_CONTEXT_WEBAUTHN_CHALLENGE = "goauthentik.io/stages/authenticator_webauthn/challenge" | ||||
| PLAN_CONTEXT_WEBAUTHN_ATTEMPT = "goauthentik.io/stages/authenticator_webauthn/attempt" | ||||
| SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge" | ||||
|  | ||||
|  | ||||
| class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge): | ||||
| @ -64,7 +62,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): | ||||
|  | ||||
|     def validate_response(self, response: dict) -> dict: | ||||
|         """Validate webauthn challenge response""" | ||||
|         challenge = self.stage.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] | ||||
|         challenge = self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] | ||||
|  | ||||
|         try: | ||||
|             registration: VerifiedRegistration = verify_registration_response( | ||||
| @ -73,7 +71,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): | ||||
|                 expected_rp_id=get_rp_id(self.request), | ||||
|                 expected_origin=get_origin(self.request), | ||||
|             ) | ||||
|         except WebAuthnException as exc: | ||||
|         except InvalidRegistrationResponse as exc: | ||||
|             self.stage.logger.warning("registration failed", exc=exc) | ||||
|             raise ValidationError(f"Registration failed. Error: {exc}") from None | ||||
|  | ||||
| @ -116,10 +114,9 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | ||||
|     response_class = AuthenticatorWebAuthnChallengeResponse | ||||
|  | ||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         # clear session variables prior to starting a new registration | ||||
|         self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) | ||||
|         stage: AuthenticatorWebAuthnStage = self.executor.current_stage | ||||
|         self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0) | ||||
|         # clear flow variables prior to starting a new registration | ||||
|         self.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) | ||||
|         user = self.get_pending_user() | ||||
|  | ||||
|         # library accepts none so we store null in the database, but if there is a value | ||||
| @ -142,7 +139,8 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | ||||
|             attestation=AttestationConveyancePreference.DIRECT, | ||||
|         ) | ||||
|  | ||||
|         self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = registration_options.challenge | ||||
|         self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = registration_options.challenge | ||||
|         self.request.session.save() | ||||
|         return AuthenticatorWebAuthnChallenge( | ||||
|             data={ | ||||
|                 "registration": loads(options_to_json(registration_options)), | ||||
| @ -155,24 +153,6 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | ||||
|         response.user = self.get_pending_user() | ||||
|         return response | ||||
|  | ||||
|     def challenge_invalid(self, response): | ||||
|         stage: AuthenticatorWebAuthnStage = self.executor.current_stage | ||||
|         self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0) | ||||
|         self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] += 1 | ||||
|         if ( | ||||
|             stage.max_attempts > 0 | ||||
|             and self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] >= stage.max_attempts | ||||
|         ): | ||||
|             return self.executor.stage_invalid( | ||||
|                 __( | ||||
|                     "Exceeded maximum attempts. " | ||||
|                     "Contact your {brand} administrator for help.".format( | ||||
|                         brand=self.request.brand.branding_title | ||||
|                     ) | ||||
|                 ) | ||||
|             ) | ||||
|         return super().challenge_invalid(response) | ||||
|  | ||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||
|         # Webauthn Challenge has already been validated | ||||
|         webauthn_credential: VerifiedRegistration = response.validated_data["response"] | ||||
| @ -199,3 +179,6 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | ||||
|         else: | ||||
|             return self.executor.stage_invalid("Device with Credential ID already exists.") | ||||
|         return self.executor.stage_ok() | ||||
|  | ||||
|     def cleanup(self): | ||||
|         self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) | ||||
|  | ||||
| @ -18,7 +18,7 @@ from authentik.stages.authenticator_webauthn.models import ( | ||||
|     WebAuthnDevice, | ||||
|     WebAuthnDeviceType, | ||||
| ) | ||||
| from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import | ||||
|  | ||||
|  | ||||
| @ -57,9 +57,6 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|         ) | ||||
|  | ||||
|         plan: FlowPlan = self.client.session[SESSION_KEY_PLAN] | ||||
|  | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         session = self.client.session | ||||
|         self.assertStageResponse( | ||||
| @ -73,7 +70,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | ||||
|                     "name": self.user.username, | ||||
|                     "displayName": self.user.name, | ||||
|                 }, | ||||
|                 "challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]), | ||||
|                 "challenge": bytes_to_base64url(session[SESSION_KEY_WEBAUTHN_CHALLENGE]), | ||||
|                 "pubKeyCredParams": [ | ||||
|                     {"type": "public-key", "alg": -7}, | ||||
|                     {"type": "public-key", "alg": -8}, | ||||
| @ -100,11 +97,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | ||||
|         """Test registration""" | ||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session.save() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
| @ -149,11 +146,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | ||||
|  | ||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session.save() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
| @ -212,11 +209,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | ||||
|  | ||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session.save() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
| @ -262,11 +259,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | ||||
|  | ||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session.save() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
| @ -301,109 +298,3 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) | ||||
|         self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists()) | ||||
|  | ||||
|     def test_register_max_retries(self): | ||||
|         """Test registration (exceeding max retries)""" | ||||
|         self.stage.max_attempts = 2 | ||||
|         self.stage.save() | ||||
|  | ||||
|         plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) | ||||
|         plan.context[PLAN_CONTEXT_PENDING_USER] = self.user | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         # first failed request | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             data={ | ||||
|                 "component": "ak-stage-authenticator-webauthn", | ||||
|                 "response": { | ||||
|                     "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", | ||||
|                     "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", | ||||
|                     "type": "public-key", | ||||
|                     "registrationClientExtensions": "{}", | ||||
|                     "response": { | ||||
|                         "clientDataJSON": ( | ||||
|                             "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" | ||||
|                             "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" | ||||
|                             "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" | ||||
|                             "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" | ||||
|                             "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" | ||||
|                         ), | ||||
|                         "attestationObject": ( | ||||
|                             "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" | ||||
|                             "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" | ||||
|                             "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" | ||||
|                             "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" | ||||
|                             "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" | ||||
|                         ), | ||||
|                     }, | ||||
|                 }, | ||||
|             }, | ||||
|             SERVER_NAME="localhost", | ||||
|             SERVER_PORT="9000", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertStageResponse( | ||||
|             response, | ||||
|             flow=self.flow, | ||||
|             component="ak-stage-authenticator-webauthn", | ||||
|             response_errors={ | ||||
|                 "response": [ | ||||
|                     { | ||||
|                         "string": ( | ||||
|                             "Registration failed. Error: Unable to decode " | ||||
|                             "client_data_json bytes as JSON" | ||||
|                         ), | ||||
|                         "code": "invalid", | ||||
|                     } | ||||
|                 ] | ||||
|             }, | ||||
|         ) | ||||
|         self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists()) | ||||
|  | ||||
|         # Second failed request | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
|             data={ | ||||
|                 "component": "ak-stage-authenticator-webauthn", | ||||
|                 "response": { | ||||
|                     "id": "kqnmrVLnDG-OwsSNHkihYZaNz5s", | ||||
|                     "rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s", | ||||
|                     "type": "public-key", | ||||
|                     "registrationClientExtensions": "{}", | ||||
|                     "response": { | ||||
|                         "clientDataJSON": ( | ||||
|                             "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd" | ||||
|                             "lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV" | ||||
|                             "pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU" | ||||
|                             "mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw" | ||||
|                             "Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF" | ||||
|                         ), | ||||
|                         "attestationObject": ( | ||||
|                             "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg" | ||||
|                             "OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA" | ||||
|                             "cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp" | ||||
|                             "QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq" | ||||
|                             "2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds" | ||||
|                         ), | ||||
|                     }, | ||||
|                 }, | ||||
|             }, | ||||
|             SERVER_NAME="localhost", | ||||
|             SERVER_PORT="9000", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|         self.assertStageResponse( | ||||
|             response, | ||||
|             flow=self.flow, | ||||
|             component="ak-stage-access-denied", | ||||
|             error_message=( | ||||
|                 "Exceeded maximum attempts. Contact your authentik administrator for help." | ||||
|             ), | ||||
|         ) | ||||
|         self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists()) | ||||
|  | ||||
| @ -21,7 +21,6 @@ from authentik.flows.models import FlowDesignation, FlowToken | ||||
| from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.stages.email.flow import pickle_flow_token_for_email | ||||
| from authentik.stages.email.models import EmailStage | ||||
| @ -129,9 +128,8 @@ class EmailStageView(ChallengeStageView): | ||||
|             Event.new( | ||||
|                 EventAction.CONFIGURATION_ERROR, | ||||
|                 message=_("Exception occurred while rendering E-mail template"), | ||||
|                 error=exception_to_string(exc), | ||||
|                 template=current_stage.template, | ||||
|             ).from_http(self.request) | ||||
|             ).with_exception(exc).from_http(self.request) | ||||
|             raise StageInvalidException from exc | ||||
|  | ||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||
|  | ||||
| @ -27,6 +27,7 @@ | ||||
|     </table> | ||||
|   </td> | ||||
| </tr> | ||||
| <td> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block sub_content %} | ||||
|  | ||||
| @ -1,7 +1,6 @@ | ||||
| """Serializer for tenants models""" | ||||
|  | ||||
| from django_tenants.utils import get_public_schema_name | ||||
| from rest_framework.fields import JSONField | ||||
| from rest_framework.generics import RetrieveUpdateAPIView | ||||
| from rest_framework.permissions import SAFE_METHODS | ||||
|  | ||||
| @ -13,8 +12,6 @@ from authentik.tenants.models import Tenant | ||||
| class SettingsSerializer(ModelSerializer): | ||||
|     """Settings Serializer""" | ||||
|  | ||||
|     footer_links = JSONField(required=False) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Tenant | ||||
|         fields = [ | ||||
|  | ||||
| @ -16,7 +16,6 @@ def check_embedded_outpost_disabled(app_configs, **kwargs): | ||||
|                 "Embedded outpost must be disabled when tenants API is enabled.", | ||||
|                 hint="Disable embedded outpost by setting outposts.disable_embedded_outpost to " | ||||
|                 "True, or disable the tenants API by setting tenants.enabled to False", | ||||
|                 id="ak.tenants.E001", | ||||
|             ) | ||||
|         ] | ||||
|     return [] | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|     "$schema": "http://json-schema.org/draft-07/schema", | ||||
|     "$id": "https://goauthentik.io/blueprints/schema.json", | ||||
|     "type": "object", | ||||
|     "title": "authentik 2025.6.3 Blueprint schema", | ||||
|     "title": "authentik 2025.6.2 Blueprint schema", | ||||
|     "required": [ | ||||
|         "version", | ||||
|         "entries" | ||||
| @ -13310,12 +13310,6 @@ | ||||
|                         "format": "uuid" | ||||
|                     }, | ||||
|                     "title": "Device type restrictions" | ||||
|                 }, | ||||
|                 "max_attempts": { | ||||
|                     "type": "integer", | ||||
|                     "minimum": 0, | ||||
|                     "maximum": 2147483647, | ||||
|                     "title": "Max attempts" | ||||
|                 } | ||||
|             }, | ||||
|             "required": [] | ||||
|  | ||||
| @ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redis:/data | ||||
|   server: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.3} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.2} | ||||
|     restart: unless-stopped | ||||
|     command: server | ||||
|     environment: | ||||
| @ -55,7 +55,7 @@ services: | ||||
|       redis: | ||||
|         condition: service_healthy | ||||
|   worker: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.3} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.2} | ||||
|     restart: unless-stopped | ||||
|     command: worker | ||||
|     environment: | ||||
|  | ||||
							
								
								
									
										6
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										6
									
								
								go.mod
									
									
									
									
									
								
							| @ -6,7 +6,7 @@ require ( | ||||
| 	beryju.io/ldap v0.1.0 | ||||
| 	github.com/avast/retry-go/v4 v4.6.1 | ||||
| 	github.com/coreos/go-oidc/v3 v3.14.1 | ||||
| 	github.com/getsentry/sentry-go v0.34.0 | ||||
| 	github.com/getsentry/sentry-go v0.33.0 | ||||
| 	github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 | ||||
| 	github.com/go-ldap/ldap/v3 v3.4.11 | ||||
| 	github.com/go-openapi/runtime v0.28.0 | ||||
| @ -23,13 +23,13 @@ require ( | ||||
| 	github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 | ||||
| 	github.com/pires/go-proxyproto v0.8.1 | ||||
| 	github.com/prometheus/client_golang v1.22.0 | ||||
| 	github.com/redis/go-redis/v9 v9.11.0 | ||||
| 	github.com/redis/go-redis/v9 v9.10.0 | ||||
| 	github.com/sethvargo/go-envconfig v1.3.0 | ||||
| 	github.com/sirupsen/logrus v1.9.3 | ||||
| 	github.com/spf13/cobra v1.9.1 | ||||
| 	github.com/stretchr/testify v1.10.0 | ||||
| 	github.com/wwt/guac v1.3.2 | ||||
| 	goauthentik.io/api/v3 v3.2025063.1 | ||||
| 	goauthentik.io/api/v3 v3.2025062.3 | ||||
| 	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab | ||||
| 	golang.org/x/oauth2 v0.30.0 | ||||
| 	golang.org/x/sync v0.15.0 | ||||
|  | ||||
							
								
								
									
										12
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								go.sum
									
									
									
									
									
								
							| @ -71,8 +71,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m | ||||
| github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= | ||||
| github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= | ||||
| github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= | ||||
| github.com/getsentry/sentry-go v0.34.0 h1:1FCHBVp8TfSc8L10zqSwXUZNiOSF+10qw4czjarTiY4= | ||||
| github.com/getsentry/sentry-go v0.34.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= | ||||
| github.com/getsentry/sentry-go v0.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg= | ||||
| github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= | ||||
| github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= | ||||
| github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= | ||||
| github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= | ||||
| @ -251,8 +251,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ | ||||
| github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= | ||||
| github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= | ||||
| github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= | ||||
| github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= | ||||
| github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= | ||||
| github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= | ||||
| github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= | ||||
| github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= | ||||
| github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= | ||||
| github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= | ||||
| @ -298,8 +298,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y | ||||
| go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= | ||||
| go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | ||||
| go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= | ||||
| goauthentik.io/api/v3 v3.2025063.1 h1:zvKhZTESgMY/SNiLuTs7G0YleBnev1v7+S9Xd6PZ9bc= | ||||
| goauthentik.io/api/v3 v3.2025063.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||
| goauthentik.io/api/v3 v3.2025062.3 h1:syBOKigaHyX/8Rwmh9kOSF+TzsxOzmP5i7rsFwbemzA= | ||||
| goauthentik.io/api/v3 v3.2025062.3/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
| golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||
|  | ||||
| @ -33,4 +33,4 @@ func UserAgent() string { | ||||
| 	return fmt.Sprintf("authentik@%s", FullVersion()) | ||||
| } | ||||
|  | ||||
| const VERSION = "2025.6.3" | ||||
| const VERSION = "2025.6.2" | ||||
|  | ||||
							
								
								
									
										8
									
								
								lifecycle/aws/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								lifecycle/aws/package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -9,7 +9,7 @@ | ||||
|             "version": "0.0.0", | ||||
|             "license": "MIT", | ||||
|             "devDependencies": { | ||||
|                 "aws-cdk": "^2.1019.2", | ||||
|                 "aws-cdk": "^2.1019.1", | ||||
|                 "cross-env": "^7.0.3" | ||||
|             }, | ||||
|             "engines": { | ||||
| @ -17,9 +17,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/aws-cdk": { | ||||
|             "version": "2.1019.2", | ||||
|             "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1019.2.tgz", | ||||
|             "integrity": "sha512-LkWZ3IKBkfCPTCu60t4Wb9JMSkb+0Uzk+HIxZeW5sFohq8bxDGV0OP1hcqEC2+KbVYRn7q+YhMeSJ/FOQcgpiw==", | ||||
|             "version": "2.1019.1", | ||||
|             "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1019.1.tgz", | ||||
|             "integrity": "sha512-G2jxKuTsYTrYZX80CDApCrKcZ+AuFxxd+b0dkb0KEkfUsela7RqrDGLm5wOzSCIc3iH6GocR8JDVZuJ+0nNuKg==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "bin": { | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|         "node": ">=20" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "aws-cdk": "^2.1019.2", | ||||
|         "aws-cdk": "^2.1019.1", | ||||
|         "cross-env": "^7.0.3" | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -26,7 +26,7 @@ Parameters: | ||||
|     Description: authentik Docker image | ||||
|   AuthentikVersion: | ||||
|     Type: String | ||||
|     Default: 2025.6.3 | ||||
|     Default: 2025.6.2 | ||||
|     Description: authentik Docker image tag | ||||
|   AuthentikServerCPU: | ||||
|     Type: Number | ||||
|  | ||||
| @ -10,7 +10,7 @@ from typing import Any | ||||
| from psycopg import Connection, Cursor, connect | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.lib.config import CONFIG, django_db_config | ||||
| from authentik.lib.config import CONFIG | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| ADV_LOCK_UID = 1000 | ||||
| @ -115,13 +115,9 @@ def run_migrations(): | ||||
|         execute_from_command_line(["", "migrate_schemas"]) | ||||
|         if CONFIG.get_bool("tenants.enabled", False): | ||||
|             execute_from_command_line(["", "migrate_schemas", "--schema", "template", "--tenant"]) | ||||
|         # Run django system checks for all databases | ||||
|         check_args = ["", "check"] | ||||
|         for label in django_db_config(CONFIG).keys(): | ||||
|             check_args.append(f"--database={label}") | ||||
|         if not CONFIG.get_bool("debug"): | ||||
|             check_args.append("--deploy") | ||||
|         execute_from_command_line(check_args) | ||||
|         execute_from_command_line( | ||||
|             ["", "check"] + ([] if CONFIG.get_bool("debug") else ["--deploy"]) | ||||
|         ) | ||||
|     finally: | ||||
|         release_lock(curr) | ||||
|         curr.close() | ||||
|  | ||||
| @ -8,7 +8,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-06-25 00:10+0000\n" | ||||
| "POT-Creation-Date: 2025-06-19 00:10+0000\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language-Team: LANGUAGE <LL@li.org>\n" | ||||
| @ -109,6 +109,10 @@ msgstr "" | ||||
| msgid "User does not have access to application." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/core/api/devices.py | ||||
| msgid "Extra description not available" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/core/api/groups.py | ||||
| msgid "Cannot set group as parent of itself." | ||||
| msgstr "" | ||||
|  | ||||
										
											Binary file not shown.
										
									
								
							
										
											Binary file not shown.
										
									
								
							| @ -11,18 +11,18 @@ | ||||
| # Nicola Mersi, 2024 | ||||
| # tmassimi, 2024 | ||||
| # Marc Schmitt, 2024 | ||||
| # albanobattistella <albanobattistella@gmail.com>, 2024 | ||||
| # Matteo Piccina <altermatte@gmail.com>, 2025 | ||||
| # Kowalski Dragon (kowalski7cc) <kowalski.7cc@gmail.com>, 2025 | ||||
| # albanobattistella <albanobattistella@gmail.com>, 2025 | ||||
| #  | ||||
| #, fuzzy | ||||
| msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-06-25 00:10+0000\n" | ||||
| "POT-Creation-Date: 2025-05-28 11:25+0000\n" | ||||
| "PO-Revision-Date: 2022-09-26 16:47+0000\n" | ||||
| "Last-Translator: albanobattistella <albanobattistella@gmail.com>, 2025\n" | ||||
| "Last-Translator: Kowalski Dragon (kowalski7cc) <kowalski.7cc@gmail.com>, 2025\n" | ||||
| "Language-Team: Italian (https://app.transifex.com/authentik/teams/119923/it/)\n" | ||||
| "MIME-Version: 1.0\n" | ||||
| "Content-Type: text/plain; charset=UTF-8\n" | ||||
| @ -116,7 +116,7 @@ msgstr "Certificato Web utilizzato dal server Web authentik Core." | ||||
|  | ||||
| #: authentik/brands/models.py | ||||
| msgid "Certificates used for client authentication." | ||||
| msgstr "Certificati utilizzati per l'autenticazione del client." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/brands/models.py | ||||
| msgid "Brand" | ||||
| @ -130,6 +130,10 @@ msgstr "Brands" | ||||
| msgid "User does not have access to application." | ||||
| msgstr "L'utente non ha accesso all'applicazione." | ||||
|  | ||||
| #: authentik/core/api/devices.py | ||||
| msgid "Extra description not available" | ||||
| msgstr "Descrizione extra non disponibile" | ||||
|  | ||||
| #: authentik/core/api/groups.py | ||||
| msgid "Cannot set group as parent of itself." | ||||
| msgstr "Impossibile impostare il gruppo come padre di se stesso." | ||||
| @ -290,15 +294,15 @@ msgid "" | ||||
| msgstr "" | ||||
| "Collegamento a un utente con indirizzo email identico. Può avere " | ||||
| "implicazioni sulla sicurezza quando una fonte non convalida gli indirizzi " | ||||
| "email." | ||||
| "e-mail." | ||||
|  | ||||
| #: authentik/core/models.py | ||||
| msgid "" | ||||
| "Use the user's email address, but deny enrollment when the email address " | ||||
| "already exists." | ||||
| msgstr "" | ||||
| "Usa l'indirizzo email dell'utente, ma nega l'iscrizione quando l'indirizzo " | ||||
| "email esiste già." | ||||
| "Usa l'indirizzo e-mail dell'utente, ma nega l'iscrizione quando l'indirizzo " | ||||
| "e-mail esiste già." | ||||
|  | ||||
| #: authentik/core/models.py | ||||
| msgid "" | ||||
| @ -678,29 +682,26 @@ msgid "" | ||||
| "option has a higher priority than the `client_certificate` option on " | ||||
| "`Brand`." | ||||
| msgstr "" | ||||
| "Configura le autorità di certificazione per convalidare il certificato. " | ||||
| "Questa opzione ha una priorità maggiore rispetto all'opzione " | ||||
| "`client_certificate` su `Brand`." | ||||
|  | ||||
| #: authentik/enterprise/stages/mtls/models.py | ||||
| msgid "Mutual TLS Stage" | ||||
| msgstr "Fase di TLS reciproca" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/enterprise/stages/mtls/models.py | ||||
| msgid "Mutual TLS Stages" | ||||
| msgstr "Fasi di TLS reciproche" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/enterprise/stages/mtls/models.py | ||||
| msgid "Permissions to pass Certificates for outposts." | ||||
| msgstr " Permessi di trasmissione dei Certificati per gli avamposti." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/enterprise/stages/mtls/stage.py | ||||
| msgid "Certificate required but no certificate was given." | ||||
| msgstr " Il certificato è stato richiesto ma non è stato consegnato." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/enterprise/stages/mtls/stage.py | ||||
| msgid "No user found for certificate." | ||||
| msgstr "Nessun utente trovato per il certificato." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/enterprise/stages/source/models.py | ||||
| msgid "" | ||||
| @ -833,14 +834,6 @@ msgstr "" | ||||
| "Definisci a quale gruppo di utenti deve essere inviata e mostrata questa " | ||||
| "notifica. Se lasciato vuoto, la notifica non verrà inviata." | ||||
|  | ||||
| #: authentik/events/models.py | ||||
| msgid "" | ||||
| "When enabled, notification will be sent to user the user that triggered the " | ||||
| "event.When destination_group is configured, notification is sent to both." | ||||
| msgstr "" | ||||
| "Se abilitata, la notifica verrà inviata all'utente che ha attivato l'evento." | ||||
| " Se destination_group è configurato, la notifica verrà inviata a entrambi." | ||||
|  | ||||
| #: authentik/events/models.py | ||||
| msgid "Notification Rule" | ||||
| msgstr "Regola di notifica" | ||||
| @ -1057,16 +1050,16 @@ msgstr "Avvio della sincronizzazione completa del provider" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| msgid "Syncing users" | ||||
| msgstr "Sincronizzazione degli utenti" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| msgid "Syncing groups" | ||||
| msgstr "Sincronizzazione dei gruppi" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| #, python-brace-format | ||||
| msgid "Syncing page {page} of {object_type}" | ||||
| msgstr "Sincronizzazione della pagina {page} di {object_type}" | ||||
| msgid "Syncing page {page} of groups" | ||||
| msgstr "Sincronizzando pagina {page} dei gruppi" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| msgid "Dropping mutating request due to dry run" | ||||
| @ -2468,10 +2461,6 @@ msgstr "Gruppo di aggiunta DN" | ||||
| msgid "Consider Objects matching this filter to be Users." | ||||
| msgstr "Considerare gli oggetti corrispondenti a questo filtro come Utenti." | ||||
|  | ||||
| #: authentik/sources/ldap/models.py | ||||
| msgid "Attribute which matches the value of `group_membership_field`." | ||||
| msgstr "Attributo che corrisponde al valore di `group_membership_field`." | ||||
|  | ||||
| #: authentik/sources/ldap/models.py | ||||
| msgid "Field which contains members of a group." | ||||
| msgstr "Campo che contiene i membri di un gruppo." | ||||
| @ -2513,8 +2502,6 @@ msgid "" | ||||
| "Delete authentik users and groups which were previously supplied by this " | ||||
| "source, but are now missing from it." | ||||
| msgstr "" | ||||
| "Elimina gli utenti e i gruppi authentik precedentemente forniti da questa " | ||||
| "fonte, ma che ora mancano." | ||||
|  | ||||
| #: authentik/sources/ldap/models.py | ||||
| msgid "LDAP Source" | ||||
| @ -2536,8 +2523,6 @@ msgstr "Mappature delle proprietà della sorgente LDAP" | ||||
| msgid "" | ||||
| "Unique ID used while checking if this object still exists in the directory." | ||||
| msgstr "" | ||||
| "ID univoco utilizzato per verificare se questo oggetto esiste ancora nella " | ||||
| "directory." | ||||
|  | ||||
| #: authentik/sources/ldap/models.py | ||||
| msgid "User LDAP Source Connection" | ||||
| @ -2935,7 +2920,7 @@ msgstr "Connessioni sorgente SAML di gruppo" | ||||
| #: authentik/sources/saml/views.py | ||||
| #, python-brace-format | ||||
| msgid "Continue to {source_name}" | ||||
| msgstr "Continua su {source_name}" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/sources/scim/models.py | ||||
| msgid "SCIM Source" | ||||
| @ -3003,8 +2988,8 @@ msgstr "Fasi di configurazione dell'autenticatore email" | ||||
| #: authentik/stages/email/stage.py | ||||
| msgid "Exception occurred while rendering E-mail template" | ||||
| msgstr "" | ||||
| "Si è verificata un'eccezione durante la visualizzazione del modello di posta" | ||||
| " elettronica" | ||||
| "Eccezione verificatasi durante la visualizzazione del modello di posta " | ||||
| "elettronica" | ||||
|  | ||||
| #: authentik/stages/authenticator_email/models.py | ||||
| msgid "Email Device" | ||||
| @ -3043,7 +3028,7 @@ msgid "" | ||||
| "          " | ||||
| msgstr "" | ||||
| "\n" | ||||
| "          Codice MFA via email.\n" | ||||
| "          Codice MFA via e-mail.\n" | ||||
| "          " | ||||
|  | ||||
| #: authentik/stages/authenticator_email/templates/email/email_otp.html | ||||
| @ -3069,7 +3054,7 @@ msgid "" | ||||
| "Email MFA code\n" | ||||
| msgstr "" | ||||
| "\n" | ||||
| "Codice email MFA\n" | ||||
| "Codice e-mail MFA\n" | ||||
|  | ||||
| #: authentik/stages/authenticator_email/templates/email/email_otp.txt | ||||
| #, python-format | ||||
| @ -3336,7 +3321,7 @@ msgstr "Consensi utente" | ||||
|  | ||||
| #: authentik/stages/consent/stage.py | ||||
| msgid "Invalid consent token, re-showing prompt" | ||||
| msgstr "Token di consenso non valido, viene nuovamente visualizzato il prompt" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/stages/deny/models.py | ||||
| msgid "Deny Stage" | ||||
| @ -3356,11 +3341,11 @@ msgstr "Fasi fittizie" | ||||
|  | ||||
| #: authentik/stages/email/flow.py | ||||
| msgid "Continue to confirm this email address." | ||||
| msgstr "Continua per confermare questo indirizzo email." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/stages/email/flow.py | ||||
| msgid "Link was already used, please request a new link." | ||||
| msgstr "Il collegamento è già stato utilizzato. Richiedine uno nuovo." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/stages/email/models.py | ||||
| msgid "Password Reset" | ||||
| @ -3380,7 +3365,7 @@ msgstr "Fase email" | ||||
|  | ||||
| #: authentik/stages/email/models.py | ||||
| msgid "Email Stages" | ||||
| msgstr "Fasi email" | ||||
| msgstr "Fasi Email" | ||||
|  | ||||
| #: authentik/stages/email/stage.py | ||||
| msgid "Successfully verified Email." | ||||
| @ -3482,7 +3467,7 @@ msgid "" | ||||
| "    " | ||||
| msgstr "" | ||||
| "\n" | ||||
| "   Se non hai richiesto una modifica della password, ignora questa email. Il link sopra è valido per %(expires)s.\n" | ||||
| "   Se non hai richiesto una modifica della password, ignora questa e-mail. Il link sopra è valido per %(expires)s.\n" | ||||
| "    " | ||||
|  | ||||
| #: authentik/stages/email/templates/email/password_reset.txt | ||||
| @ -3500,11 +3485,11 @@ msgid "" | ||||
| "If you did not request a password change, please ignore this email. The link above is valid for %(expires)s.\n" | ||||
| msgstr "" | ||||
| "\n" | ||||
| "Se non hai richiesto una modifica della password, ignora questa email. Il link sopra è valido per %(expires)s.\n" | ||||
| "Se non hai richiesto una modifica della password, ignora questa e-mail. Il link sopra è valido per %(expires)s.\n" | ||||
|  | ||||
| #: authentik/stages/email/templates/email/setup.html | ||||
| msgid "authentik Test-Email" | ||||
| msgstr "email di prova di authentik" | ||||
| msgstr "e-mail di prova di authentik" | ||||
|  | ||||
| #: authentik/stages/email/templates/email/setup.html | ||||
| msgid "" | ||||
| @ -3513,7 +3498,7 @@ msgid "" | ||||
| "                    " | ||||
| msgstr "" | ||||
| "\n" | ||||
| "                    Questa è un'email di prova per informarti che hai configurato correttamente le email di authentik.\n" | ||||
| "                    Questa è un'e-mail di prova per informarti che hai configurato correttamente le e-mail di authentik.\n" | ||||
| "                    " | ||||
|  | ||||
| #: authentik/stages/email/templates/email/setup.txt | ||||
| @ -3522,7 +3507,7 @@ msgid "" | ||||
| "This is a test email to inform you, that you've successfully configured authentik emails.\n" | ||||
| msgstr "" | ||||
| "\n" | ||||
| "Questa è un'email di prova per informarti che hai configurato correttamente le email di authentik.\n" | ||||
| "Questa è un'e-mail di prova per informarti che hai configurato correttamente le e-mail di authentik.\n" | ||||
|  | ||||
| #: authentik/stages/identification/api.py | ||||
| msgid "When no user fields are selected, at least one source must be selected" | ||||
| @ -3725,7 +3710,7 @@ msgstr "" | ||||
|  | ||||
| #: authentik/stages/prompt/models.py | ||||
| msgid "Email: Text field with Email type." | ||||
| msgstr "Email: Campo di testo con il tipo di email." | ||||
| msgstr "E-mail: Campo di testo con il tipo di e-mail." | ||||
|  | ||||
| #: authentik/stages/prompt/models.py | ||||
| msgid "" | ||||
| @ -3880,6 +3865,10 @@ msgstr "Fasi di accesso utente" | ||||
| msgid "No Pending user to login." | ||||
| msgstr "Nessun utente in attesa di accesso." | ||||
|  | ||||
| #: authentik/stages/user_login/stage.py | ||||
| msgid "Successfully logged in!" | ||||
| msgstr "Accesso effettuato!" | ||||
|  | ||||
| #: authentik/stages/user_logout/models.py | ||||
| msgid "User Logout Stage" | ||||
| msgstr "Fase di disconnessione dell'utente" | ||||
|  | ||||
										
											Binary file not shown.
										
									
								
							| @ -15,7 +15,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-06-25 00:10+0000\n" | ||||
| "POT-Creation-Date: 2025-06-04 00:12+0000\n" | ||||
| "PO-Revision-Date: 2022-09-26 16:47+0000\n" | ||||
| "Last-Translator: deluxghost, 2025\n" | ||||
| "Language-Team: Chinese Simplified (https://app.transifex.com/authentik/teams/119923/zh-Hans/)\n" | ||||
| @ -118,6 +118,10 @@ msgstr "品牌" | ||||
| msgid "User does not have access to application." | ||||
| msgstr "用户没有访问此应用程序的权限。" | ||||
|  | ||||
| #: authentik/core/api/devices.py | ||||
| msgid "Extra description not available" | ||||
| msgstr "额外描述不可用" | ||||
|  | ||||
| #: authentik/core/api/groups.py | ||||
| msgid "Cannot set group as parent of itself." | ||||
| msgstr "无法设置组自身为父级。" | ||||
| @ -771,12 +775,6 @@ msgid "" | ||||
| "If left empty, Notification won't ben sent." | ||||
| msgstr "定义此通知应该发送到哪些用户组。如果留空,则不会发送通知。" | ||||
|  | ||||
| #: authentik/events/models.py | ||||
| msgid "" | ||||
| "When enabled, notification will be sent to user the user that triggered the " | ||||
| "event.When destination_group is configured, notification is sent to both." | ||||
| msgstr "启用时,通知会被发送到触发事件的用户。当配置了 destination_group 时,通知也会同时发送到对应组。" | ||||
|  | ||||
| #: authentik/events/models.py | ||||
| msgid "Notification Rule" | ||||
| msgstr "通知规则" | ||||
|  | ||||
										
											Binary file not shown.
										
									
								
							Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	