Compare commits
	
		
			115 Commits
		
	
	
		
			website/in
			...
			api/nested
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8128d8dab5 | |||
| f4a68c7878 | |||
| 7ab17822e3 | |||
| 76da77f26e | |||
| eab6e288d7 | |||
| 91c2863358 | |||
| 1638e95bc7 | |||
| 8f75131541 | |||
| c85471575a | |||
| 5d00dc7e9e | |||
| 6982e7d1c9 | |||
| c7fe987c5a | |||
| e48739c8a0 | |||
| b2ee585c43 | |||
| 97e8ea8e76 | |||
| 1f1e0c9db1 | |||
| ca47a803fe | |||
| c606eb53b0 | |||
| 62357133b0 | |||
| 99d2d91257 | |||
| 69d9363fce | |||
| cfc7f6b993 | |||
| bebbbe9b90 | |||
| 188d3c69c1 | |||
| 877f312145 | |||
| f471a98bc7 | |||
| e874cfc21d | |||
| ec7bdf74aa | |||
| e87bc94b95 | |||
| a3865abaa9 | |||
| 7100d3c674 | |||
| c0c2d2ad3c | |||
| dc287989db | |||
| 03204f6943 | |||
| fcd369e466 | |||
| cb79407bc1 | |||
| 04a88daf34 | |||
| c6a49da5c3 | |||
| bfeeecf3fa | |||
| d86b5e7c8a | |||
| a95776891e | |||
| 031158fdba | |||
| b2fbb92498 | |||
| b1b6bf1a19 | |||
| 179d9d0721 | |||
| 8e94d58851 | |||
| 026669cfce | |||
| c83cea6963 | |||
| 8e01cc2df8 | |||
| 279cec203d | |||
| 41c5030c1e | |||
| 3206fdb7ef | |||
| d7c0868eef | |||
| 7d96a89697 | |||
| dfb0007777 | |||
| 816d9668eb | |||
| 371d35ec06 | |||
| 664d3593ca | |||
| 7acd27eea8 | |||
| 83550dc50d | |||
| c272dd70fd | |||
| ae1d82dc69 | |||
| dd42eeab62 | |||
| 680db9bae6 | |||
| 31b72751bc | |||
| 8210067479 | |||
| 423911d974 | |||
| d4ca070d76 | |||
| db1e8b291f | |||
| 44ff6fce23 | |||
| 085c22a41a | |||
| fb2887fa4b | |||
| ed41eb66de | |||
| ee8122baa7 | |||
| f0d70eef6f | |||
| ff966d763b | |||
| e00b68cafe | |||
| bf4e8dbedc | |||
| d09b7757b6 | |||
| ca2f0439f6 | |||
| 27b7b0b0e7 | |||
| 88073305eb | |||
| 37657e47a3 | |||
| 0d649a70c9 | |||
| 7ec3055018 | |||
| 50ffce87c4 | |||
| a4393ac9f0 | |||
| e235c854a5 | |||
| 910b69f89d | |||
| f89cc98014 | |||
| 91a675a5a1 | |||
| 71be3acd1a | |||
| 0b6ab171ce | |||
| 0c73572b0c | |||
| 03d0899a76 | |||
| 91f79c97d8 | |||
| 19324c61a3 | |||
| d297733614 | |||
| f201f41a1b | |||
| f58f679171 | |||
| 1bea5e38a1 | |||
| 4d1c63e7fa | |||
| e341032bf9 | |||
| e3ff242956 | |||
| c6756bf809 | |||
| cf9b7eaa64 | |||
| 53d8f9bd8c | |||
| f76becfd86 | |||
| 080e2311fe | |||
| eacc0eb546 | |||
| c77a54dc2a | |||
| 84781df51b | |||
| a640866534 | |||
| e070241407 | |||
| 85985c3673 | 
| @ -1,5 +1,5 @@ | ||||
| [bumpversion] | ||||
| current_version = 2025.6.2 | ||||
| current_version = 2025.6.3 | ||||
| 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*))? | ||||
| @ -21,6 +21,8 @@ optional_value = final | ||||
|  | ||||
| [bumpversion:file:package.json] | ||||
|  | ||||
| [bumpversion:file:package-lock.json] | ||||
|  | ||||
| [bumpversion:file:docker-compose.yml] | ||||
|  | ||||
| [bumpversion:file:schema.yml] | ||||
| @ -31,6 +33,4 @@ optional_value = final | ||||
|  | ||||
| [bumpversion:file:internal/constants/constants.go] | ||||
|  | ||||
| [bumpversion:file:web/src/common/constants.ts] | ||||
|  | ||||
| [bumpversion:file:lifecycle/aws/template.yaml] | ||||
|  | ||||
| @ -38,6 +38,8 @@ 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,14 +9,15 @@ 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,11 +247,13 @@ 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: ghcr.io/goauthentik/dev-server | ||||
|       image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || '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,6 +59,7 @@ 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,6 +63,7 @@ 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 | ||||
| @ -122,3 +123,4 @@ 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
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.github/workflows/repo-mirror-cleanup.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,21 @@ | ||||
| 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,11 +11,10 @@ jobs: | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|       - if: ${{ env.MIRROR_KEY != '' }} | ||||
|         uses: pixta-dev/repository-mirroring-action@v1 | ||||
|         uses: BeryJu/repository-mirroring-action@5cf300935bc2e068f73ea69bcc411a8a997208eb | ||||
|         with: | ||||
|           target_repo_url: | ||||
|             git@github.com:goauthentik/authentik-internal.git | ||||
|           ssh_private_key: | ||||
|             ${{ secrets.GH_MIRROR_KEY }} | ||||
|           target_repo_url: git@github.com:goauthentik/authentik-internal.git | ||||
|           ssh_private_key: ${{ secrets.GH_MIRROR_KEY }} | ||||
|           args: --tags --force | ||||
|         env: | ||||
|           MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }} | ||||
|  | ||||
| @ -16,6 +16,7 @@ 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,13 +6,15 @@ | ||||
|         "!Context scalar", | ||||
|         "!Enumerate sequence", | ||||
|         "!Env scalar", | ||||
|         "!Env sequence", | ||||
|         "!Find sequence", | ||||
|         "!Format sequence", | ||||
|         "!If sequence", | ||||
|         "!Index scalar", | ||||
|         "!KeyOf scalar", | ||||
|         "!Value scalar", | ||||
|         "!AtIndex scalar" | ||||
|         "!AtIndex scalar", | ||||
|         "!ParseJSON 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.13 AS uv | ||||
| FROM ghcr.io/astral-sh/uv:0.7.17 AS uv | ||||
| # Stage 5: Base python image | ||||
| FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base | ||||
|  | ||||
|  | ||||
							
								
								
									
										10
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								Makefile
									
									
									
									
									
								
							| @ -86,6 +86,10 @@ dev-create-db: | ||||
|  | ||||
| dev-reset: dev-drop-db dev-create-db migrate  ## Drop and restore the Authentik PostgreSQL instance to a "fresh install" state. | ||||
|  | ||||
| update-test-mmdb:  ## Update test GeoIP and ASN Databases | ||||
| 	curl -L https://raw.githubusercontent.com/maxmind/MaxMind-DB/refs/heads/main/test-data/GeoLite2-ASN-Test.mmdb -o ${PWD}/tests/GeoLite2-ASN-Test.mmdb | ||||
| 	curl -L https://raw.githubusercontent.com/maxmind/MaxMind-DB/refs/heads/main/test-data/GeoLite2-City-Test.mmdb -o ${PWD}/tests/GeoLite2-City-Test.mmdb | ||||
|  | ||||
| ######################### | ||||
| ## API Schema | ||||
| ######################### | ||||
| @ -146,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 | ||||
| 	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 | ||||
|  | ||||
| 	cd ${PWD}/${GEN_API_TS} && npm link | ||||
| 	cd ${PWD}/web && npm link @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.2" | ||||
| __version__ = "2025.6.3" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
							
								
								
									
										67
									
								
								authentik/api/v3/routers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								authentik/api/v3/routers.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,67 @@ | ||||
| from rest_framework.routers import DefaultRouter as UpstreamDefaultRouter | ||||
| from rest_framework.viewsets import ViewSet | ||||
| from rest_framework_nested.routers import NestedMixin | ||||
|  | ||||
|  | ||||
| class DefaultRouter(UpstreamDefaultRouter): | ||||
|     include_format_suffixes = False | ||||
|  | ||||
|  | ||||
| class NestedRouter(DefaultRouter): | ||||
|  | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|         self.nested_routers = [] | ||||
|  | ||||
|     class nested: | ||||
|         def __init__(self, parent: "NestedRouter", prefix: str): | ||||
|             self.parent = parent | ||||
|             self.prefix = prefix | ||||
|             self.inner = None | ||||
|  | ||||
|         def nested(self, lookup: str, prefix: str, viewset: type[ViewSet]): | ||||
|             if not self.inner: | ||||
|                 self.inner = NestedDefaultRouter(self.parent, self.prefix, lookup=lookup) | ||||
|             self.inner.register(prefix, viewset) | ||||
|             return self | ||||
|  | ||||
|         @property | ||||
|         def urls(self): | ||||
|             return self.parent.urls | ||||
|  | ||||
|     def register(self, prefix, viewset, basename=None): | ||||
|         super().register(prefix, viewset, basename) | ||||
|         nested_router = self.nested(self, prefix) | ||||
|         self.nested_routers.append(nested_router) | ||||
|         return nested_router | ||||
|  | ||||
|     def get_urls(self): | ||||
|         urls = super().get_urls() | ||||
|         for nested in self.nested_routers: | ||||
|             if not nested.inner: | ||||
|                 continue | ||||
|             urls.extend(nested.inner.urls) | ||||
|         return urls | ||||
|  | ||||
|  | ||||
| class NestedDefaultRouter(NestedMixin, DefaultRouter): | ||||
|     ... | ||||
|     # def __init__(self, *args, **kwargs): | ||||
|     #     self.args = args | ||||
|     #     self.kwargs = kwargs | ||||
|     #     self.routes = [] | ||||
|  | ||||
|     # def register(self, *args, **kwargs): | ||||
|     #     self.routes.append((args, kwargs)) | ||||
|  | ||||
|     # @property | ||||
|     # def urls(self): | ||||
|     #     class r(NestedMixin, DefaultRouter): | ||||
|     #         ... | ||||
|     #     router = r(*self.args, **self.kwargs) | ||||
|     #     for route_args, route_kwrags in self.routes: | ||||
|     #         router.register(*route_args, **route_kwrags) | ||||
|     #     return router | ||||
|  | ||||
|  | ||||
| root_router = DefaultRouter() | ||||
| @ -6,18 +6,15 @@ from django.urls import path | ||||
| from django.urls.resolvers import URLPattern | ||||
| from django.views.decorators.cache import cache_page | ||||
| from drf_spectacular.views import SpectacularAPIView | ||||
| from rest_framework import routers | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.api.v3.config import ConfigView | ||||
| from authentik.api.v3.routers import root_router | ||||
| from authentik.api.views import APIBrowserView | ||||
| from authentik.lib.utils.reflection import get_apps | ||||
|  | ||||
| LOGGER = get_logger() | ||||
|  | ||||
| router = routers.DefaultRouter() | ||||
| router.include_format_suffixes = False | ||||
|  | ||||
| _other_urls = [] | ||||
| for _authentik_app in get_apps(): | ||||
|     try: | ||||
| @ -38,7 +35,7 @@ for _authentik_app in get_apps(): | ||||
|         if isinstance(url, URLPattern): | ||||
|             _other_urls.append(url) | ||||
|         else: | ||||
|             router.register(*url) | ||||
|             root_router.register(*url) | ||||
|     LOGGER.debug( | ||||
|         "Mounted API URLs", | ||||
|         app_name=_authentik_app.name, | ||||
| @ -49,7 +46,7 @@ urlpatterns = ( | ||||
|     [ | ||||
|         path("", APIBrowserView.as_view(), name="schema-browser"), | ||||
|     ] | ||||
|     + router.urls | ||||
|     + root_router.urls | ||||
|     + _other_urls | ||||
|     + [ | ||||
|         path("root/config/", ConfigView.as_view(), name="config"), | ||||
|  | ||||
| @ -37,6 +37,7 @@ 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): | ||||
|     if "local" in str(blueprint_file) or "testing" in str(blueprint_file): | ||||
|         continue | ||||
|     setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file)) | ||||
|  | ||||
| @ -5,7 +5,6 @@ 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 | ||||
|  | ||||
| @ -22,10 +21,13 @@ 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.assertEqual(model_class.serializer.Meta().model, test_model) | ||||
|         self.assertTrue(issubclass(test_model, model_class.serializer.Meta().model)) | ||||
|  | ||||
|     return tester | ||||
|  | ||||
| @ -34,6 +36,6 @@ for app in apps.get_app_configs(): | ||||
|     if not app.label.startswith("authentik"): | ||||
|         continue | ||||
|     for model in app.get_models(): | ||||
|         if not is_model_allowed(model): | ||||
|         if not issubclass(model, SerializerModel): | ||||
|             continue | ||||
|         setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model)) | ||||
|  | ||||
| @ -215,6 +215,7 @@ 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,6 +6,7 @@ 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 | ||||
| @ -291,6 +292,22 @@ 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""" | ||||
|  | ||||
| @ -666,6 +683,7 @@ 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,8 +1,6 @@ | ||||
| """Authenticator Devices API Views""" | ||||
|  | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import OpenApiParameter, extend_schema | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from guardian.shortcuts import get_objects_for_user | ||||
| from rest_framework.fields import ( | ||||
|     BooleanField, | ||||
| @ -15,6 +13,7 @@ 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 | ||||
| @ -23,7 +22,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice | ||||
|  | ||||
|  | ||||
| class DeviceSerializer(MetaNameSerializer): | ||||
|     """Serializer for Duo authenticator devices""" | ||||
|     """Serializer for authenticator devices""" | ||||
|  | ||||
|     pk = CharField() | ||||
|     name = CharField() | ||||
| @ -33,22 +32,27 @@ 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: | ||||
|     def get_extra_description(self, instance: Device) -> str | None: | ||||
|         """Get extra description""" | ||||
|         if isinstance(instance, WebAuthnDevice): | ||||
|             return ( | ||||
|                 instance.device_type.description | ||||
|                 if instance.device_type | ||||
|                 else _("Extra description not available") | ||||
|             ) | ||||
|             return instance.device_type.description if instance.device_type else None | ||||
|         if isinstance(instance, EndpointDevice): | ||||
|             return instance.data.get("deviceSignals", {}).get("deviceModel") | ||||
|         return "" | ||||
|         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 | ||||
|  | ||||
|  | ||||
| class DeviceViewSet(ViewSet): | ||||
| @ -57,7 +61,6 @@ 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) | ||||
| @ -79,18 +82,11 @@ class AdminDeviceViewSet(ViewSet): | ||||
|             yield from device_set | ||||
|  | ||||
|     @extend_schema( | ||||
|         parameters=[ | ||||
|             OpenApiParameter( | ||||
|                 name="user", | ||||
|                 location=OpenApiParameter.QUERY, | ||||
|                 type=OpenApiTypes.INT, | ||||
|             ) | ||||
|         ], | ||||
|         parameters=[ParamUserSerializer], | ||||
|         responses={200: DeviceSerializer(many=True)}, | ||||
|     ) | ||||
|     def list(self, request: Request) -> Response: | ||||
|         """Get all devices for current user""" | ||||
|         kwargs = {} | ||||
|         if "user" in request.query_params: | ||||
|             kwargs = {"user": request.query_params["user"]} | ||||
|         return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data) | ||||
|         args = ParamUserSerializer(data=request.query_params) | ||||
|         args.is_valid(raise_exception=True) | ||||
|         return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data) | ||||
|  | ||||
| @ -90,6 +90,12 @@ 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""" | ||||
|  | ||||
| @ -401,7 +407,7 @@ class UserViewSet(UsedByMixin, ModelViewSet): | ||||
|             StrField(User, "path"), | ||||
|             BoolField(User, "is_active", nullable=True), | ||||
|             ChoiceSearchField(User, "type"), | ||||
|             JSONSearchField(User, "attributes"), | ||||
|             JSONSearchField(User, "attributes", suggest_nested=False), | ||||
|         ] | ||||
|  | ||||
|     def get_queryset(self): | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|  | ||||
| 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 | ||||
| @ -30,7 +31,27 @@ 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) | ||||
|  | ||||
| @ -71,21 +92,6 @@ 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""" | ||||
|  | ||||
|  | ||||
| @ -13,7 +13,6 @@ 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,6 +1082,12 @@ 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,10 +1,8 @@ | ||||
| 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 ( | ||||
| @ -62,31 +60,6 @@ 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) | ||||
|  | ||||
| @ -39,19 +39,22 @@ class BaseSchema(DjangoQLSchema): | ||||
|         return super().resolve_name(name) | ||||
|  | ||||
|  | ||||
| # Inherits from SearchFilter to keep the schema correctly | ||||
| 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) -> 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, "") | ||||
|     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", "") | ||||
|         params = params.replace("\x00", "")  # strip null characters | ||||
|         return params | ||||
|  | ||||
| @ -70,9 +73,9 @@ class QLSearch(SearchFilter): | ||||
|         search_query = self.get_search_terms(request) | ||||
|         schema = self.get_schema(request, view) | ||||
|         if len(search_query) == 0 or not self.enabled: | ||||
|             return super().filter_queryset(request, queryset, view) | ||||
|             return self._fallback.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 super().filter_queryset(request, queryset, view) | ||||
|             return self._fallback.filter_queryset(request, queryset, view) | ||||
|  | ||||
| @ -57,7 +57,7 @@ class QLTest(APITestCase): | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 200) | ||||
|         content = loads(res.content) | ||||
|         self.assertGreaterEqual(content["pagination"]["count"], 1) | ||||
|         self.assertEqual(content["pagination"]["count"], 1) | ||||
|         self.assertEqual(content["results"][0]["username"], self.user.username) | ||||
|  | ||||
|     def test_search_json(self): | ||||
|  | ||||
| @ -97,6 +97,7 @@ 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,14 +90,17 @@ 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 | ||||
|         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}) | ||||
|         ) | ||||
|         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") | ||||
|  | ||||
| @ -15,13 +15,13 @@ class MMDBContextProcessor(EventContextProcessor): | ||||
|         self.reader: Reader | None = None | ||||
|         self._last_mtime: float = 0.0 | ||||
|         self.logger = get_logger() | ||||
|         self.open() | ||||
|         self.load() | ||||
|  | ||||
|     def path(self) -> str | None: | ||||
|         """Get the path to the MMDB file to load""" | ||||
|         raise NotImplementedError | ||||
|  | ||||
|     def open(self): | ||||
|     def load(self): | ||||
|         """Get GeoIP Reader, if configured, otherwise none""" | ||||
|         path = self.path() | ||||
|         if path == "" or not path: | ||||
| @ -44,7 +44,7 @@ class MMDBContextProcessor(EventContextProcessor): | ||||
|             diff = self._last_mtime < mtime | ||||
|             if diff > 0: | ||||
|                 self.logger.info("Found new MMDB Database, reopening", diff=diff, path=path) | ||||
|                 self.open() | ||||
|                 self.load() | ||||
|         except OSError as exc: | ||||
|             self.logger.warning("Failed to check MMDB age", exc=exc) | ||||
|  | ||||
|  | ||||
| @ -19,7 +19,7 @@ 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 before_send | ||||
| from authentik.lib.sentry import should_ignore_exception | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.stages.authenticator_static.models import StaticToken | ||||
|  | ||||
| @ -173,7 +173,7 @@ class AuditMiddleware: | ||||
|                 message=exception_to_string(exception), | ||||
|             ) | ||||
|             thread.run() | ||||
|         elif before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||
|         elif not should_ignore_exception(exception): | ||||
|             thread = EventNewThread( | ||||
|                 EventAction.SYSTEM_EXCEPTION, | ||||
|                 request, | ||||
|  | ||||
| @ -193,17 +193,32 @@ class Event(SerializerModel, ExpiringModel): | ||||
|             brand: Brand = request.brand | ||||
|             self.brand = sanitize_dict(model_to_dict(brand)) | ||||
|         if hasattr(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) | ||||
|             self.user = get_user(request.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 | ||||
|  | ||||
| @ -2,7 +2,9 @@ | ||||
|  | ||||
| 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): | ||||
| @ -13,8 +15,7 @@ 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"), | ||||
|             { | ||||
| @ -25,3 +26,12 @@ 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,9 +8,11 @@ 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 | ||||
| from authentik.core.models import Group, User | ||||
| from authentik.core.tests.utils import create_test_user | ||||
| from authentik.events.models import Event | ||||
| from authentik.flows.views.executor import QS_QUERY | ||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan | ||||
| from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.policies.dummy.models import DummyPolicy | ||||
|  | ||||
| @ -116,3 +118,92 @@ 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, original_user: User | None = None) -> dict[str, Any]: | ||||
|     """Convert user object to dictionary, optionally including the original user""" | ||||
| def get_user(user: User | AnonymousUser) -> dict[str, Any]: | ||||
|     """Convert user object to dictionary""" | ||||
|     if isinstance(user, AnonymousUser): | ||||
|         try: | ||||
|             user = get_anonymous_user() | ||||
| @ -88,10 +88,6 @@ def get_user(user: User | AnonymousUser, original_user: User | None = None) -> d | ||||
|     } | ||||
|     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,8 +4,10 @@ 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 | ||||
| @ -648,3 +650,25 @@ 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,7 +55,7 @@ from authentik.flows.planner import ( | ||||
|     FlowPlanner, | ||||
| ) | ||||
| from authentik.flows.stage import AccessDeniedStage, StageView | ||||
| from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
| from authentik.lib.utils.reflection import all_subclasses, class_to_path | ||||
| from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs | ||||
| @ -234,12 +234,13 @@ class FlowExecutorView(APIView): | ||||
|         """Handle exception in stage execution""" | ||||
|         if settings.DEBUG or settings.TEST: | ||||
|             raise exc | ||||
|         capture_exception(exc) | ||||
|         self._logger.warning(exc) | ||||
|         Event.new( | ||||
|             action=EventAction.SYSTEM_EXCEPTION, | ||||
|             message=exception_to_string(exc), | ||||
|         ).from_http(self.request) | ||||
|         if not should_ignore_exception(exc): | ||||
|             capture_exception(exc) | ||||
|             Event.new( | ||||
|                 action=EventAction.SYSTEM_EXCEPTION, | ||||
|                 message=exception_to_string(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,6 +14,7 @@ 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 | ||||
| @ -44,6 +45,49 @@ 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""" | ||||
|  | ||||
| @ -101,56 +145,17 @@ 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 isinstance(exc_value, ignored_classes): | ||||
|         if should_ignore_exception(exc_value): | ||||
|             LOGGER.debug("dropping exception", exc=exc_value) | ||||
|             return None | ||||
|     if "logger" in event: | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|  | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.lib.sentry import SentryIgnoredException, before_send | ||||
| from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception | ||||
|  | ||||
|  | ||||
| class TestSentry(TestCase): | ||||
| @ -10,8 +10,8 @@ class TestSentry(TestCase): | ||||
|  | ||||
|     def test_error_not_sent(self): | ||||
|         """Test SentryIgnoredError not sent""" | ||||
|         self.assertIsNone(before_send({}, {"exc_info": (0, SentryIgnoredException(), 0)})) | ||||
|         self.assertTrue(should_ignore_exception(SentryIgnoredException())) | ||||
|  | ||||
|     def test_error_sent(self): | ||||
|         """Test error sent""" | ||||
|         self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)})) | ||||
|         self.assertFalse(should_ignore_exception(ValueError())) | ||||
|  | ||||
| @ -1,15 +1,13 @@ | ||||
| """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, User | ||||
| from authentik.core.models import AuthenticatedSession, Provider | ||||
| from authentik.crypto.models import CertificateKeyPair | ||||
| from authentik.lib.utils.reflection import class_to_path | ||||
| from authentik.outposts.models import Outpost, OutpostServiceConnection | ||||
| @ -82,14 +80,6 @@ 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""" | ||||
|  | ||||
| @ -1,23 +1,10 @@ | ||||
| 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""" | ||||
|  | ||||
| @ -40,9 +40,16 @@ class ConnectionTokenViewSet( | ||||
| ): | ||||
|     """ConnectionToken Viewset""" | ||||
|  | ||||
|     queryset = ConnectionToken.objects.all().select_related("session", "endpoint") | ||||
|     queryset = ConnectionToken.objects.none() | ||||
|     serializer_class = ConnectionTokenSerializer | ||||
|     filterset_fields = ["endpoint", "session__user", "provider"] | ||||
|     search_fields = ["endpoint__name", "provider__name"] | ||||
|     ordering = ["endpoint__name", "provider__name"] | ||||
|     filterset_fields = ["endpoint", "session__user"] | ||||
|     search_fields = ["endpoint__name", "session__user__username"] | ||||
|     ordering = ["endpoint__name", "session__user__username"] | ||||
|     owner_field = "session__user" | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         return ( | ||||
|             ConnectionToken.objects.all() | ||||
|             .select_related("session", "endpoint") | ||||
|             .filter(provider=self.kwargs["provider_pk"]) | ||||
|         ) | ||||
|  | ||||
| @ -22,9 +22,9 @@ from authentik.rbac.filters import ObjectFilter | ||||
| LOGGER = get_logger() | ||||
|  | ||||
|  | ||||
| def user_endpoint_cache_key(user_pk: str) -> str: | ||||
| def user_endpoint_cache_key(user_pk: str, provider_pk: str) -> str: | ||||
|     """Cache key where endpoint list for user is saved""" | ||||
|     return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}" | ||||
|     return f"goauthentik.io/providers/rac/endpoint_access/{user_pk}/{provider_pk}" | ||||
|  | ||||
|  | ||||
| class EndpointSerializer(ModelSerializer): | ||||
| @ -65,12 +65,15 @@ class EndpointSerializer(ModelSerializer): | ||||
| class EndpointViewSet(UsedByMixin, ModelViewSet): | ||||
|     """Endpoint Viewset""" | ||||
|  | ||||
|     queryset = Endpoint.objects.all() | ||||
|     queryset = Endpoint.objects.none() | ||||
|     serializer_class = EndpointSerializer | ||||
|     filterset_fields = ["name", "provider"] | ||||
|     filterset_fields = ["name"] | ||||
|     search_fields = ["name", "protocol"] | ||||
|     ordering = ["name", "protocol"] | ||||
|  | ||||
|     def get_queryset(self): | ||||
|         return Endpoint.objects.filter(provider=self.kwargs["provider_pk"]) | ||||
|  | ||||
|     def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: | ||||
|         """Custom filter_queryset method which ignores guardian, but still supports sorting""" | ||||
|         for backend in list(self.filter_backends): | ||||
| @ -120,14 +123,11 @@ class EndpointViewSet(UsedByMixin, ModelViewSet): | ||||
|         if not should_cache: | ||||
|             allowed_endpoints = self._get_allowed_endpoints(queryset) | ||||
|         if should_cache: | ||||
|             allowed_endpoints = cache.get(user_endpoint_cache_key(self.request.user.pk)) | ||||
|             key = user_endpoint_cache_key(self.request.user.pk, self.kwargs["provider_pk"]) | ||||
|             allowed_endpoints = cache.get(key) | ||||
|             if not allowed_endpoints: | ||||
|                 LOGGER.debug("Caching allowed endpoint list") | ||||
|                 allowed_endpoints = self._get_allowed_endpoints(queryset) | ||||
|                 cache.set( | ||||
|                     user_endpoint_cache_key(self.request.user.pk), | ||||
|                     allowed_endpoints, | ||||
|                     timeout=86400, | ||||
|                 ) | ||||
|                 cache.set(key, allowed_endpoints, timeout=86400) | ||||
|         serializer = self.get_serializer(allowed_endpoints, many=True) | ||||
|         return self.get_paginated_response(serializer.data) | ||||
|  | ||||
| @ -66,7 +66,10 @@ 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"]) | ||||
|             ConnectionToken.filter_not_expired( | ||||
|                 token=self.scope["url_route"]["kwargs"]["token"], | ||||
|                 session__session__session_key=self.scope["session"].session_key, | ||||
|             ) | ||||
|             .select_related("endpoint", "provider", "session", "session__user") | ||||
|             .first() | ||||
|         ) | ||||
|  | ||||
| @ -2,13 +2,11 @@ | ||||
|  | ||||
| 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, User | ||||
| from authentik.core.models import AuthenticatedSession | ||||
| from authentik.providers.rac.api.endpoints import user_endpoint_cache_key | ||||
| from authentik.providers.rac.consumer_client import ( | ||||
|     RAC_CLIENT_GROUP_SESSION, | ||||
| @ -17,21 +15,6 @@ 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() | ||||
| @ -60,5 +43,5 @@ def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, ** | ||||
| @receiver([post_save, post_delete], sender=Endpoint) | ||||
| def post_save_post_delete_endpoint(**_): | ||||
|     """Clear user's endpoint cache upon endpoint creation or deletion""" | ||||
|     keys = cache.keys(user_endpoint_cache_key("*")) | ||||
|     keys = cache.keys(user_endpoint_cache_key("*", "*")) | ||||
|     cache.delete_many(keys) | ||||
|  | ||||
| @ -87,3 +87,22 @@ 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")) | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|  | ||||
| from django.urls import path | ||||
|  | ||||
| from authentik.api.v3.routers import NestedRouter | ||||
| from authentik.outposts.channels import TokenOutpostMiddleware | ||||
| from authentik.providers.rac.api.connection_tokens import ConnectionTokenViewSet | ||||
| from authentik.providers.rac.api.endpoints import EndpointViewSet | ||||
| @ -38,8 +39,10 @@ websocket_urlpatterns = [ | ||||
| ] | ||||
|  | ||||
| api_urlpatterns = [ | ||||
|     ("providers/rac", RACProviderViewSet), | ||||
|     *NestedRouter() | ||||
|     .register("providers/rac", RACProviderViewSet) | ||||
|     .nested("provider", "endpoints", EndpointViewSet) | ||||
|     .nested("provider", "connection_tokens", ConnectionTokenViewSet) | ||||
|     .urls, | ||||
|     ("propertymappings/provider/rac", RACPropertyMappingViewSet), | ||||
|     ("rac/endpoints", EndpointViewSet), | ||||
|     ("rac/connection_tokens", ConnectionTokenViewSet), | ||||
| ] | ||||
|  | ||||
| @ -68,7 +68,10 @@ 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"]).first() | ||||
|         token = ConnectionToken.filter_not_expired( | ||||
|             token=self.kwargs["token"], | ||||
|             session__session__session_key=request.session.session_key, | ||||
|         ).first() | ||||
|         if not token: | ||||
|             return redirect("authentik_core:if-user") | ||||
|         self.token = token | ||||
|  | ||||
| @ -5,7 +5,6 @@ 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 | ||||
| @ -20,7 +19,12 @@ 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, PatchOperation, PatchRequest | ||||
| from authentik.providers.scim.clients.schema import ( | ||||
|     SCIM_GROUP_SCHEMA, | ||||
|     PatchOp, | ||||
|     PatchOperation, | ||||
|     PatchRequest, | ||||
| ) | ||||
| from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema | ||||
| from authentik.providers.scim.models import ( | ||||
|     SCIMMapping, | ||||
|  | ||||
| @ -1,5 +1,7 @@ | ||||
| """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 | ||||
| @ -65,6 +67,21 @@ 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""" | ||||
|  | ||||
| @ -74,6 +91,7 @@ class PatchRequest(BasePatchRequest): | ||||
| class PatchOperation(BasePatchOperation): | ||||
|     """PatchOperation with optional path""" | ||||
|  | ||||
|     op: PatchOp | ||||
|     path: str | None | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -27,7 +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 before_send | ||||
| from authentik.lib.sentry import should_ignore_exception | ||||
| from authentik.lib.utils.errors import exception_to_string | ||||
|  | ||||
| # set the default Django settings module for the 'celery' program. | ||||
| @ -81,7 +81,7 @@ 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 before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||
|     if not should_ignore_exception(exception): | ||||
|         Event.new( | ||||
|             EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception), task_id=task_id | ||||
|         ).save() | ||||
|  | ||||
| @ -1,13 +1,49 @@ | ||||
| """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() | ||||
|  | ||||
| @ -11,6 +11,8 @@ from django.contrib.contenttypes.models import ContentType | ||||
| from django.test.runner import DiscoverRunner | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.events.context_processors.asn import ASN_CONTEXT_PROCESSOR | ||||
| from authentik.events.context_processors.geoip import GEOIP_CONTEXT_PROCESSOR | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.sentry import sentry_init | ||||
| from authentik.root.signals import post_startup, pre_startup, startup | ||||
| @ -76,6 +78,9 @@ class PytestTestRunner(DiscoverRunner):  # pragma: no cover | ||||
|         for key, value in test_config.items(): | ||||
|             CONFIG.set(key, value) | ||||
|  | ||||
|         ASN_CONTEXT_PROCESSOR.load() | ||||
|         GEOIP_CONTEXT_PROCESSOR.load() | ||||
|  | ||||
|         sentry_init() | ||||
|         self.logger.debug("Test environment configured") | ||||
|  | ||||
|  | ||||
| @ -71,37 +71,31 @@ def ldap_sync_single(source_pk: str): | ||||
|             return | ||||
|         # Delete all sync tasks from the cache | ||||
|         DBSystemTask.objects.filter(name="ldap_sync", uid__startswith=source.slug).delete() | ||||
|         task = chain( | ||||
|             # User and group sync can happen at once, they have no dependencies on each other | ||||
|             group( | ||||
|                 ldap_sync_paginator(source, UserLDAPSynchronizer) | ||||
|                 + ldap_sync_paginator(source, GroupLDAPSynchronizer), | ||||
|             ), | ||||
|             # Membership sync needs to run afterwards | ||||
|             group( | ||||
|                 ldap_sync_paginator(source, MembershipLDAPSynchronizer), | ||||
|             ), | ||||
|             # Finally, deletions. What we'd really like to do here is something like | ||||
|             # ``` | ||||
|             # user_identifiers = <ldap query> | ||||
|             # User.objects.exclude( | ||||
|             #     usersourceconnection__identifier__in=user_uniqueness_identifiers, | ||||
|             # ).delete() | ||||
|             # ``` | ||||
|             # This runs into performance issues in large installations. So instead we spread the | ||||
|             # work out into three steps: | ||||
|             # 1. Get every object from the LDAP source. | ||||
|             # 2. Mark every object as "safe" in the database. This is quick, but any error could | ||||
|             #    mean deleting users which should not be deleted, so we do it immediately, in | ||||
|             #    large chunks, and only queue the deletion step afterwards. | ||||
|             # 3. Delete every unmarked item. This is slow, so we spread it over many tasks in | ||||
|             #    small chunks. | ||||
|             group( | ||||
|                 ldap_sync_paginator(source, UserLDAPForwardDeletion) | ||||
|                 + ldap_sync_paginator(source, GroupLDAPForwardDeletion), | ||||
|             ), | ||||
|  | ||||
|         # The order of these operations needs to be preserved as each depends on the previous one(s) | ||||
|         # 1. User and group sync can happen simultaneously | ||||
|         # 2. Membership sync needs to run afterwards | ||||
|         # 3. Finally, user and group deletions can happen simultaneously | ||||
|         user_group_sync = ldap_sync_paginator(source, UserLDAPSynchronizer) + ldap_sync_paginator( | ||||
|             source, GroupLDAPSynchronizer | ||||
|         ) | ||||
|         task() | ||||
|         membership_sync = ldap_sync_paginator(source, MembershipLDAPSynchronizer) | ||||
|         user_group_deletion = ldap_sync_paginator( | ||||
|             source, UserLDAPForwardDeletion | ||||
|         ) + ldap_sync_paginator(source, GroupLDAPForwardDeletion) | ||||
|  | ||||
|         # Celery is buggy with empty groups, so we are careful only to add non-empty groups. | ||||
|         # See https://github.com/celery/celery/issues/9772 | ||||
|         task_groups = [] | ||||
|         if user_group_sync: | ||||
|             task_groups.append(group(user_group_sync)) | ||||
|         if membership_sync: | ||||
|             task_groups.append(group(membership_sync)) | ||||
|         if user_group_deletion: | ||||
|             task_groups.append(group(user_group_deletion)) | ||||
|  | ||||
|         all_tasks = chain(task_groups) | ||||
|         all_tasks() | ||||
|  | ||||
|  | ||||
| def ldap_sync_paginator(source: LDAPSource, sync: type[BaseLDAPSynchronizer]) -> list: | ||||
|  | ||||
							
								
								
									
										277
									
								
								authentik/sources/scim/tests/test_groups.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								authentik/sources/scim/tests/test_groups.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,277 @@ | ||||
| """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,3 +177,51 @@ 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,6 +8,7 @@ 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 | ||||
|  | ||||
| @ -26,6 +27,7 @@ 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 | ||||
|  | ||||
| @ -52,4 +54,5 @@ 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,13 +1,11 @@ | ||||
| """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 | ||||
| @ -46,7 +44,7 @@ class SCIMView(APIView): | ||||
|     logger: BoundLogger | ||||
|  | ||||
|     permission_classes = [IsAuthenticated] | ||||
|     parser_classes = [SCIMParser] | ||||
|     parser_classes = [SCIMParser, JSONParser] | ||||
|     renderer_classes = [SCIMRenderer] | ||||
|  | ||||
|     def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None: | ||||
| @ -56,28 +54,6 @@ 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") | ||||
|  | ||||
							
								
								
									
										58
									
								
								authentik/sources/scim/views/v2/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								authentik/sources/scim/views/v2/exceptions.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,58 @@ | ||||
| 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,19 +4,25 @@ from uuid import uuid4 | ||||
|  | ||||
| from django.db.models import Q | ||||
| from django.db.transaction import atomic | ||||
| from django.http import Http404, QueryDict | ||||
| from django.http import 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_USER_SCHEMA | ||||
| from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation | ||||
| 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): | ||||
| @ -27,7 +33,7 @@ class GroupsView(SCIMObjectView): | ||||
|     def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict: | ||||
|         """Convert Group to SCIM data""" | ||||
|         payload = SCIMGroupModel( | ||||
|             schemas=[SCIM_USER_SCHEMA], | ||||
|             schemas=[SCIM_GROUP_SCHEMA], | ||||
|             id=str(scim_group.group.pk), | ||||
|             externalId=scim_group.id, | ||||
|             displayName=scim_group.group.name, | ||||
| @ -58,7 +64,7 @@ class GroupsView(SCIMObjectView): | ||||
|         if group_id: | ||||
|             connection = base_query.filter(source=self.source, group__group_uuid=group_id).first() | ||||
|             if not connection: | ||||
|                 raise Http404 | ||||
|                 raise SCIMNotFoundError("Group not found.") | ||||
|             return Response(self.group_to_scim(connection)) | ||||
|         connections = ( | ||||
|             base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request)) | ||||
| @ -119,7 +125,7 @@ class GroupsView(SCIMObjectView): | ||||
|         ).first() | ||||
|         if connection: | ||||
|             self.logger.debug("Found existing group") | ||||
|             return Response(status=409) | ||||
|             raise SCIMConflictError("Group with ID exists already.") | ||||
|         connection = self.update_group(None, request.data) | ||||
|         return Response(self.group_to_scim(connection), status=201) | ||||
|  | ||||
| @ -129,10 +135,44 @@ class GroupsView(SCIMObjectView): | ||||
|             source=self.source, group__group_uuid=group_id | ||||
|         ).first() | ||||
|         if not connection: | ||||
|             raise Http404 | ||||
|             raise SCIMNotFoundError("Group not found.") | ||||
|         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""" | ||||
| @ -140,7 +180,7 @@ class GroupsView(SCIMObjectView): | ||||
|             source=self.source, group__group_uuid=group_id | ||||
|         ).first() | ||||
|         if not connection: | ||||
|             raise Http404 | ||||
|             raise SCIMNotFoundError("Group not found.") | ||||
|         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 Http404 | ||||
|             raise SCIMNotFoundError("Resource not found.") | ||||
|         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 Http404 | ||||
|             raise SCIMNotFoundError("Schema not found.") | ||||
|         return Response( | ||||
|             { | ||||
|                 "schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], | ||||
|  | ||||
| @ -33,6 +33,8 @@ 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 Http404, QueryDict | ||||
| from django.http import QueryDict | ||||
| from django.urls import reverse | ||||
| from pydanticscim.user import Email, EmailKind, Name | ||||
| from rest_framework.exceptions import ValidationError | ||||
| @ -16,6 +16,7 @@ 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): | ||||
| @ -69,7 +70,7 @@ class UsersView(SCIMObjectView): | ||||
|                 .first() | ||||
|             ) | ||||
|             if not connection: | ||||
|                 raise Http404 | ||||
|                 raise SCIMNotFoundError("User not found.") | ||||
|             return Response(self.user_to_scim(connection)) | ||||
|         connections = ( | ||||
|             SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk") | ||||
| @ -122,7 +123,7 @@ class UsersView(SCIMObjectView): | ||||
|         ).first() | ||||
|         if connection: | ||||
|             self.logger.debug("Found existing user") | ||||
|             return Response(status=409) | ||||
|             raise SCIMConflictError("Group with ID exists already.") | ||||
|         connection = self.update_user(None, request.data) | ||||
|         return Response(self.user_to_scim(connection), status=201) | ||||
|  | ||||
| @ -130,7 +131,7 @@ class UsersView(SCIMObjectView): | ||||
|         """Update user handler""" | ||||
|         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() | ||||
|         if not connection: | ||||
|             raise Http404 | ||||
|             raise SCIMNotFoundError("User not found.") | ||||
|         self.update_user(connection, request.data) | ||||
|         return Response(self.user_to_scim(connection), status=200) | ||||
|  | ||||
| @ -139,7 +140,7 @@ class UsersView(SCIMObjectView): | ||||
|         """Delete user handler""" | ||||
|         connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first() | ||||
|         if not connection: | ||||
|             raise Http404 | ||||
|             raise SCIMNotFoundError("User not found.") | ||||
|         connection.user.delete() | ||||
|         connection.delete() | ||||
|         return Response(status=204) | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| """Validation stage challenge checking""" | ||||
|  | ||||
| from json import loads | ||||
| from typing import TYPE_CHECKING | ||||
| from urllib.parse import urlencode | ||||
|  | ||||
| from django.http import HttpRequest | ||||
| @ -36,10 +37,12 @@ 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 SESSION_KEY_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_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): | ||||
| @ -52,11 +55,11 @@ class DeviceChallenge(PassiveSerializer): | ||||
|  | ||||
|  | ||||
| def get_challenge_for_device( | ||||
|     request: HttpRequest, stage: AuthenticatorValidateStage, device: Device | ||||
|     stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage, device: Device | ||||
| ) -> dict: | ||||
|     """Generate challenge for a single device""" | ||||
|     if isinstance(device, WebAuthnDevice): | ||||
|         return get_webauthn_challenge(request, stage, device) | ||||
|         return get_webauthn_challenge(stage_view, stage, device) | ||||
|     if isinstance(device, EmailDevice): | ||||
|         return {"email": mask_email(device.email)} | ||||
|     # Code-based challenges have no hints | ||||
| @ -64,26 +67,30 @@ def get_challenge_for_device( | ||||
|  | ||||
|  | ||||
| def get_webauthn_challenge_without_user( | ||||
|     request: HttpRequest, stage: AuthenticatorValidateStage | ||||
|     stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage | ||||
| ) -> dict: | ||||
|     """Same as `get_webauthn_challenge`, but allows any client device. We can then later check | ||||
|     who the device belongs to.""" | ||||
|     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) | ||||
|     stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) | ||||
|     authentication_options = generate_authentication_options( | ||||
|         rp_id=get_rp_id(request), | ||||
|         rp_id=get_rp_id(stage_view.request), | ||||
|         allow_credentials=[], | ||||
|         user_verification=UserVerificationRequirement(stage.webauthn_user_verification), | ||||
|     ) | ||||
|     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge | ||||
|     stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = ( | ||||
|         authentication_options.challenge | ||||
|     ) | ||||
|  | ||||
|     return loads(options_to_json(authentication_options)) | ||||
|  | ||||
|  | ||||
| def get_webauthn_challenge( | ||||
|     request: HttpRequest, stage: AuthenticatorValidateStage, device: WebAuthnDevice | None = None | ||||
|     stage_view: "AuthenticatorValidateStageView", | ||||
|     stage: AuthenticatorValidateStage, | ||||
|     device: WebAuthnDevice | None = None, | ||||
| ) -> dict: | ||||
|     """Send the client a challenge that we'll check later""" | ||||
|     request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None) | ||||
|     stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None) | ||||
|  | ||||
|     allowed_credentials = [] | ||||
|  | ||||
| @ -94,12 +101,14 @@ def get_webauthn_challenge( | ||||
|             allowed_credentials.append(user_device.descriptor) | ||||
|  | ||||
|     authentication_options = generate_authentication_options( | ||||
|         rp_id=get_rp_id(request), | ||||
|         rp_id=get_rp_id(stage_view.request), | ||||
|         allow_credentials=allowed_credentials, | ||||
|         user_verification=UserVerificationRequirement(stage.webauthn_user_verification), | ||||
|     ) | ||||
|  | ||||
|     request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge | ||||
|     stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = ( | ||||
|         authentication_options.challenge | ||||
|     ) | ||||
|  | ||||
|     return loads(options_to_json(authentication_options)) | ||||
|  | ||||
| @ -146,7 +155,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 = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE) | ||||
|     challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_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.request, stage, device), | ||||
|                     "challenge": get_challenge_for_device(self, 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.request, | ||||
|                     self, | ||||
|                     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 SESSION_KEY_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_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,7 +103,11 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             device_classes=[DeviceClasses.WEBAUTHN], | ||||
|             webauthn_user_verification=UserVerification.PREFERRED, | ||||
|         ) | ||||
|         challenge = get_challenge_for_device(request, stage, webauthn_device) | ||||
|         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) | ||||
|         del challenge["challenge"] | ||||
|         self.assertEqual( | ||||
|             challenge, | ||||
| @ -122,7 +126,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|  | ||||
|         with self.assertRaises(ValidationError): | ||||
|             validate_challenge_webauthn( | ||||
|                 {}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user | ||||
|                 {}, | ||||
|                 StageView(FlowExecutorView(current_stage=stage, plan=plan), request=request), | ||||
|                 self.user, | ||||
|             ) | ||||
|  | ||||
|     def test_device_challenge_webauthn_restricted(self): | ||||
| @ -193,22 +199,35 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             sign_count=0, | ||||
|             rp_id=generate_id(), | ||||
|         ) | ||||
|         challenge = get_challenge_for_device(request, stage, webauthn_device) | ||||
|         webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] | ||||
|         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) | ||||
|         self.assertEqual( | ||||
|             challenge, | ||||
|             { | ||||
|                 "allowCredentials": [ | ||||
|                     { | ||||
|                         "id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU", | ||||
|                         "type": "public-key", | ||||
|                     } | ||||
|                 ], | ||||
|                 "challenge": bytes_to_base64url(webauthn_challenge), | ||||
|                 "rpId": "testserver", | ||||
|                 "timeout": 60000, | ||||
|                 "userVerification": "preferred", | ||||
|             }, | ||||
|             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", | ||||
|         ) | ||||
|  | ||||
|     def test_get_challenge_userless(self): | ||||
| @ -228,18 +247,16 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             sign_count=0, | ||||
|             rp_id=generate_id(), | ||||
|         ) | ||||
|         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", | ||||
|             }, | ||||
|         plan = FlowPlan("") | ||||
|         stage_view = AuthenticatorValidateStageView( | ||||
|             FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request | ||||
|         ) | ||||
|         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)""" | ||||
| @ -275,10 +292,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|                 "last_used": None, | ||||
|             } | ||||
|         ] | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" | ||||
|         ) | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         response = self.client.post( | ||||
| @ -352,10 +369,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|                 "last_used": None, | ||||
|             } | ||||
|         ] | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ" | ||||
|         ) | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         response = self.client.post( | ||||
| @ -433,10 +450,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|                 "last_used": None, | ||||
|             } | ||||
|         ] | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" | ||||
|         ) | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|  | ||||
|         response = self.client.post( | ||||
| @ -496,17 +513,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase): | ||||
|             not_configured_action=NotConfiguredAction.CONFIGURE, | ||||
|             device_classes=[DeviceClasses.WEBAUTHN], | ||||
|         ) | ||||
|         stage_view = AuthenticatorValidateStageView( | ||||
|             FlowExecutorView(flow=flow, current_stage=stage), request=request | ||||
|         ) | ||||
|         request = get_request("/") | ||||
|         request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|         plan = FlowPlan(flow.pk.hex) | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes( | ||||
|             "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" | ||||
|         ) | ||||
|         request.session.save() | ||||
|         request = get_request("/") | ||||
|  | ||||
|         stage_view = AuthenticatorValidateStageView( | ||||
|             FlowExecutorView(flow=flow, current_stage=stage), request=request | ||||
|             FlowExecutorView(flow=flow, current_stage=stage, plan=plan), request=request | ||||
|         ) | ||||
|         request.META["SERVER_NAME"] = "localhost" | ||||
|         request.META["SERVER_PORT"] = "9000" | ||||
|  | ||||
| @ -25,6 +25,7 @@ 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
											
										
									
								
							| @ -0,0 +1,21 @@ | ||||
| # 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,6 +84,8 @@ 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,12 +5,13 @@ 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 InvalidRegistrationResponse | ||||
| from webauthn.helpers.exceptions import WebAuthnException | ||||
| from webauthn.helpers.structs import ( | ||||
|     AttestationConveyancePreference, | ||||
|     AuthenticatorAttachment, | ||||
| @ -41,7 +42,8 @@ from authentik.stages.authenticator_webauthn.models import ( | ||||
| ) | ||||
| from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id | ||||
|  | ||||
| SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge" | ||||
| PLAN_CONTEXT_WEBAUTHN_CHALLENGE = "goauthentik.io/stages/authenticator_webauthn/challenge" | ||||
| PLAN_CONTEXT_WEBAUTHN_ATTEMPT = "goauthentik.io/stages/authenticator_webauthn/attempt" | ||||
|  | ||||
|  | ||||
| class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge): | ||||
| @ -62,7 +64,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): | ||||
|  | ||||
|     def validate_response(self, response: dict) -> dict: | ||||
|         """Validate webauthn challenge response""" | ||||
|         challenge = self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] | ||||
|         challenge = self.stage.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] | ||||
|  | ||||
|         try: | ||||
|             registration: VerifiedRegistration = verify_registration_response( | ||||
| @ -71,7 +73,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse): | ||||
|                 expected_rp_id=get_rp_id(self.request), | ||||
|                 expected_origin=get_origin(self.request), | ||||
|             ) | ||||
|         except InvalidRegistrationResponse as exc: | ||||
|         except WebAuthnException as exc: | ||||
|             self.stage.logger.warning("registration failed", exc=exc) | ||||
|             raise ValidationError(f"Registration failed. Error: {exc}") from None | ||||
|  | ||||
| @ -114,9 +116,10 @@ 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 | ||||
| @ -139,8 +142,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView): | ||||
|             attestation=AttestationConveyancePreference.DIRECT, | ||||
|         ) | ||||
|  | ||||
|         self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = registration_options.challenge | ||||
|         self.request.session.save() | ||||
|         self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = registration_options.challenge | ||||
|         return AuthenticatorWebAuthnChallenge( | ||||
|             data={ | ||||
|                 "registration": loads(options_to_json(registration_options)), | ||||
| @ -153,6 +155,24 @@ 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"] | ||||
| @ -179,6 +199,3 @@ 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 SESSION_KEY_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE | ||||
| from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import | ||||
|  | ||||
|  | ||||
| @ -57,6 +57,9 @@ 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( | ||||
| @ -70,7 +73,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase): | ||||
|                     "name": self.user.username, | ||||
|                     "displayName": self.user.name, | ||||
|                 }, | ||||
|                 "challenge": bytes_to_base64url(session[SESSION_KEY_WEBAUTHN_CHALLENGE]), | ||||
|                 "challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]), | ||||
|                 "pubKeyCredParams": [ | ||||
|                     {"type": "public-key", "alg": -7}, | ||||
|                     {"type": "public-key", "alg": -8}, | ||||
| @ -97,11 +100,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 | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
| @ -146,11 +149,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 | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
| @ -209,11 +212,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 | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
| @ -259,11 +262,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 | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|         plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode( | ||||
|             b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw==" | ||||
|         ) | ||||
|         session = self.client.session | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|         session.save() | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), | ||||
| @ -298,3 +301,109 @@ 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()) | ||||
|  | ||||
| @ -27,7 +27,6 @@ | ||||
|     </table> | ||||
|   </td> | ||||
| </tr> | ||||
| <td> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block sub_content %} | ||||
|  | ||||
| @ -101,9 +101,9 @@ class BoundSessionMiddleware(SessionMiddleware): | ||||
|             SESSION_KEY_BINDING_GEO, GeoIPBinding.NO_BINDING | ||||
|         ) | ||||
|         if configured_binding_net != NetworkBinding.NO_BINDING: | ||||
|             self.recheck_session_net(configured_binding_net, last_ip, new_ip) | ||||
|             BoundSessionMiddleware.recheck_session_net(configured_binding_net, last_ip, new_ip) | ||||
|         if configured_binding_geo != GeoIPBinding.NO_BINDING: | ||||
|             self.recheck_session_geo(configured_binding_geo, last_ip, new_ip) | ||||
|             BoundSessionMiddleware.recheck_session_geo(configured_binding_geo, last_ip, new_ip) | ||||
|         # If we got to this point without any error being raised, we need to | ||||
|         # update the last saved IP to the current one | ||||
|         if SESSION_KEY_BINDING_NET in request.session or SESSION_KEY_BINDING_GEO in request.session: | ||||
| @ -111,7 +111,8 @@ class BoundSessionMiddleware(SessionMiddleware): | ||||
|             # (== basically requires the user to be logged in) | ||||
|             request.session[request.session.model.Keys.LAST_IP] = new_ip | ||||
|  | ||||
|     def recheck_session_net(self, binding: NetworkBinding, last_ip: str, new_ip: str): | ||||
|     @staticmethod | ||||
|     def recheck_session_net(binding: NetworkBinding, last_ip: str, new_ip: str): | ||||
|         """Check network/ASN binding""" | ||||
|         last_asn = ASN_CONTEXT_PROCESSOR.asn(last_ip) | ||||
|         new_asn = ASN_CONTEXT_PROCESSOR.asn(new_ip) | ||||
| @ -158,7 +159,8 @@ class BoundSessionMiddleware(SessionMiddleware): | ||||
|                     new_ip, | ||||
|                 ) | ||||
|  | ||||
|     def recheck_session_geo(self, binding: GeoIPBinding, last_ip: str, new_ip: str): | ||||
|     @staticmethod | ||||
|     def recheck_session_geo(binding: GeoIPBinding, last_ip: str, new_ip: str): | ||||
|         """Check GeoIP binding""" | ||||
|         last_geo = GEOIP_CONTEXT_PROCESSOR.city(last_ip) | ||||
|         new_geo = GEOIP_CONTEXT_PROCESSOR.city(new_ip) | ||||
| @ -179,8 +181,8 @@ class BoundSessionMiddleware(SessionMiddleware): | ||||
|             if last_geo.continent != new_geo.continent: | ||||
|                 raise SessionBindingBroken( | ||||
|                     "geoip.continent", | ||||
|                     last_geo.continent, | ||||
|                     new_geo.continent, | ||||
|                     last_geo.continent.to_dict(), | ||||
|                     new_geo.continent.to_dict(), | ||||
|                     last_ip, | ||||
|                     new_ip, | ||||
|                 ) | ||||
| @ -192,8 +194,8 @@ class BoundSessionMiddleware(SessionMiddleware): | ||||
|             if last_geo.country != new_geo.country: | ||||
|                 raise SessionBindingBroken( | ||||
|                     "geoip.country", | ||||
|                     last_geo.country, | ||||
|                     new_geo.country, | ||||
|                     last_geo.country.to_dict(), | ||||
|                     new_geo.country.to_dict(), | ||||
|                     last_ip, | ||||
|                     new_ip, | ||||
|                 ) | ||||
| @ -202,8 +204,8 @@ class BoundSessionMiddleware(SessionMiddleware): | ||||
|             if last_geo.city != new_geo.city: | ||||
|                 raise SessionBindingBroken( | ||||
|                     "geoip.city", | ||||
|                     last_geo.city, | ||||
|                     new_geo.city, | ||||
|                     last_geo.city.to_dict(), | ||||
|                     new_geo.city.to_dict(), | ||||
|                     last_ip, | ||||
|                     new_ip, | ||||
|                 ) | ||||
|  | ||||
| @ -3,6 +3,7 @@ | ||||
| from time import sleep | ||||
| from unittest.mock import patch | ||||
|  | ||||
| from django.http import HttpRequest | ||||
| from django.urls import reverse | ||||
| from django.utils.timezone import now | ||||
|  | ||||
| @ -17,7 +18,12 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.root.middleware import ClientIPMiddleware | ||||
| from authentik.stages.user_login.models import UserLoginStage | ||||
| from authentik.stages.user_login.middleware import ( | ||||
|     BoundSessionMiddleware, | ||||
|     SessionBindingBroken, | ||||
|     logout_extra, | ||||
| ) | ||||
| from authentik.stages.user_login.models import GeoIPBinding, NetworkBinding, UserLoginStage | ||||
|  | ||||
|  | ||||
| class TestUserLoginStage(FlowTestCase): | ||||
| @ -192,3 +198,52 @@ class TestUserLoginStage(FlowTestCase): | ||||
|         self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) | ||||
|         response = self.client.get(reverse("authentik_api:application-list")) | ||||
|         self.assertEqual(response.status_code, 403) | ||||
|  | ||||
|     def test_binding_net_break_log(self): | ||||
|         """Test logout_extra with exception""" | ||||
|         # IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-ASN-Test.json | ||||
|         for args, expect in [ | ||||
|             [[NetworkBinding.BIND_ASN, "8.8.8.8", "8.8.8.8"], ["network.missing"]], | ||||
|             [[NetworkBinding.BIND_ASN, "1.0.0.1", "1.128.0.1"], ["network.asn"]], | ||||
|             [ | ||||
|                 [NetworkBinding.BIND_ASN_NETWORK, "12.81.96.1", "12.81.128.1"], | ||||
|                 ["network.asn_network"], | ||||
|             ], | ||||
|             [[NetworkBinding.BIND_ASN_NETWORK_IP, "1.0.0.1", "1.0.0.2"], ["network.ip"]], | ||||
|         ]: | ||||
|             with self.subTest(args[0]): | ||||
|                 with self.assertRaises(SessionBindingBroken) as cm: | ||||
|                     BoundSessionMiddleware.recheck_session_net(*args) | ||||
|                 self.assertEqual(cm.exception.reason, expect[0]) | ||||
|                 # Ensure the request can be logged without throwing errors | ||||
|                 self.client.force_login(self.user) | ||||
|                 request = HttpRequest() | ||||
|                 request.session = self.client.session | ||||
|                 request.user = self.user | ||||
|                 logout_extra(request, cm.exception) | ||||
|  | ||||
|     def test_binding_geo_break_log(self): | ||||
|         """Test logout_extra with exception""" | ||||
|         # IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json | ||||
|         for args, expect in [ | ||||
|             [[GeoIPBinding.BIND_CONTINENT, "8.8.8.8", "8.8.8.8"], ["geoip.missing"]], | ||||
|             [[GeoIPBinding.BIND_CONTINENT, "2.125.160.216", "67.43.156.1"], ["geoip.continent"]], | ||||
|             [ | ||||
|                 [GeoIPBinding.BIND_CONTINENT_COUNTRY, "81.2.69.142", "89.160.20.112"], | ||||
|                 ["geoip.country"], | ||||
|             ], | ||||
|             [ | ||||
|                 [GeoIPBinding.BIND_CONTINENT_COUNTRY_CITY, "2.125.160.216", "81.2.69.142"], | ||||
|                 ["geoip.city"], | ||||
|             ], | ||||
|         ]: | ||||
|             with self.subTest(args[0]): | ||||
|                 with self.assertRaises(SessionBindingBroken) as cm: | ||||
|                     BoundSessionMiddleware.recheck_session_geo(*args) | ||||
|                 self.assertEqual(cm.exception.reason, expect[0]) | ||||
|                 # Ensure the request can be logged without throwing errors | ||||
|                 self.client.force_login(self.user) | ||||
|                 request = HttpRequest() | ||||
|                 request.session = self.client.session | ||||
|                 request.user = self.user | ||||
|                 logout_extra(request, cm.exception) | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| """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 | ||||
|  | ||||
| @ -12,6 +13,8 @@ from authentik.tenants.models import Tenant | ||||
| class SettingsSerializer(ModelSerializer): | ||||
|     """Settings Serializer""" | ||||
|  | ||||
|     footer_links = JSONField(required=False) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Tenant | ||||
|         fields = [ | ||||
|  | ||||
| @ -16,6 +16,7 @@ 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.2 Blueprint schema", | ||||
|     "title": "authentik 2025.6.3 Blueprint schema", | ||||
|     "required": [ | ||||
|         "version", | ||||
|         "entries" | ||||
| @ -13310,6 +13310,12 @@ | ||||
|                         "format": "uuid" | ||||
|                     }, | ||||
|                     "title": "Device type restrictions" | ||||
|                 }, | ||||
|                 "max_attempts": { | ||||
|                     "type": "integer", | ||||
|                     "minimum": 0, | ||||
|                     "maximum": 2147483647, | ||||
|                     "title": "Max attempts" | ||||
|                 } | ||||
|             }, | ||||
|             "required": [] | ||||
|  | ||||
| @ -1,6 +1,8 @@ | ||||
| version: 1 | ||||
| metadata: | ||||
|   name: OIDC conformance testing | ||||
|   name: OpenID Conformance testing | ||||
|   labels: | ||||
|     blueprints.goauthentik.io/instantiate: "false" | ||||
| entries: | ||||
|   - identifiers: | ||||
|       managed: goauthentik.io/providers/oauth2/scope-address | ||||
| @ -21,38 +23,72 @@ entries: | ||||
|     attrs: | ||||
|       name: "authentik default OAuth Mapping: OpenID 'phone'" | ||||
|       scope_name: phone | ||||
|       description: "General phone Information" | ||||
|       description: "General phone information" | ||||
|       expression: | | ||||
|         return { | ||||
|             "phone_number": "+1234", | ||||
|             "phone_number_verified": True, | ||||
|         } | ||||
|   - identifiers: | ||||
|       managed: goauthentik.io/providers/oauth2/scope-profile-oidc-standard | ||||
|     model: authentik_providers_oauth2.scopemapping | ||||
|     attrs: | ||||
|       name: "OIDC conformance profile" | ||||
|       scope_name: profile | ||||
|       description: "General profile information" | ||||
|       expression: | | ||||
|         return { | ||||
|             # Because authentik only saves the user's full name, and has no concept of first and last names, | ||||
|             # the full name is used as given name. | ||||
|             # You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")` | ||||
|             "name": request.user.name, | ||||
|             "given_name": request.user.name, | ||||
|             "preferred_username": request.user.username, | ||||
|             "nickname": request.user.username, | ||||
|             "groups": [group.name for group in request.user.ak_groups.all()], | ||||
|             "website" : "foo", | ||||
|             "zoneinfo" : "foo", | ||||
|             "birthdate" : "2000", | ||||
|             "gender" : "foo", | ||||
|             "profile" : "foo", | ||||
|             "middle_name" : "foo", | ||||
|             "locale" : "foo", | ||||
|             "picture" : "foo", | ||||
|             "updated_at" : 1748557810, | ||||
|             "family_name" : "foo", | ||||
|         } | ||||
| 
 | ||||
| 
 | ||||
|   - model: authentik_providers_oauth2.oauth2provider | ||||
|     id: provider | ||||
|     id: oidc-conformance-1 | ||||
|     identifiers: | ||||
|       name: provider | ||||
|       name: oidc-conformance-1 | ||||
|     attrs: | ||||
|       authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] | ||||
|       invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] | ||||
|       # Required as OIDC Conformance test requires issues to be the same across multiple clients | ||||
|       issuer_mode: global | ||||
|       client_id: 4054d882aff59755f2f279968b97ce8806a926e1 | ||||
|       client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867 | ||||
|       redirect_uris: | | ||||
|         https://localhost:8443/test/a/authentik/callback | ||||
|         https://localhost.emobix.co.uk:8443/test/a/authentik/callback | ||||
|       redirect_uris: | ||||
|         - matching_mode: strict | ||||
|           url: https://localhost:8443/test/a/authentik/callback | ||||
|         - matching_mode: strict | ||||
|           url: https://host.docker.internal:8443/test/a/authentik/callback | ||||
|       property_mappings: | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]] | ||||
|       signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] | ||||
|   - model: authentik_core.application | ||||
|     identifiers: | ||||
|       slug: conformance | ||||
|       slug: oidc-conformance-1 | ||||
|     attrs: | ||||
|       provider: !KeyOf provider | ||||
|       name: Conformance | ||||
|       provider: !KeyOf oidc-conformance-1 | ||||
|       name: OIDC Conformance (1) | ||||
| 
 | ||||
|   - model: authentik_providers_oauth2.oauth2provider | ||||
|     id: oidc-conformance-2 | ||||
| @ -60,22 +96,27 @@ entries: | ||||
|       name: oidc-conformance-2 | ||||
|     attrs: | ||||
|       authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]] | ||||
|       invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]] | ||||
|       # Required as OIDC Conformance test requires issues to be the same across multiple clients | ||||
|       issuer_mode: global | ||||
|       client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26 | ||||
|       client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789 | ||||
|       redirect_uris: | | ||||
|         https://localhost:8443/test/a/authentik/callback | ||||
|         https://localhost.emobix.co.uk:8443/test/a/authentik/callback | ||||
|       redirect_uris: | ||||
|         - matching_mode: strict | ||||
|           url: https://localhost:8443/test/a/authentik/callback | ||||
|         - matching_mode: strict | ||||
|           url: https://host.docker.internal:8443/test/a/authentik/callback | ||||
|       property_mappings: | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]] | ||||
|         - !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]] | ||||
|       signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] | ||||
|   - model: authentik_core.application | ||||
|     identifiers: | ||||
|       slug: oidc-conformance-2 | ||||
|     attrs: | ||||
|       provider: !KeyOf oidc-conformance-2 | ||||
|       name: OIDC Conformance | ||||
|       name: OIDC Conformance (2) | ||||
| @ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redis:/data | ||||
|   server: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.2} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.3} | ||||
|     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.2} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.3} | ||||
|     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.33.0 | ||||
| 	github.com/getsentry/sentry-go v0.34.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.10.0 | ||||
| 	github.com/redis/go-redis/v9 v9.11.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.2025062.3 | ||||
| 	goauthentik.io/api/v3 v3.2025063.1 | ||||
| 	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.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg= | ||||
| github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= | ||||
| 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/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.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= | ||||
| github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= | ||||
| 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/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.2025062.3 h1:syBOKigaHyX/8Rwmh9kOSF+TzsxOzmP5i7rsFwbemzA= | ||||
| goauthentik.io/api/v3 v3.2025062.3/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||
| 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= | ||||
| 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.2" | ||||
| const VERSION = "2025.6.3" | ||||
|  | ||||
							
								
								
									
										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.1018.1", | ||||
|                 "aws-cdk": "^2.1019.2", | ||||
|                 "cross-env": "^7.0.3" | ||||
|             }, | ||||
|             "engines": { | ||||
| @ -17,9 +17,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/aws-cdk": { | ||||
|             "version": "2.1018.1", | ||||
|             "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1018.1.tgz", | ||||
|             "integrity": "sha512-kFPRox5kSm+ktJ451o0ng9rD+60p5Kt1CZIWw8kXnvqbsxN2xv6qbmyWSXw7sGVXVwqrRKVj+71/JeDr+LMAZw==", | ||||
|             "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==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "bin": { | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|         "node": ">=20" | ||||
|     }, | ||||
|     "devDependencies": { | ||||
|         "aws-cdk": "^2.1018.1", | ||||
|         "aws-cdk": "^2.1019.2", | ||||
|         "cross-env": "^7.0.3" | ||||
|     } | ||||
| } | ||||
|  | ||||
| @ -26,7 +26,7 @@ Parameters: | ||||
|     Description: authentik Docker image | ||||
|   AuthentikVersion: | ||||
|     Type: String | ||||
|     Default: 2025.6.2 | ||||
|     Default: 2025.6.3 | ||||
|     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 | ||||
| from authentik.lib.config import CONFIG, django_db_config | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| ADV_LOCK_UID = 1000 | ||||
| @ -115,9 +115,13 @@ 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"]) | ||||
|         execute_from_command_line( | ||||
|             ["", "check"] + ([] if CONFIG.get_bool("debug") else ["--deploy"]) | ||||
|         ) | ||||
|         # 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) | ||||
|     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-19 00:10+0000\n" | ||||
| "POT-Creation-Date: 2025-06-25 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,10 +109,6 @@ 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-05-28 11:25+0000\n" | ||||
| "POT-Creation-Date: 2025-06-25 00:10+0000\n" | ||||
| "PO-Revision-Date: 2022-09-26 16:47+0000\n" | ||||
| "Last-Translator: Kowalski Dragon (kowalski7cc) <kowalski.7cc@gmail.com>, 2025\n" | ||||
| "Last-Translator: albanobattistella <albanobattistella@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 "" | ||||
| msgstr "Certificati utilizzati per l'autenticazione del client." | ||||
|  | ||||
| #: authentik/brands/models.py | ||||
| msgid "Brand" | ||||
| @ -130,10 +130,6 @@ 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." | ||||
| @ -294,15 +290,15 @@ msgid "" | ||||
| msgstr "" | ||||
| "Collegamento a un utente con indirizzo email identico. Può avere " | ||||
| "implicazioni sulla sicurezza quando una fonte non convalida gli indirizzi " | ||||
| "e-mail." | ||||
| "email." | ||||
|  | ||||
| #: authentik/core/models.py | ||||
| msgid "" | ||||
| "Use the user's email address, but deny enrollment when the email address " | ||||
| "already exists." | ||||
| msgstr "" | ||||
| "Usa l'indirizzo e-mail dell'utente, ma nega l'iscrizione quando l'indirizzo " | ||||
| "e-mail esiste già." | ||||
| "Usa l'indirizzo email dell'utente, ma nega l'iscrizione quando l'indirizzo " | ||||
| "email esiste già." | ||||
|  | ||||
| #: authentik/core/models.py | ||||
| msgid "" | ||||
| @ -682,26 +678,29 @@ 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 "" | ||||
| msgstr "Fase di TLS reciproca" | ||||
|  | ||||
| #: authentik/enterprise/stages/mtls/models.py | ||||
| msgid "Mutual TLS Stages" | ||||
| msgstr "" | ||||
| msgstr "Fasi di TLS reciproche" | ||||
|  | ||||
| #: authentik/enterprise/stages/mtls/models.py | ||||
| msgid "Permissions to pass Certificates for outposts." | ||||
| msgstr "" | ||||
| msgstr " Permessi di trasmissione dei Certificati per gli avamposti." | ||||
|  | ||||
| #: authentik/enterprise/stages/mtls/stage.py | ||||
| msgid "Certificate required but no certificate was given." | ||||
| msgstr "" | ||||
| msgstr " Il certificato è stato richiesto ma non è stato consegnato." | ||||
|  | ||||
| #: authentik/enterprise/stages/mtls/stage.py | ||||
| msgid "No user found for certificate." | ||||
| msgstr "" | ||||
| msgstr "Nessun utente trovato per il certificato." | ||||
|  | ||||
| #: authentik/enterprise/stages/source/models.py | ||||
| msgid "" | ||||
| @ -834,6 +833,14 @@ 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" | ||||
| @ -1050,16 +1057,16 @@ msgstr "Avvio della sincronizzazione completa del provider" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| msgid "Syncing users" | ||||
| msgstr "" | ||||
| msgstr "Sincronizzazione degli utenti" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| msgid "Syncing groups" | ||||
| msgstr "" | ||||
| msgstr "Sincronizzazione dei gruppi" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| #, python-brace-format | ||||
| msgid "Syncing page {page} of groups" | ||||
| msgstr "Sincronizzando pagina {page} dei gruppi" | ||||
| msgid "Syncing page {page} of {object_type}" | ||||
| msgstr "Sincronizzazione della pagina {page} di {object_type}" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| msgid "Dropping mutating request due to dry run" | ||||
| @ -2461,6 +2468,10 @@ 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." | ||||
| @ -2502,6 +2513,8 @@ 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" | ||||
| @ -2523,6 +2536,8 @@ 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" | ||||
| @ -2920,7 +2935,7 @@ msgstr "Connessioni sorgente SAML di gruppo" | ||||
| #: authentik/sources/saml/views.py | ||||
| #, python-brace-format | ||||
| msgid "Continue to {source_name}" | ||||
| msgstr "" | ||||
| msgstr "Continua su {source_name}" | ||||
|  | ||||
| #: authentik/sources/scim/models.py | ||||
| msgid "SCIM Source" | ||||
| @ -2988,8 +3003,8 @@ msgstr "Fasi di configurazione dell'autenticatore email" | ||||
| #: authentik/stages/email/stage.py | ||||
| msgid "Exception occurred while rendering E-mail template" | ||||
| msgstr "" | ||||
| "Eccezione verificatasi durante la visualizzazione del modello di posta " | ||||
| "elettronica" | ||||
| "Si è verificata un'eccezione durante la visualizzazione del modello di posta" | ||||
| " elettronica" | ||||
|  | ||||
| #: authentik/stages/authenticator_email/models.py | ||||
| msgid "Email Device" | ||||
| @ -3028,7 +3043,7 @@ msgid "" | ||||
| "          " | ||||
| msgstr "" | ||||
| "\n" | ||||
| "          Codice MFA via e-mail.\n" | ||||
| "          Codice MFA via email.\n" | ||||
| "          " | ||||
|  | ||||
| #: authentik/stages/authenticator_email/templates/email/email_otp.html | ||||
| @ -3054,7 +3069,7 @@ msgid "" | ||||
| "Email MFA code\n" | ||||
| msgstr "" | ||||
| "\n" | ||||
| "Codice e-mail MFA\n" | ||||
| "Codice email MFA\n" | ||||
|  | ||||
| #: authentik/stages/authenticator_email/templates/email/email_otp.txt | ||||
| #, python-format | ||||
| @ -3321,7 +3336,7 @@ msgstr "Consensi utente" | ||||
|  | ||||
| #: authentik/stages/consent/stage.py | ||||
| msgid "Invalid consent token, re-showing prompt" | ||||
| msgstr "" | ||||
| msgstr "Token di consenso non valido, viene nuovamente visualizzato il prompt" | ||||
|  | ||||
| #: authentik/stages/deny/models.py | ||||
| msgid "Deny Stage" | ||||
| @ -3341,11 +3356,11 @@ msgstr "Fasi fittizie" | ||||
|  | ||||
| #: authentik/stages/email/flow.py | ||||
| msgid "Continue to confirm this email address." | ||||
| msgstr "" | ||||
| msgstr "Continua per confermare questo indirizzo email." | ||||
|  | ||||
| #: authentik/stages/email/flow.py | ||||
| msgid "Link was already used, please request a new link." | ||||
| msgstr "" | ||||
| msgstr "Il collegamento è già stato utilizzato. Richiedine uno nuovo." | ||||
|  | ||||
| #: authentik/stages/email/models.py | ||||
| msgid "Password Reset" | ||||
| @ -3365,7 +3380,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." | ||||
| @ -3467,7 +3482,7 @@ msgid "" | ||||
| "    " | ||||
| msgstr "" | ||||
| "\n" | ||||
| "   Se non hai richiesto una modifica della password, ignora questa e-mail. Il link sopra è valido per %(expires)s.\n" | ||||
| "   Se non hai richiesto una modifica della password, ignora questa email. Il link sopra è valido per %(expires)s.\n" | ||||
| "    " | ||||
|  | ||||
| #: authentik/stages/email/templates/email/password_reset.txt | ||||
| @ -3485,11 +3500,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 e-mail. Il link sopra è valido per %(expires)s.\n" | ||||
| "Se non hai richiesto una modifica della password, ignora questa email. Il link sopra è valido per %(expires)s.\n" | ||||
|  | ||||
| #: authentik/stages/email/templates/email/setup.html | ||||
| msgid "authentik Test-Email" | ||||
| msgstr "e-mail di prova di authentik" | ||||
| msgstr "email di prova di authentik" | ||||
|  | ||||
| #: authentik/stages/email/templates/email/setup.html | ||||
| msgid "" | ||||
| @ -3498,7 +3513,7 @@ msgid "" | ||||
| "                    " | ||||
| msgstr "" | ||||
| "\n" | ||||
| "                    Questa è un'e-mail di prova per informarti che hai configurato correttamente le e-mail di authentik.\n" | ||||
| "                    Questa è un'email di prova per informarti che hai configurato correttamente le email di authentik.\n" | ||||
| "                    " | ||||
|  | ||||
| #: authentik/stages/email/templates/email/setup.txt | ||||
| @ -3507,7 +3522,7 @@ msgid "" | ||||
| "This is a test email to inform you, that you've successfully configured authentik emails.\n" | ||||
| msgstr "" | ||||
| "\n" | ||||
| "Questa è un'e-mail di prova per informarti che hai configurato correttamente le e-mail di authentik.\n" | ||||
| "Questa è un'email di prova per informarti che hai configurato correttamente le email di authentik.\n" | ||||
|  | ||||
| #: authentik/stages/identification/api.py | ||||
| msgid "When no user fields are selected, at least one source must be selected" | ||||
| @ -3710,7 +3725,7 @@ msgstr "" | ||||
|  | ||||
| #: authentik/stages/prompt/models.py | ||||
| msgid "Email: Text field with Email type." | ||||
| msgstr "E-mail: Campo di testo con il tipo di e-mail." | ||||
| msgstr "Email: Campo di testo con il tipo di email." | ||||
|  | ||||
| #: authentik/stages/prompt/models.py | ||||
| msgid "" | ||||
| @ -3865,10 +3880,6 @@ 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-04 00:12+0000\n" | ||||
| "POT-Creation-Date: 2025-06-25 00:10+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,10 +118,6 @@ 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 "无法设置组自身为父级。" | ||||
| @ -775,6 +771,12 @@ 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.
										
									
								
							| @ -14,7 +14,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-06-04 00:12+0000\n" | ||||
| "POT-Creation-Date: 2025-06-25 00:10+0000\n" | ||||
| "PO-Revision-Date: 2022-09-26 16:47+0000\n" | ||||
| "Last-Translator: deluxghost, 2025\n" | ||||
| "Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n" | ||||
| @ -117,10 +117,6 @@ 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 "无法设置组自身为父级。" | ||||
| @ -774,6 +770,12 @@ 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 "通知规则" | ||||
|  | ||||
							
								
								
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,12 +1,12 @@ | ||||
| { | ||||
|     "name": "@goauthentik/authentik", | ||||
|     "version": "2025.6.2", | ||||
|     "version": "2025.6.3", | ||||
|     "lockfileVersion": 3, | ||||
|     "requires": true, | ||||
|     "packages": { | ||||
|         "": { | ||||
|             "name": "@goauthentik/authentik", | ||||
|             "version": "2025.6.2", | ||||
|             "version": "2025.6.3", | ||||
|             "devDependencies": { | ||||
|                 "@trivago/prettier-plugin-sort-imports": "^5.2.2", | ||||
|                 "prettier": "^3.3.3", | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| { | ||||
|     "name": "@goauthentik/authentik", | ||||
|     "version": "2025.6.2", | ||||
|     "version": "2025.6.3", | ||||
|     "private": true, | ||||
|     "type": "module", | ||||
|     "devDependencies": { | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	