Compare commits
	
		
			125 Commits
		
	
	
		
			docs-read-
			...
			reduce-mem
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7d0abbf072 | |||
| 640d0a4a95 | |||
| 6b8782556c | |||
| 7f6f3b6602 | |||
| 3367ac0e08 | |||
| d5ea0ffdc6 | |||
| 93f1638b39 | |||
| 37525175fa | |||
| 0db1e52f90 | |||
| 3e8620b686 | |||
| 6687ffc6d2 | |||
| e265ee253b | |||
| 7763a3673c | |||
| d99005e130 | |||
| c61f96e770 | |||
| 83622dd934 | |||
| 2eebd0eaa1 | |||
| b61d918c5c | |||
| 076a4f4772 | |||
| b3872b35f8 | |||
| f06534cdf0 | |||
| c528a6c336 | |||
| 821f06ffdf | |||
| e83d040a48 | |||
| 9affd90850 | |||
| 80d84cb03f | |||
| a9cc5fdafe | |||
| b45109afce | |||
| c8711d9f8f | |||
| 40a7135c0c | |||
| 675a4a6788 | |||
| 98b5b75f29 | |||
| 22b0a1bd23 | |||
| 1a1d499833 | |||
| 1573cfbaa1 | |||
| b88ce32111 | |||
| a1965ceada | |||
| 9c536a1b4b | |||
| f3e0ff2833 | |||
| 06dc47b582 | |||
| a4bf24a039 | |||
| 1715c3e268 | |||
| feb3be7cee | |||
| db05232f12 | |||
| ebfa7dbcfc | |||
| 8c4dab7399 | |||
| 28d8fcc115 | |||
| c436205e3d | |||
| 3f788e7abe | |||
| b7a1a9c107 | |||
| 559ec290d0 | |||
| 05279514f8 | |||
| 061275d243 | |||
| 30e7f7acbd | |||
| 80ab39675c | |||
| e63f13c9fe | |||
| 08b07aebb9 | |||
| 83219ff2ca | |||
| be3e01912d | |||
| 0e180ebd21 | |||
| 190cb33f8e | |||
| 15061dab6d | |||
| d5a7f0fc3a | |||
| c1525449cf | |||
| f23965a55e | |||
| de5191be6c | |||
| 0bc2d4a7b8 | |||
| 4b5e66f9eb | |||
| 9a5effae2c | |||
| ff504a3b80 | |||
| 587f2d74ac | |||
| c3555c778c | |||
| 1acf48ae1e | |||
| a32d396cec | |||
| deacc17832 | |||
| 96b3e2b3d9 | |||
| ddd3b0557e | |||
| 006766cd3c | |||
| a79cda924b | |||
| 9d0901effd | |||
| 89b6a2a2f2 | |||
| 12d9966604 | |||
| accc88fcdd | |||
| e5dd923333 | |||
| 29f53fd3a4 | |||
| cbe5a0d2c8 | |||
| 81b3662046 | |||
| 12ac058547 | |||
| 69cd0ef565 | |||
| 83edb0dcb8 | |||
| 0c80b1b8c3 | |||
| 47e330d08a | |||
| 97676d28a7 | |||
| b9435870c0 | |||
| 81ae02e623 | |||
| 28a23110c2 | |||
| a117918cd6 | |||
| f1a548f941 | |||
| 021c0f7cb9 | |||
| c09ce06812 | |||
| e0aa588e60 | |||
| e842a73f3b | |||
| 98c43447ff | |||
| eef1237297 | |||
| dea0a34dd9 | |||
| f3359fb4d0 | |||
| d344db9c2a | |||
| 13b1811268 | |||
| 84bc0b6fdd | |||
| 5c2853bf73 | |||
| 242546e621 | |||
| b75672ff0e | |||
| 672ba72c8a | |||
| d618f48bff | |||
| 716f2dff74 | |||
| fc8a0b5ce0 | |||
| 2b079816bb | |||
| eded745332 | |||
| 1740ab938d | |||
| 480f305f29 | |||
| 90cc36fdee | |||
| cf5b951a8c | |||
| 9a9d8e4cf7 | |||
| 695de453ec | |||
| d3a581b614 | 
| @ -1,16 +1,16 @@ | ||||
| [bumpversion] | ||||
| current_version = 2024.10.4 | ||||
| current_version = 2024.12.0 | ||||
| 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*))? | ||||
| serialize =  | ||||
| serialize = | ||||
| 	{major}.{minor}.{patch}-{rc_t}{rc_n} | ||||
| 	{major}.{minor}.{patch} | ||||
| message = release: {new_version} | ||||
| tag_name = version/{new_version} | ||||
|  | ||||
| [bumpversion:part:rc_t] | ||||
| values =  | ||||
| values = | ||||
| 	rc | ||||
| 	final | ||||
| optional_value = final | ||||
|  | ||||
							
								
								
									
										2
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.github/actions/setup/action.yml
									
									
									
									
										vendored
									
									
								
							| @ -35,7 +35,7 @@ runs: | ||||
|       run: | | ||||
|         export PSQL_TAG=${{ inputs.postgresql_version }} | ||||
|         docker compose -f .github/actions/setup/docker-compose.yml up -d | ||||
|         poetry install | ||||
|         poetry install --sync | ||||
|         cd web && npm ci | ||||
|     - name: Generate config | ||||
|       shell: poetry run python {0} | ||||
|  | ||||
							
								
								
									
										5
									
								
								.github/workflows/ci-aws-cfn.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/ci-aws-cfn.yml
									
									
									
									
										vendored
									
									
								
							| @ -36,8 +36,11 @@ jobs: | ||||
|           poetry run make aws-cfn | ||||
|           git diff --exit-code | ||||
|   ci-aws-cfn-mark: | ||||
|     if: always() | ||||
|     needs: | ||||
|       - check-changes-applied | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: echo mark | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|  | ||||
							
								
								
									
										9
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.github/workflows/ci-main.yml
									
									
									
									
										vendored
									
									
								
							| @ -134,7 +134,7 @@ jobs: | ||||
|       - name: Setup authentik env | ||||
|         uses: ./.github/actions/setup | ||||
|       - name: Create k8s Kind Cluster | ||||
|         uses: helm/kind-action@v1.10.0 | ||||
|         uses: helm/kind-action@v1.11.0 | ||||
|       - name: run integration | ||||
|         run: | | ||||
|           poetry run coverage run manage.py test tests/integration | ||||
| @ -209,6 +209,7 @@ jobs: | ||||
|           file: unittest.xml | ||||
|           token: ${{ secrets.CODECOV_TOKEN }} | ||||
|   ci-core-mark: | ||||
|     if: always() | ||||
|     needs: | ||||
|       - lint | ||||
|       - test-migrations | ||||
| @ -218,7 +219,9 @@ jobs: | ||||
|       - test-e2e | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: echo mark | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|   build: | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
| @ -275,7 +278,7 @@ jobs: | ||||
|           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache | ||||
|           cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max' || '' }} | ||||
|           platforms: linux/${{ matrix.arch }} | ||||
|       - uses: actions/attest-build-provenance@v1 | ||||
|       - uses: actions/attest-build-provenance@v2 | ||||
|         id: attest | ||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} | ||||
|         with: | ||||
|  | ||||
							
								
								
									
										7
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/ci-outpost.yml
									
									
									
									
										vendored
									
									
								
							| @ -49,12 +49,15 @@ jobs: | ||||
|         run: | | ||||
|           go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./... | ||||
|   ci-outpost-mark: | ||||
|     if: always() | ||||
|     needs: | ||||
|       - lint-golint | ||||
|       - test-unittest | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: echo mark | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|   build-container: | ||||
|     timeout-minutes: 120 | ||||
|     needs: | ||||
| @ -111,7 +114,7 @@ jobs: | ||||
|           context: . | ||||
|           cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache | ||||
|           cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }} | ||||
|       - uses: actions/attest-build-provenance@v1 | ||||
|       - uses: actions/attest-build-provenance@v2 | ||||
|         id: attest | ||||
|         if: ${{ steps.ev.outputs.shouldPush == 'true' }} | ||||
|         with: | ||||
|  | ||||
							
								
								
									
										5
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -61,12 +61,15 @@ jobs: | ||||
|         working-directory: web/ | ||||
|         run: npm run build | ||||
|   ci-web-mark: | ||||
|     if: always() | ||||
|     needs: | ||||
|       - build | ||||
|       - lint | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: echo mark | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|   test: | ||||
|     needs: | ||||
|       - ci-web-mark | ||||
|  | ||||
							
								
								
									
										5
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.github/workflows/ci-website.yml
									
									
									
									
										vendored
									
									
								
							| @ -62,10 +62,13 @@ jobs: | ||||
|         working-directory: website/ | ||||
|         run: npm run ${{ matrix.job }} | ||||
|   ci-website-mark: | ||||
|     if: always() | ||||
|     needs: | ||||
|       - lint | ||||
|       - test | ||||
|       - build | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - run: echo mark | ||||
|       - uses: re-actors/alls-green@release/v1 | ||||
|         with: | ||||
|           jobs: ${{ toJSON(needs) }} | ||||
|  | ||||
							
								
								
									
										4
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.github/workflows/release-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -55,7 +55,7 @@ jobs: | ||||
|             VERSION=${{ github.ref }} | ||||
|           tags: ${{ steps.ev.outputs.imageTags }} | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|       - uses: actions/attest-build-provenance@v1 | ||||
|       - uses: actions/attest-build-provenance@v2 | ||||
|         id: attest | ||||
|         with: | ||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||
| @ -119,7 +119,7 @@ jobs: | ||||
|           file: ${{ matrix.type }}.Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           context: . | ||||
|       - uses: actions/attest-build-provenance@v1 | ||||
|       - uses: actions/attest-build-provenance@v2 | ||||
|         id: attest | ||||
|         with: | ||||
|           subject-name: ${{ steps.ev.outputs.attestImageNames }} | ||||
|  | ||||
							
								
								
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @ -33,7 +33,8 @@ | ||||
|         "!If sequence", | ||||
|         "!Index scalar", | ||||
|         "!KeyOf scalar", | ||||
|         "!Value scalar" | ||||
|         "!Value scalar", | ||||
|         "!AtIndex scalar" | ||||
|     ], | ||||
|     "typescript.preferences.importModuleSpecifier": "non-relative", | ||||
|     "typescript.preferences.importModuleSpecifierEnding": "index", | ||||
|  | ||||
							
								
								
									
										10
									
								
								CODEOWNERS
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								CODEOWNERS
									
									
									
									
									
								
							| @ -19,10 +19,18 @@ Dockerfile                      @goauthentik/infrastructure | ||||
| *Dockerfile                     @goauthentik/infrastructure | ||||
| .dockerignore                   @goauthentik/infrastructure | ||||
| docker-compose.yml              @goauthentik/infrastructure | ||||
| Makefile                        @goauthentik/infrastructure | ||||
| .editorconfig                   @goauthentik/infrastructure | ||||
| CODEOWNERS                      @goauthentik/infrastructure | ||||
| # Web | ||||
| web/                            @goauthentik/frontend | ||||
| tests/wdio/                     @goauthentik/frontend | ||||
| # Locale | ||||
| locale/                         @goauthentik/backend @goauthentik/frontend | ||||
| web/xliff/                      @goauthentik/backend @goauthentik/frontend | ||||
| # Docs & Website | ||||
| website/                        @goauthentik/docs | ||||
| CODE_OF_CONDUCT.md              @goauthentik/docs | ||||
| # Security | ||||
| website/docs/security/          @goauthentik/security | ||||
| SECURITY.md                     @goauthentik/security @goauthentik/docs | ||||
| website/docs/security/          @goauthentik/security @goauthentik/docs | ||||
|  | ||||
| @ -2,7 +2,7 @@ authentik takes security very seriously. We follow the rules of [responsible di | ||||
|  | ||||
| ## Independent audits and pentests | ||||
|  | ||||
| In May/June of 2023 [Cure53](https://cure53.de) conducted an audit and pentest. The [results](https://cure53.de/pentest-report_authentik.pdf) are published on the [Cure53 website](https://cure53.de/#publications-2023). For more details about authentik's response to the findings of the audit refer to [2023-06 Cure53 Code audit](https://goauthentik.io/docs/security/2023-06-cure53). | ||||
| We are committed to engaging in regular pentesting and security audits of authentik. Defining and adhering to a cadence of external testing ensures a stronger probability that our code base, our features, and our architecture is as secure and non-exploitable as possible. For more details about specfic audits and pentests, refer to "Audits and Certificates" in our [Security documentation](https://docs.goauthentik.io/docs/security). | ||||
|  | ||||
| ## What authentik classifies as a CVE | ||||
|  | ||||
| @ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni | ||||
|  | ||||
| | Version   | Supported | | ||||
| | --------- | --------- | | ||||
| | 2024.8.x  | ✅        | | ||||
| | 2024.10.x | ✅        | | ||||
| | 2024.12.x | ✅        | | ||||
|  | ||||
| ## Reporting a Vulnerability | ||||
|  | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|  | ||||
| from os import environ | ||||
|  | ||||
| __version__ = "2024.10.4" | ||||
| __version__ = "2024.12.0" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -126,7 +126,7 @@ class Command(BaseCommand): | ||||
|         def_name_perm = f"model_{model_path}_permissions" | ||||
|         def_path_perm = f"#/$defs/{def_name_perm}" | ||||
|         self.schema["$defs"][def_name_perm] = self.model_permissions(model) | ||||
|         return { | ||||
|         template = { | ||||
|             "type": "object", | ||||
|             "required": ["model", "identifiers"], | ||||
|             "properties": { | ||||
| @ -143,6 +143,11 @@ class Command(BaseCommand): | ||||
|                 "identifiers": {"$ref": def_path}, | ||||
|             }, | ||||
|         } | ||||
|         # Meta models don't require identifiers, as there's no matching database model to find | ||||
|         if issubclass(model, BaseMetaModel): | ||||
|             del template["properties"]["identifiers"] | ||||
|             template["required"].remove("identifiers") | ||||
|         return template | ||||
|  | ||||
|     def field_to_jsonschema(self, field: Field) -> dict: | ||||
|         """Convert a single field to json schema""" | ||||
|  | ||||
| @ -146,6 +146,10 @@ entries: | ||||
|                   ] | ||||
|               ] | ||||
|               nested_context: !Context context2 | ||||
|               at_index_sequence: !AtIndex [!Context sequence, 0] | ||||
|               at_index_sequence_default: !AtIndex [!Context sequence, 100, "non existent"] | ||||
|               at_index_mapping: !AtIndex [!Context mapping, "key2"] | ||||
|               at_index_mapping_default: !AtIndex [!Context mapping, "invalid", "non existent"] | ||||
|       identifiers: | ||||
|           name: test | ||||
|       conditions: | ||||
|  | ||||
| @ -215,6 +215,10 @@ class TestBlueprintsV1(TransactionTestCase): | ||||
|                     }, | ||||
|                     "nested_context": "context-nested-value", | ||||
|                     "env_null": None, | ||||
|                     "at_index_sequence": "foo", | ||||
|                     "at_index_sequence_default": "non existent", | ||||
|                     "at_index_mapping": 2, | ||||
|                     "at_index_mapping_default": "non existent", | ||||
|                 } | ||||
|             ).exists() | ||||
|         ) | ||||
|  | ||||
| @ -24,6 +24,10 @@ from authentik.lib.sentry import SentryIgnoredException | ||||
| from authentik.policies.models import PolicyBindingModel | ||||
|  | ||||
|  | ||||
| class UNSET: | ||||
|     """Used to test whether a key has not been set.""" | ||||
|  | ||||
|  | ||||
| def get_attrs(obj: SerializerModel) -> dict[str, Any]: | ||||
|     """Get object's attributes via their serializer, and convert it to a normal dict""" | ||||
|     serializer: Serializer = obj.serializer(obj) | ||||
| @ -556,6 +560,53 @@ class Value(EnumeratedItem): | ||||
|             raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc | ||||
|  | ||||
|  | ||||
| class AtIndex(YAMLTag): | ||||
|     """Get value at index of a sequence or mapping""" | ||||
|  | ||||
|     obj: YAMLTag | dict | list | tuple | ||||
|     attribute: int | str | YAMLTag | ||||
|     default: Any | UNSET | ||||
|  | ||||
|     def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None: | ||||
|         super().__init__() | ||||
|         self.obj = loader.construct_object(node.value[0]) | ||||
|         self.attribute = loader.construct_object(node.value[1]) | ||||
|         if len(node.value) == 2:  # noqa: PLR2004 | ||||
|             self.default = UNSET | ||||
|         else: | ||||
|             self.default = loader.construct_object(node.value[2]) | ||||
|  | ||||
|     def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any: | ||||
|         if isinstance(self.obj, YAMLTag): | ||||
|             obj = self.obj.resolve(entry, blueprint) | ||||
|         else: | ||||
|             obj = self.obj | ||||
|         if isinstance(self.attribute, YAMLTag): | ||||
|             attribute = self.attribute.resolve(entry, blueprint) | ||||
|         else: | ||||
|             attribute = self.attribute | ||||
|  | ||||
|         if isinstance(obj, list | tuple): | ||||
|             try: | ||||
|                 return obj[attribute] | ||||
|             except TypeError as exc: | ||||
|                 raise EntryInvalidError.from_entry( | ||||
|                     f"Invalid index for list: {attribute}", entry | ||||
|                 ) from exc | ||||
|             except IndexError as exc: | ||||
|                 if self.default is UNSET: | ||||
|                     raise EntryInvalidError.from_entry( | ||||
|                         f"Index out of range: {attribute}", entry | ||||
|                     ) from exc | ||||
|                 return self.default | ||||
|         if attribute in obj: | ||||
|             return obj[attribute] | ||||
|         else: | ||||
|             if self.default is UNSET: | ||||
|                 raise EntryInvalidError.from_entry(f"Key does not exist: {attribute}", entry) | ||||
|             return self.default | ||||
|  | ||||
|  | ||||
| class BlueprintDumper(SafeDumper): | ||||
|     """Dump dataclasses to yaml""" | ||||
|  | ||||
| @ -606,6 +657,7 @@ class BlueprintLoader(SafeLoader): | ||||
|         self.add_constructor("!Enumerate", Enumerate) | ||||
|         self.add_constructor("!Value", Value) | ||||
|         self.add_constructor("!Index", Index) | ||||
|         self.add_constructor("!AtIndex", AtIndex) | ||||
|  | ||||
|  | ||||
| class EntryInvalidError(SentryIgnoredException): | ||||
|  | ||||
| @ -25,5 +25,7 @@ class BrandMiddleware: | ||||
|             locale = brand.default_locale | ||||
|             if locale != "": | ||||
|                 locale_to_set = locale | ||||
|         with override(locale_to_set): | ||||
|             return self.get_response(request) | ||||
|         if locale_to_set: | ||||
|             with override(locale_to_set): | ||||
|                 return self.get_response(request) | ||||
|         return self.get_response(request) | ||||
|  | ||||
							
								
								
									
										54
									
								
								authentik/core/api/application_entitlements.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								authentik/core/api/application_entitlements.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | ||||
| """Application Roles API Viewset""" | ||||
|  | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from rest_framework.exceptions import ValidationError | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.core.api.utils import ModelSerializer | ||||
| from authentik.core.models import ( | ||||
|     Application, | ||||
|     ApplicationEntitlement, | ||||
|     User, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class ApplicationEntitlementSerializer(ModelSerializer): | ||||
|     """ApplicationEntitlement Serializer""" | ||||
|  | ||||
|     def validate_app(self, app: Application) -> Application: | ||||
|         """Ensure user has permission to view""" | ||||
|         user: User = self._context["request"].user | ||||
|         if user.has_perm("view_application", app) or user.has_perm( | ||||
|             "authentik_core.view_application" | ||||
|         ): | ||||
|             return app | ||||
|         raise ValidationError(_("User does not have access to application."), code="invalid") | ||||
|  | ||||
|     class Meta: | ||||
|         model = ApplicationEntitlement | ||||
|         fields = [ | ||||
|             "pbm_uuid", | ||||
|             "name", | ||||
|             "app", | ||||
|             "attributes", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet): | ||||
|     """ApplicationEntitlement Viewset""" | ||||
|  | ||||
|     queryset = ApplicationEntitlement.objects.all() | ||||
|     serializer_class = ApplicationEntitlementSerializer | ||||
|     search_fields = [ | ||||
|         "pbm_uuid", | ||||
|         "name", | ||||
|         "app", | ||||
|         "attributes", | ||||
|     ] | ||||
|     filterset_fields = [ | ||||
|         "pbm_uuid", | ||||
|         "name", | ||||
|         "app", | ||||
|     ] | ||||
|     ordering = ["name"] | ||||
| @ -159,9 +159,9 @@ class SourceViewSet( | ||||
|  | ||||
|  | ||||
| class UserSourceConnectionSerializer(SourceSerializer): | ||||
|     """OAuth Source Serializer""" | ||||
|     """User source connection""" | ||||
|  | ||||
|     source = SourceSerializer(read_only=True) | ||||
|     source_obj = SourceSerializer(read_only=True, source="source") | ||||
|  | ||||
|     class Meta: | ||||
|         model = UserSourceConnection | ||||
| @ -169,10 +169,10 @@ class UserSourceConnectionSerializer(SourceSerializer): | ||||
|             "pk", | ||||
|             "user", | ||||
|             "source", | ||||
|             "source_obj", | ||||
|             "created", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "user": {"read_only": True}, | ||||
|             "created": {"read_only": True}, | ||||
|         } | ||||
|  | ||||
| @ -197,9 +197,9 @@ class UserSourceConnectionViewSet( | ||||
|  | ||||
|  | ||||
| class GroupSourceConnectionSerializer(SourceSerializer): | ||||
|     """Group Source Connection Serializer""" | ||||
|     """Group Source Connection""" | ||||
|  | ||||
|     source = SourceSerializer(read_only=True) | ||||
|     source_obj = SourceSerializer(read_only=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = GroupSourceConnection | ||||
| @ -207,12 +207,11 @@ class GroupSourceConnectionSerializer(SourceSerializer): | ||||
|             "pk", | ||||
|             "group", | ||||
|             "source", | ||||
|             "source_obj", | ||||
|             "identifier", | ||||
|             "created", | ||||
|         ] | ||||
|         extra_kwargs = { | ||||
|             "group": {"read_only": True}, | ||||
|             "identifier": {"read_only": True}, | ||||
|             "created": {"read_only": True}, | ||||
|         } | ||||
|  | ||||
|  | ||||
| @ -22,7 +22,7 @@ from authentik.blueprints.v1.common import ( | ||||
| from authentik.blueprints.v1.importer import Importer | ||||
| from authentik.core.api.applications import ApplicationSerializer | ||||
| from authentik.core.api.utils import PassiveSerializer | ||||
| from authentik.core.models import Provider | ||||
| from authentik.core.models import Application, Provider | ||||
| from authentik.lib.utils.reflection import all_subclasses | ||||
| from authentik.policies.api.bindings import PolicyBindingSerializer | ||||
|  | ||||
| @ -51,6 +51,13 @@ class TransactionProviderField(DictField): | ||||
| class TransactionPolicyBindingSerializer(PolicyBindingSerializer): | ||||
|     """PolicyBindingSerializer which does not require target as target is set implicitly""" | ||||
|  | ||||
|     def validate(self, attrs): | ||||
|         # As the PolicyBindingSerializer checks that the correct things can be bound to a target | ||||
|         # but we don't have a target here as that's set by the blueprint, pass in an empty app | ||||
|         # which will have the correct allowed combination of group/user/policy. | ||||
|         attrs["target"] = Application() | ||||
|         return super().validate(attrs) | ||||
|  | ||||
|     class Meta(PolicyBindingSerializer.Meta): | ||||
|         fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"] | ||||
|  | ||||
|  | ||||
| @ -42,8 +42,10 @@ class ImpersonateMiddleware: | ||||
|             # Ensure that the user is active, otherwise nothing will work | ||||
|             request.user.is_active = True | ||||
|  | ||||
|         with override(locale_to_set): | ||||
|             return self.get_response(request) | ||||
|         if locale_to_set: | ||||
|             with override(locale_to_set): | ||||
|                 return self.get_response(request) | ||||
|         return self.get_response(request) | ||||
|  | ||||
|  | ||||
| class RequestIDMiddleware: | ||||
|  | ||||
							
								
								
									
										45
									
								
								authentik/core/migrations/0041_applicationentitlement.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								authentik/core/migrations/0041_applicationentitlement.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,45 @@ | ||||
| # Generated by Django 5.0.9 on 2024-11-20 15:16 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0040_provider_invalidation_flow"), | ||||
|         ("authentik_policies", "0011_policybinding_failure_result_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="ApplicationEntitlement", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "policybindingmodel_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         to="authentik_policies.policybindingmodel", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("attributes", models.JSONField(blank=True, default=dict)), | ||||
|                 ("name", models.TextField()), | ||||
|                 ( | ||||
|                     "app", | ||||
|                     models.ForeignKey( | ||||
|                         on_delete=django.db.models.deletion.CASCADE, to="authentik_core.application" | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Application Entitlement", | ||||
|                 "verbose_name_plural": "Application Entitlements", | ||||
|                 "unique_together": {("app", "name")}, | ||||
|             }, | ||||
|             bases=("authentik_policies.policybindingmodel", models.Model), | ||||
|         ), | ||||
|     ] | ||||
| @ -314,6 +314,32 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser): | ||||
|         always_merger.merge(final_attributes, self.attributes) | ||||
|         return final_attributes | ||||
|  | ||||
|     def app_entitlements(self, app: "Application | None") -> QuerySet["ApplicationEntitlement"]: | ||||
|         """Get all entitlements this user has for `app`.""" | ||||
|         if not app: | ||||
|             return [] | ||||
|         all_groups = self.all_groups() | ||||
|         qs = app.applicationentitlement_set.filter( | ||||
|             Q( | ||||
|                 Q(bindings__user=self) | Q(bindings__group__in=all_groups), | ||||
|                 bindings__negate=False, | ||||
|             ) | ||||
|             | Q( | ||||
|                 Q(~Q(bindings__user=self), bindings__user__isnull=False) | ||||
|                 | Q(~Q(bindings__group__in=all_groups), bindings__group__isnull=False), | ||||
|                 bindings__negate=True, | ||||
|             ), | ||||
|             bindings__enabled=True, | ||||
|         ).order_by("name") | ||||
|         return qs | ||||
|  | ||||
|     def app_entitlements_attributes(self, app: "Application | None") -> dict: | ||||
|         """Get a dictionary containing all merged attributes from app entitlements for `app`.""" | ||||
|         final_attributes = {} | ||||
|         for attrs in self.app_entitlements(app).values_list("attributes", flat=True): | ||||
|             always_merger.merge(final_attributes, attrs) | ||||
|         return final_attributes | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> Serializer: | ||||
|         from authentik.core.api.users import UserSerializer | ||||
| @ -581,6 +607,31 @@ class Application(SerializerModel, PolicyBindingModel): | ||||
|         verbose_name_plural = _("Applications") | ||||
|  | ||||
|  | ||||
| class ApplicationEntitlement(AttributesMixin, SerializerModel, PolicyBindingModel): | ||||
|     """Application-scoped entitlement to control authorization in an application""" | ||||
|  | ||||
|     name = models.TextField() | ||||
|  | ||||
|     app = models.ForeignKey(Application, on_delete=models.CASCADE) | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Application Entitlement") | ||||
|         verbose_name_plural = _("Application Entitlements") | ||||
|         unique_together = (("app", "name"),) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return f"Application Entitlement {self.name} for app {self.app_id}" | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[Serializer]: | ||||
|         from authentik.core.api.application_entitlements import ApplicationEntitlementSerializer | ||||
|  | ||||
|         return ApplicationEntitlementSerializer | ||||
|  | ||||
|     def supported_policy_binding_targets(self): | ||||
|         return ["group", "user"] | ||||
|  | ||||
|  | ||||
| class SourceUserMatchingModes(models.TextChoices): | ||||
|     """Different modes a source can handle new/returning users""" | ||||
|  | ||||
|  | ||||
| @ -238,13 +238,7 @@ class SourceFlowManager: | ||||
|                 self.request.GET, | ||||
|                 flow_slug=flow_slug, | ||||
|             ) | ||||
|         # Ensure redirect is carried through when user was trying to | ||||
|         # authorize application | ||||
|         final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( | ||||
|             NEXT_ARG_NAME, "authentik_core:if-user" | ||||
|         ) | ||||
|         if PLAN_CONTEXT_REDIRECT not in flow_context: | ||||
|             flow_context[PLAN_CONTEXT_REDIRECT] = final_redirect | ||||
|         flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect) | ||||
|  | ||||
|         if not flow: | ||||
|             return bad_request_message( | ||||
| @ -265,12 +259,7 @@ class SourceFlowManager: | ||||
|         if stages: | ||||
|             for stage in stages: | ||||
|                 plan.append_stage(stage) | ||||
|         self.request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=flow.slug, | ||||
|         ) | ||||
|         return plan.to_redirect(self.request, flow) | ||||
|  | ||||
|     def handle_auth( | ||||
|         self, | ||||
|  | ||||
| @ -18,6 +18,7 @@ from authentik.core.models import ( | ||||
| ) | ||||
| from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.db import qs_batch_iter | ||||
| from authentik.root.celery import CELERY_APP | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| @ -34,14 +35,14 @@ def clean_expired_models(self: SystemTask): | ||||
|             cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now()) | ||||
|         ) | ||||
|         amount = objects.count() | ||||
|         for obj in objects: | ||||
|         for obj in qs_batch_iter(objects): | ||||
|             obj.expire_action() | ||||
|         LOGGER.debug("Expired models", model=cls, amount=amount) | ||||
|         messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}") | ||||
|     # Special case | ||||
|     amount = 0 | ||||
|  | ||||
|     for session in AuthenticatedSession.objects.all(): | ||||
|     for session in qs_batch_iter(AuthenticatedSession.objects.all()): | ||||
|         match CONFIG.get("session_storage", "cache"): | ||||
|             case "cache": | ||||
|                 cache_key = f"{KEY_PREFIX}{session.session_key}" | ||||
|  | ||||
							
								
								
									
										153
									
								
								authentik/core/tests/test_application_entitlements.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										153
									
								
								authentik/core/tests/test_application_entitlements.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,153 @@ | ||||
| """Test Application Entitlements API""" | ||||
|  | ||||
| from django.urls import reverse | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework.test import APITestCase | ||||
|  | ||||
| from authentik.core.models import Application, ApplicationEntitlement, Group | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.policies.dummy.models import DummyPolicy | ||||
| from authentik.policies.models import PolicyBinding | ||||
| from authentik.providers.oauth2.models import OAuth2Provider | ||||
|  | ||||
|  | ||||
| class TestApplicationEntitlements(APITestCase): | ||||
|     """Test application entitlements""" | ||||
|  | ||||
|     def setUp(self) -> None: | ||||
|         self.user = create_test_user() | ||||
|         self.other_user = create_test_user() | ||||
|         self.provider = OAuth2Provider.objects.create( | ||||
|             name="test", | ||||
|             authorization_flow=create_test_flow(), | ||||
|         ) | ||||
|         self.app: Application = Application.objects.create( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
|             provider=self.provider, | ||||
|         ) | ||||
|  | ||||
|     def test_user(self): | ||||
|         """Test user-direct assignment""" | ||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) | ||||
|         PolicyBinding.objects.create(target=ent, user=self.user, order=0) | ||||
|         ents = self.user.app_entitlements(self.app) | ||||
|         self.assertEqual(len(ents), 1) | ||||
|         self.assertEqual(ents[0].name, ent.name) | ||||
|  | ||||
|     def test_group(self): | ||||
|         """Test direct group""" | ||||
|         group = Group.objects.create(name=generate_id()) | ||||
|         self.user.ak_groups.add(group) | ||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) | ||||
|         PolicyBinding.objects.create(target=ent, group=group, order=0) | ||||
|         ents = self.user.app_entitlements(self.app) | ||||
|         self.assertEqual(len(ents), 1) | ||||
|         self.assertEqual(ents[0].name, ent.name) | ||||
|  | ||||
|     def test_group_indirect(self): | ||||
|         """Test indirect group""" | ||||
|         parent = Group.objects.create(name=generate_id()) | ||||
|         group = Group.objects.create(name=generate_id(), parent=parent) | ||||
|         self.user.ak_groups.add(group) | ||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) | ||||
|         PolicyBinding.objects.create(target=ent, group=parent, order=0) | ||||
|         ents = self.user.app_entitlements(self.app) | ||||
|         self.assertEqual(len(ents), 1) | ||||
|         self.assertEqual(ents[0].name, ent.name) | ||||
|  | ||||
|     def test_negate_user(self): | ||||
|         """Test with negate flag""" | ||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) | ||||
|         PolicyBinding.objects.create(target=ent, user=self.other_user, order=0, negate=True) | ||||
|         ents = self.user.app_entitlements(self.app) | ||||
|         self.assertEqual(len(ents), 1) | ||||
|         self.assertEqual(ents[0].name, ent.name) | ||||
|  | ||||
|     def test_negate_group(self): | ||||
|         """Test with negate flag""" | ||||
|         other_group = Group.objects.create(name=generate_id()) | ||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) | ||||
|         PolicyBinding.objects.create(target=ent, group=other_group, order=0, negate=True) | ||||
|         ents = self.user.app_entitlements(self.app) | ||||
|         self.assertEqual(len(ents), 1) | ||||
|         self.assertEqual(ents[0].name, ent.name) | ||||
|  | ||||
|     def test_api_perms_global(self): | ||||
|         """Test API creation with global permissions""" | ||||
|         assign_perm("authentik_core.add_applicationentitlement", self.user) | ||||
|         assign_perm("authentik_core.view_application", self.user) | ||||
|         self.client.force_login(self.user) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_api:applicationentitlement-list"), | ||||
|             data={ | ||||
|                 "name": generate_id(), | ||||
|                 "app": self.app.pk, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 201) | ||||
|  | ||||
|     def test_api_perms_scoped(self): | ||||
|         """Test API creation with scoped permissions""" | ||||
|         assign_perm("authentik_core.add_applicationentitlement", self.user) | ||||
|         assign_perm("authentik_core.view_application", self.user, self.app) | ||||
|         self.client.force_login(self.user) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_api:applicationentitlement-list"), | ||||
|             data={ | ||||
|                 "name": generate_id(), | ||||
|                 "app": self.app.pk, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 201) | ||||
|  | ||||
|     def test_api_perms_missing(self): | ||||
|         """Test API creation with no permissions""" | ||||
|         assign_perm("authentik_core.add_applicationentitlement", self.user) | ||||
|         self.client.force_login(self.user) | ||||
|         res = self.client.post( | ||||
|             reverse("authentik_api:applicationentitlement-list"), | ||||
|             data={ | ||||
|                 "name": generate_id(), | ||||
|                 "app": self.app.pk, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(res.status_code, 400) | ||||
|         self.assertJSONEqual(res.content, {"app": ["User does not have access to application."]}) | ||||
|  | ||||
|     def test_api_bindings_policy(self): | ||||
|         """Test that API doesn't allow policies to be bound to this""" | ||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) | ||||
|         policy = DummyPolicy.objects.create(name=generate_id()) | ||||
|         admin = create_test_admin_user() | ||||
|         self.client.force_login(admin) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:policybinding-list"), | ||||
|             data={ | ||||
|                 "target": ent.pbm_uuid, | ||||
|                 "policy": policy.pk, | ||||
|                 "order": 0, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             {"non_field_errors": ["One of 'group', 'user' must be set."]}, | ||||
|         ) | ||||
|  | ||||
|     def test_api_bindings_group(self): | ||||
|         """Test that API doesn't allow policies to be bound to this""" | ||||
|         ent = ApplicationEntitlement.objects.create(app=self.app, name=generate_id()) | ||||
|         group = Group.objects.create(name=generate_id()) | ||||
|         admin = create_test_admin_user() | ||||
|         self.client.force_login(admin) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_api:policybinding-list"), | ||||
|             data={ | ||||
|                 "target": ent.pbm_uuid, | ||||
|                 "group": group.pk, | ||||
|                 "order": 0, | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 201) | ||||
|         self.assertTrue(PolicyBinding.objects.filter(target=ent.pbm_uuid).exists()) | ||||
| @ -6,6 +6,7 @@ from django.conf import settings | ||||
| from django.contrib.auth.decorators import login_required | ||||
| from django.urls import path | ||||
|  | ||||
| from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet | ||||
| from authentik.core.api.applications import ApplicationViewSet | ||||
| from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet | ||||
| from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet | ||||
| @ -69,6 +70,7 @@ urlpatterns = [ | ||||
| api_urlpatterns = [ | ||||
|     ("core/authenticated_sessions", AuthenticatedSessionViewSet), | ||||
|     ("core/applications", ApplicationViewSet), | ||||
|     ("core/application_entitlements", ApplicationEntitlementViewSet), | ||||
|     path( | ||||
|         "core/transactional/applications/", | ||||
|         TransactionalApplicationView.as_view(), | ||||
|  | ||||
| @ -17,10 +17,8 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.views.executor import ( | ||||
|     SESSION_KEY_APPLICATION_PRE, | ||||
|     SESSION_KEY_PLAN, | ||||
|     ToDefaultFlow, | ||||
| ) | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.stages.consent.stage import ( | ||||
|     PLAN_CONTEXT_CONSENT_HEADER, | ||||
|     PLAN_CONTEXT_CONSENT_PERMISSIONS, | ||||
| @ -58,8 +56,7 @@ class RedirectToAppLaunch(View): | ||||
|         except FlowNonApplicableException: | ||||
|             raise Http404 from None | ||||
|         plan.insert_stage(in_memory_stage(RedirectToAppStage)) | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug) | ||||
|         return plan.to_redirect(request, flow) | ||||
|  | ||||
|  | ||||
| class RedirectToAppStage(ChallengeStageView): | ||||
|  | ||||
| @ -6,6 +6,7 @@ from django.http import HttpRequest, HttpResponse, JsonResponse | ||||
| from django.urls import resolve | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
|  | ||||
| from authentik.core.api.users import UserViewSet | ||||
| from authentik.enterprise.api import LicenseViewSet | ||||
| from authentik.enterprise.license import LicenseKey | ||||
| from authentik.enterprise.models import LicenseUsageStatus | ||||
| @ -59,6 +60,9 @@ class EnterpriseMiddleware: | ||||
|         # Flow executor is mounted as an API path but explicitly allowed | ||||
|         if request.resolver_match._func_path == class_to_path(FlowExecutorView): | ||||
|             return True | ||||
|         # Always allow making changes to users, even in case the license has ben exceeded | ||||
|         if request.resolver_match._func_path == class_to_path(UserViewSet): | ||||
|             return True | ||||
|         # Only apply these restrictions to the API | ||||
|         if "authentik_api" not in request.resolver_match.app_names: | ||||
|             return True | ||||
|  | ||||
| @ -18,9 +18,7 @@ from authentik.flows.exceptions import FlowNonApplicableException | ||||
| from authentik.flows.models import in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| from authentik.flows.stage import RedirectStage | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.policies.engine import PolicyEngine | ||||
|  | ||||
|  | ||||
| @ -56,12 +54,7 @@ class RACStartView(EnterprisePolicyAccessView): | ||||
|                 provider=self.provider, | ||||
|             ) | ||||
|         ) | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             request.GET, | ||||
|             flow_slug=self.provider.authorization_flow.slug, | ||||
|         ) | ||||
|         return plan.to_redirect(request, self.provider.authorization_flow) | ||||
|  | ||||
|  | ||||
| class RACInterface(InterfaceView): | ||||
|  | ||||
| @ -4,7 +4,9 @@ from typing import Any | ||||
| from django.http import HttpRequest, HttpResponse, HttpResponseRedirect | ||||
| from django.template.response import TemplateResponse | ||||
| from django.urls import reverse | ||||
| from django.utils.decorators import method_decorator | ||||
| from django.views import View | ||||
| from django.views.decorators.clickjacking import xframe_options_sameorigin | ||||
| from googleapiclient.discovery import build | ||||
|  | ||||
| from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import ( | ||||
| @ -26,6 +28,7 @@ HEADER_ACCESS_CHALLENGE_RESPONSE = "X-Verified-Access-Challenge-Response" | ||||
| DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess" | ||||
|  | ||||
|  | ||||
| @method_decorator(xframe_options_sameorigin, name="dispatch") | ||||
| class GoogleChromeDeviceTrustConnector(View): | ||||
|     """Google Chrome Device-trust connector based endpoint authenticator""" | ||||
|  | ||||
|  | ||||
| @ -215,3 +215,49 @@ class TestReadOnly(FlowTestCase): | ||||
|             {"detail": "Request denied due to expired/invalid license.", "code": "denied_license"}, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 400) | ||||
|  | ||||
|     @patch( | ||||
|         "authentik.enterprise.license.LicenseKey.validate", | ||||
|         MagicMock( | ||||
|             return_value=LicenseKey( | ||||
|                 aud="", | ||||
|                 exp=expiry_valid, | ||||
|                 name=generate_id(), | ||||
|                 internal_users=100, | ||||
|                 external_users=100, | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|     @patch( | ||||
|         "authentik.enterprise.license.LicenseKey.get_internal_user_count", | ||||
|         MagicMock(return_value=1000), | ||||
|     ) | ||||
|     @patch( | ||||
|         "authentik.enterprise.license.LicenseKey.get_external_user_count", | ||||
|         MagicMock(return_value=1000), | ||||
|     ) | ||||
|     @patch( | ||||
|         "authentik.enterprise.license.LicenseKey.record_usage", | ||||
|         MagicMock(), | ||||
|     ) | ||||
|     def test_manage_users(self): | ||||
|         """Test that managing users is still possible""" | ||||
|         License.objects.create(key=generate_id()) | ||||
|         usage = LicenseUsage.objects.create( | ||||
|             internal_user_count=100, | ||||
|             external_user_count=100, | ||||
|             status=LicenseUsageStatus.VALID, | ||||
|         ) | ||||
|         usage.record_date = now() - timedelta(weeks=THRESHOLD_READ_ONLY_WEEKS + 1) | ||||
|         usage.save(update_fields=["record_date"]) | ||||
|  | ||||
|         admin = create_test_admin_user() | ||||
|         self.client.force_login(admin) | ||||
|  | ||||
|         # Reading is always allowed | ||||
|         response = self.client.get(reverse("authentik_api:user-list")) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
|         # Writing should also be allowed | ||||
|         response = self.client.patch(reverse("authentik_api:user-detail", kwargs={"pk": admin.pk})) | ||||
|         self.assertEqual(response.status_code, 200) | ||||
|  | ||||
| @ -15,6 +15,7 @@ from authentik.events.models import ( | ||||
|     TaskStatus, | ||||
| ) | ||||
| from authentik.events.system_tasks import SystemTask, prefill_task | ||||
| from authentik.lib.utils.db import qs_batch_iter | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.policies.models import PolicyBinding, PolicyEngineMode | ||||
| from authentik.root.celery import CELERY_APP | ||||
| @ -129,7 +130,8 @@ def gdpr_cleanup(user_pk: int): | ||||
|     """cleanup events from gdpr_compliance""" | ||||
|     events = Event.objects.filter(user__pk=user_pk) | ||||
|     LOGGER.debug("GDPR cleanup, removing events from user", events=events.count()) | ||||
|     events.delete() | ||||
|     for event in qs_batch_iter(events): | ||||
|         event.delete() | ||||
|  | ||||
|  | ||||
| @CELERY_APP.task(bind=True, base=SystemTask) | ||||
| @ -138,7 +140,7 @@ def notification_cleanup(self: SystemTask): | ||||
|     """Cleanup seen notifications and notifications whose event expired.""" | ||||
|     notifications = Notification.objects.filter(Q(event=None) | Q(seen=True)) | ||||
|     amount = notifications.count() | ||||
|     for notification in notifications: | ||||
|     for notification in qs_batch_iter(notifications): | ||||
|         notification.delete() | ||||
|     LOGGER.debug("Expired notifications", amount=amount) | ||||
|     self.set_status(TaskStatus.SUCCESSFUL, f"Expired {amount} Notifications") | ||||
|  | ||||
| @ -40,6 +40,7 @@ class Migration(migrations.Migration): | ||||
|                     ("require_authenticated", "Require Authenticated"), | ||||
|                     ("require_unauthenticated", "Require Unauthenticated"), | ||||
|                     ("require_superuser", "Require Superuser"), | ||||
|                     ("require_redirect", "Require Redirect"), | ||||
|                     ("require_outpost", "Require Outpost"), | ||||
|                 ], | ||||
|                 default="none", | ||||
|  | ||||
| @ -33,6 +33,7 @@ class FlowAuthenticationRequirement(models.TextChoices): | ||||
|     REQUIRE_AUTHENTICATED = "require_authenticated" | ||||
|     REQUIRE_UNAUTHENTICATED = "require_unauthenticated" | ||||
|     REQUIRE_SUPERUSER = "require_superuser" | ||||
|     REQUIRE_REDIRECT = "require_redirect" | ||||
|     REQUIRE_OUTPOST = "require_outpost" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,10 +1,10 @@ | ||||
| """Flows Planner""" | ||||
|  | ||||
| from dataclasses import dataclass, field | ||||
| from typing import Any | ||||
| from typing import TYPE_CHECKING, Any | ||||
|  | ||||
| from django.core.cache import cache | ||||
| from django.http import HttpRequest | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from sentry_sdk import start_span | ||||
| from sentry_sdk.tracing import Span | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
| @ -23,10 +23,15 @@ from authentik.flows.models import ( | ||||
|     in_memory_stage, | ||||
| ) | ||||
| from authentik.lib.config import CONFIG | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.outposts.models import Outpost | ||||
| from authentik.policies.engine import PolicyEngine | ||||
| from authentik.root.middleware import ClientIPMiddleware | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from authentik.flows.stage import StageView | ||||
|  | ||||
|  | ||||
| LOGGER = get_logger() | ||||
| PLAN_CONTEXT_PENDING_USER = "pending_user" | ||||
| PLAN_CONTEXT_SSO = "is_sso" | ||||
| @ -37,6 +42,8 @@ PLAN_CONTEXT_OUTPOST = "outpost" | ||||
| # Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan | ||||
| # was restored. | ||||
| PLAN_CONTEXT_IS_RESTORED = "is_restored" | ||||
| PLAN_CONTEXT_IS_REDIRECTED = "is_redirected" | ||||
| PLAN_CONTEXT_REDIRECT_STAGE_TARGET = "redirect_stage_target" | ||||
| CACHE_TIMEOUT = CONFIG.get_int("cache.timeout_flows") | ||||
| CACHE_PREFIX = "goauthentik.io/flows/planner/" | ||||
|  | ||||
| @ -110,6 +117,54 @@ class FlowPlan: | ||||
|         """Check if there are any stages left in this plan""" | ||||
|         return len(self.markers) + len(self.bindings) > 0 | ||||
|  | ||||
|     def requires_flow_executor( | ||||
|         self, | ||||
|         allowed_silent_types: list["StageView"] | None = None, | ||||
|     ): | ||||
|         # Check if we actually need to show the Flow executor, or if we can jump straight to the end | ||||
|         found_unskippable = True | ||||
|         if allowed_silent_types: | ||||
|             LOGGER.debug("Checking if we can skip the flow executor...") | ||||
|             # Policies applied to the flow have already been evaluated, so we're checking for stages | ||||
|             # allow-listed or bindings that require a policy re-eval | ||||
|             found_unskippable = False | ||||
|             for binding, marker in zip(self.bindings, self.markers, strict=True): | ||||
|                 if binding.stage.view not in allowed_silent_types: | ||||
|                     found_unskippable = True | ||||
|                 if marker and isinstance(marker, ReevaluateMarker): | ||||
|                     found_unskippable = True | ||||
|         LOGGER.debug("Required flow executor status", status=found_unskippable) | ||||
|         return found_unskippable | ||||
|  | ||||
|     def to_redirect( | ||||
|         self, | ||||
|         request: HttpRequest, | ||||
|         flow: Flow, | ||||
|         allowed_silent_types: list["StageView"] | None = None, | ||||
|     ) -> HttpResponse: | ||||
|         """Redirect to the flow executor for this flow plan""" | ||||
|         from authentik.flows.views.executor import ( | ||||
|             SESSION_KEY_PLAN, | ||||
|             FlowExecutorView, | ||||
|         ) | ||||
|  | ||||
|         request.session[SESSION_KEY_PLAN] = self | ||||
|         requires_flow_executor = self.requires_flow_executor(allowed_silent_types) | ||||
|  | ||||
|         if not requires_flow_executor: | ||||
|             # No unskippable stages found, so we can directly return the response of the last stage | ||||
|             final_stage: type[StageView] = self.bindings[-1].stage.view | ||||
|             temp_exec = FlowExecutorView(flow=flow, request=request, plan=self) | ||||
|             temp_exec.current_stage = self.bindings[-1].stage | ||||
|             stage = final_stage(request=request, executor=temp_exec) | ||||
|             return stage.dispatch(request) | ||||
|  | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             request.GET, | ||||
|             flow_slug=flow.slug, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class FlowPlanner: | ||||
|     """Execute all policies to plan out a flat list of all Stages | ||||
| @ -128,7 +183,7 @@ class FlowPlanner: | ||||
|         self.flow = flow | ||||
|         self._logger = get_logger().bind(flow_slug=flow.slug) | ||||
|  | ||||
|     def _check_authentication(self, request: HttpRequest): | ||||
|     def _check_authentication(self, request: HttpRequest, context: dict[str, Any]): | ||||
|         """Check the flow's authentication level is matched by `request`""" | ||||
|         if ( | ||||
|             self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED | ||||
| @ -145,6 +200,11 @@ class FlowPlanner: | ||||
|             and not request.user.is_superuser | ||||
|         ): | ||||
|             raise FlowNonApplicableException() | ||||
|         if ( | ||||
|             self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_REDIRECT | ||||
|             and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None | ||||
|         ): | ||||
|             raise FlowNonApplicableException() | ||||
|         outpost_user = ClientIPMiddleware.get_outpost_user(request) | ||||
|         if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST: | ||||
|             if not outpost_user: | ||||
| @ -176,18 +236,13 @@ class FlowPlanner: | ||||
|             ) | ||||
|             context = default_context or {} | ||||
|             # Bit of a workaround here, if there is a pending user set in the default context | ||||
|             # we use that user for our cache key | ||||
|             # to make sure they don't get the generic response | ||||
|             # we use that user for our cache key to make sure they don't get the generic response | ||||
|             if context and PLAN_CONTEXT_PENDING_USER in context: | ||||
|                 user = context[PLAN_CONTEXT_PENDING_USER] | ||||
|             else: | ||||
|                 user = request.user | ||||
|                 # We only need to check the flow authentication if it's planned without a user | ||||
|                 # in the context, as a user in the context can only be set via the explicit code API | ||||
|                 # or if a flow is restarted due to `invalid_response_action` being set to | ||||
|                 # `restart_with_context`, which can only happen if the user was already authorized | ||||
|                 # to use the flow | ||||
|                 context.update(self._check_authentication(request)) | ||||
|  | ||||
|             context.update(self._check_authentication(request, context)) | ||||
|             # First off, check the flow's direct policy bindings | ||||
|             # to make sure the user even has access to the flow | ||||
|             engine = PolicyEngine(self.flow, user, request) | ||||
|  | ||||
| @ -2,6 +2,7 @@ | ||||
|  | ||||
| from typing import TYPE_CHECKING | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.auth.models import AnonymousUser | ||||
| from django.http import HttpRequest | ||||
| from django.http.request import QueryDict | ||||
| @ -92,7 +93,11 @@ class ChallengeStageView(StageView): | ||||
|  | ||||
|     def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: | ||||
|         """Return a challenge for the frontend to solve""" | ||||
|         challenge = self._get_challenge(*args, **kwargs) | ||||
|         try: | ||||
|             challenge = self._get_challenge(*args, **kwargs) | ||||
|         except StageInvalidException as exc: | ||||
|             self.logger.debug("Got StageInvalidException", exc=exc) | ||||
|             return self.executor.stage_invalid() | ||||
|         if not challenge.is_valid(): | ||||
|             self.logger.warning( | ||||
|                 "f(ch): Invalid challenge", | ||||
| @ -168,11 +173,7 @@ class ChallengeStageView(StageView): | ||||
|                 stage_type=self.__class__.__name__, method="get_challenge" | ||||
|             ).time(), | ||||
|         ): | ||||
|             try: | ||||
|                 challenge = self.get_challenge(*args, **kwargs) | ||||
|             except StageInvalidException as exc: | ||||
|                 self.logger.debug("Got StageInvalidException", exc=exc) | ||||
|                 return self.executor.stage_invalid() | ||||
|             challenge = self.get_challenge(*args, **kwargs) | ||||
|         with start_span( | ||||
|             op="authentik.flow.stage._get_challenge", | ||||
|             name=self.__class__.__name__, | ||||
| @ -224,6 +225,14 @@ class ChallengeStageView(StageView): | ||||
|                 full_errors[field].append(field_error) | ||||
|         challenge_response.initial_data["response_errors"] = full_errors | ||||
|         if not challenge_response.is_valid(): | ||||
|             if settings.TEST: | ||||
|                 raise StageInvalidException( | ||||
|                     ( | ||||
|                         f"Invalid challenge response: \n\t{challenge_response.errors}" | ||||
|                         f"\n\nValidated data:\n\t {challenge_response.data}" | ||||
|                         f"\n\nInitial data:\n\t {challenge_response.initial_data}" | ||||
|                     ), | ||||
|                 ) | ||||
|             self.logger.error( | ||||
|                 "f(ch): invalid challenge response", | ||||
|                 errors=challenge_response.errors, | ||||
|  | ||||
| @ -5,6 +5,8 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch | ||||
| from django.contrib.auth.models import AnonymousUser | ||||
| from django.contrib.sessions.middleware import SessionMiddleware | ||||
| from django.core.cache import cache | ||||
| from django.http import HttpRequest | ||||
| from django.shortcuts import redirect | ||||
| from django.test import RequestFactory, TestCase | ||||
| from django.urls import reverse | ||||
| from guardian.shortcuts import get_anonymous_user | ||||
| @ -14,8 +16,19 @@ from authentik.core.models import User | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||
| from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException | ||||
| from authentik.flows.markers import ReevaluateMarker, StageMarker | ||||
| from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation, FlowStageBinding | ||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key | ||||
| from authentik.flows.models import ( | ||||
|     FlowAuthenticationRequirement, | ||||
|     FlowDesignation, | ||||
|     FlowStageBinding, | ||||
|     in_memory_stage, | ||||
| ) | ||||
| from authentik.flows.planner import ( | ||||
|     PLAN_CONTEXT_IS_REDIRECTED, | ||||
|     PLAN_CONTEXT_PENDING_USER, | ||||
|     FlowPlanner, | ||||
|     cache_key, | ||||
| ) | ||||
| from authentik.flows.stage import StageView | ||||
| from authentik.lib.tests.utils import dummy_get_response | ||||
| from authentik.outposts.apps import MANAGED_OUTPOST | ||||
| from authentik.outposts.models import Outpost | ||||
| @ -73,6 +86,24 @@ class TestFlowPlanner(TestCase): | ||||
|         planner.allow_empty_flows = True | ||||
|         planner.plan(request) | ||||
|  | ||||
|     def test_authentication_redirect_required(self): | ||||
|         """Test flow authentication (redirect required)""" | ||||
|         flow = create_test_flow() | ||||
|         flow.authentication = FlowAuthenticationRequirement.REQUIRE_REDIRECT | ||||
|         request = self.request_factory.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         request.user = AnonymousUser() | ||||
|         planner = FlowPlanner(flow) | ||||
|         planner.allow_empty_flows = True | ||||
|  | ||||
|         with self.assertRaises(FlowNonApplicableException): | ||||
|             planner.plan(request) | ||||
|  | ||||
|         context = {} | ||||
|         context[PLAN_CONTEXT_IS_REDIRECTED] = create_test_flow() | ||||
|         planner.plan(request, context) | ||||
|  | ||||
|     @reconcile_app("authentik_outposts") | ||||
|     def test_authentication_outpost(self): | ||||
|         """Test flow authentication (outpost)""" | ||||
| @ -211,3 +242,99 @@ class TestFlowPlanner(TestCase): | ||||
|  | ||||
|             self.assertIsInstance(plan.markers[0], StageMarker) | ||||
|             self.assertIsInstance(plan.markers[1], ReevaluateMarker) | ||||
|  | ||||
|     def test_to_redirect(self): | ||||
|         """Test to_redirect and skipping the flow executor""" | ||||
|         flow = create_test_flow() | ||||
|         flow.authentication = FlowAuthenticationRequirement.NONE | ||||
|         request = self.request_factory.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(request) | ||||
|         request.session.save() | ||||
|  | ||||
|         request.user = AnonymousUser() | ||||
|         planner = FlowPlanner(flow) | ||||
|         planner.allow_empty_flows = True | ||||
|         plan = planner.plan(request) | ||||
|         self.assertTrue(plan.requires_flow_executor()) | ||||
|         self.assertEqual( | ||||
|             plan.to_redirect(request, flow).url, | ||||
|             reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|  | ||||
|     def test_to_redirect_skip_simple(self): | ||||
|         """Test to_redirect and skipping the flow executor""" | ||||
|         flow = create_test_flow() | ||||
|         flow.authentication = FlowAuthenticationRequirement.NONE | ||||
|         request = self.request_factory.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         middleware = SessionMiddleware(dummy_get_response) | ||||
|         middleware.process_request(request) | ||||
|         request.session.save() | ||||
|         request.user = AnonymousUser() | ||||
|         planner = FlowPlanner(flow) | ||||
|         planner.allow_empty_flows = True | ||||
|         plan = planner.plan(request) | ||||
|  | ||||
|         class TStageView(StageView): | ||||
|             def dispatch(self, request: HttpRequest, *args, **kwargs): | ||||
|                 return redirect("https://authentik.company") | ||||
|  | ||||
|         plan.append_stage(in_memory_stage(TStageView)) | ||||
|         self.assertFalse(plan.requires_flow_executor(allowed_silent_types=[TStageView])) | ||||
|         self.assertEqual( | ||||
|             plan.to_redirect(request, flow, allowed_silent_types=[TStageView]).url, | ||||
|             "https://authentik.company", | ||||
|         ) | ||||
|  | ||||
|     def test_to_redirect_skip_stage(self): | ||||
|         """Test to_redirect and skipping the flow executor | ||||
|         (with a stage bound that cannot be skipped)""" | ||||
|         flow = create_test_flow() | ||||
|         flow.authentication = FlowAuthenticationRequirement.NONE | ||||
|  | ||||
|         FlowStageBinding.objects.create( | ||||
|             target=flow, stage=DummyStage.objects.create(name="dummy"), order=0 | ||||
|         ) | ||||
|  | ||||
|         request = self.request_factory.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         request.user = AnonymousUser() | ||||
|         planner = FlowPlanner(flow) | ||||
|         planner.allow_empty_flows = True | ||||
|         plan = planner.plan(request) | ||||
|  | ||||
|         class TStageView(StageView): | ||||
|             def dispatch(self, request: HttpRequest, *args, **kwargs): | ||||
|                 return redirect("https://authentik.company") | ||||
|  | ||||
|         plan.append_stage(in_memory_stage(TStageView)) | ||||
|         self.assertTrue(plan.requires_flow_executor(allowed_silent_types=[TStageView])) | ||||
|  | ||||
|     def test_to_redirect_skip_policies(self): | ||||
|         """Test to_redirect and skipping the flow executor | ||||
|         (with a marker on the stage view type that can be skipped) | ||||
|  | ||||
|         Note that this is not actually used anywhere in the code, all stages that are dynamically | ||||
|         added are statically added""" | ||||
|         flow = create_test_flow() | ||||
|         flow.authentication = FlowAuthenticationRequirement.NONE | ||||
|  | ||||
|         request = self.request_factory.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         request.user = AnonymousUser() | ||||
|         planner = FlowPlanner(flow) | ||||
|         planner.allow_empty_flows = True | ||||
|         plan = planner.plan(request) | ||||
|  | ||||
|         class TStageView(StageView): | ||||
|             def dispatch(self, request: HttpRequest, *args, **kwargs): | ||||
|                 return redirect("https://authentik.company") | ||||
|  | ||||
|         plan.append_stage(in_memory_stage(TStageView), ReevaluateMarker(None)) | ||||
|         self.assertTrue(plan.requires_flow_executor(allowed_silent_types=[TStageView])) | ||||
|  | ||||
| @ -171,7 +171,8 @@ class FlowExecutorView(APIView): | ||||
|                     # Existing plan is deleted from session and instance | ||||
|                     self.plan = None | ||||
|                     self.cancel() | ||||
|                 self._logger.debug("f(exec): Continuing existing plan") | ||||
|                 else: | ||||
|                     self._logger.debug("f(exec): Continuing existing plan") | ||||
|  | ||||
|             # Initial flow request, check if we have an upstream query string passed in | ||||
|             request.session[SESSION_KEY_GET] = get_params | ||||
| @ -597,9 +598,4 @@ class ConfigureFlowInitView(LoginRequiredMixin, View): | ||||
|         except FlowNonApplicableException: | ||||
|             LOGGER.warning("Flow not applicable to user") | ||||
|             raise Http404 from None | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=stage.configure_flow.slug, | ||||
|         ) | ||||
|         return plan.to_redirect(request, stage.configure_flow) | ||||
|  | ||||
| @ -5,6 +5,7 @@ import json | ||||
| import os | ||||
| from collections.abc import Mapping | ||||
| from contextlib import contextmanager | ||||
| from copy import deepcopy | ||||
| from dataclasses import dataclass, field | ||||
| from enum import Enum | ||||
| from glob import glob | ||||
| @ -279,9 +280,24 @@ class ConfigLoader: | ||||
|             self.log("warning", "Failed to parse config as int", path=path, exc=str(exc)) | ||||
|             return default | ||||
|  | ||||
|     def get_optional_int(self, path: str, default=None) -> int | None: | ||||
|         """Wrapper for get that converts value into int or None if set""" | ||||
|         value = self.get(path, default) | ||||
|  | ||||
|         try: | ||||
|             return int(value) | ||||
|         except (ValueError, TypeError) as exc: | ||||
|             if value is None or (isinstance(value, str) and value.lower() == "null"): | ||||
|                 return None | ||||
|             self.log("warning", "Failed to parse config as int", path=path, exc=str(exc)) | ||||
|             return default | ||||
|  | ||||
|     def get_bool(self, path: str, default=False) -> bool: | ||||
|         """Wrapper for get that converts value into boolean""" | ||||
|         return str(self.get(path, default)).lower() == "true" | ||||
|         value = self.get(path, UNSET) | ||||
|         if value is UNSET: | ||||
|             return default | ||||
|         return str(self.get(path)).lower() == "true" | ||||
|  | ||||
|     def get_keys(self, path: str, sep=".") -> list[str]: | ||||
|         """List attribute keys by using yaml path""" | ||||
| @ -336,6 +352,71 @@ def redis_url(db: int) -> str: | ||||
|     return _redis_url | ||||
|  | ||||
|  | ||||
| def django_db_config(config: ConfigLoader | None = None) -> dict: | ||||
|     if not config: | ||||
|         config = CONFIG | ||||
|     db = { | ||||
|         "default": { | ||||
|             "ENGINE": "authentik.root.db", | ||||
|             "HOST": config.get("postgresql.host"), | ||||
|             "NAME": config.get("postgresql.name"), | ||||
|             "USER": config.get("postgresql.user"), | ||||
|             "PASSWORD": config.get("postgresql.password"), | ||||
|             "PORT": config.get("postgresql.port"), | ||||
|             "OPTIONS": { | ||||
|                 "sslmode": config.get("postgresql.sslmode"), | ||||
|                 "sslrootcert": config.get("postgresql.sslrootcert"), | ||||
|                 "sslcert": config.get("postgresql.sslcert"), | ||||
|                 "sslkey": config.get("postgresql.sslkey"), | ||||
|             }, | ||||
|             "CONN_MAX_AGE": CONFIG.get_optional_int("postgresql.conn_max_age", 0), | ||||
|             "CONN_HEALTH_CHECKS": CONFIG.get_bool("postgresql.conn_health_checks", False), | ||||
|             "DISABLE_SERVER_SIDE_CURSORS": CONFIG.get_bool( | ||||
|                 "postgresql.disable_server_side_cursors", False | ||||
|             ), | ||||
|             "TEST": { | ||||
|                 "NAME": config.get("postgresql.test.name"), | ||||
|             }, | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     conn_max_age = CONFIG.get_optional_int("postgresql.conn_max_age", UNSET) | ||||
|     disable_server_side_cursors = CONFIG.get_bool("postgresql.disable_server_side_cursors", UNSET) | ||||
|     if config.get_bool("postgresql.use_pgpool", False): | ||||
|         db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True | ||||
|         if disable_server_side_cursors is not UNSET: | ||||
|             db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = disable_server_side_cursors | ||||
|  | ||||
|     if config.get_bool("postgresql.use_pgbouncer", False): | ||||
|         # https://docs.djangoproject.com/en/4.0/ref/databases/#transaction-pooling-server-side-cursors | ||||
|         db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True | ||||
|         # https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections | ||||
|         db["default"]["CONN_MAX_AGE"] = None  # persistent | ||||
|         if disable_server_side_cursors is not UNSET: | ||||
|             db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = disable_server_side_cursors | ||||
|         if conn_max_age is not UNSET: | ||||
|             db["default"]["CONN_MAX_AGE"] = conn_max_age | ||||
|  | ||||
|     for replica in config.get_keys("postgresql.read_replicas"): | ||||
|         _database = deepcopy(db["default"]) | ||||
|         for setting, current_value in db["default"].items(): | ||||
|             if isinstance(current_value, dict): | ||||
|                 continue | ||||
|             override = config.get( | ||||
|                 f"postgresql.read_replicas.{replica}.{setting.lower()}", default=UNSET | ||||
|             ) | ||||
|             if override is not UNSET: | ||||
|                 _database[setting] = override | ||||
|         for setting in db["default"]["OPTIONS"].keys(): | ||||
|             override = config.get( | ||||
|                 f"postgresql.read_replicas.{replica}.{setting.lower()}", default=UNSET | ||||
|             ) | ||||
|             if override is not UNSET: | ||||
|                 _database["OPTIONS"][setting] = override | ||||
|         db[f"replica_{replica}"] = _database | ||||
|     return db | ||||
|  | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     if len(argv) < 2:  # noqa: PLR2004 | ||||
|         print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder)) | ||||
|  | ||||
| @ -6,8 +6,6 @@ postgresql: | ||||
|   user: authentik | ||||
|   port: 5432 | ||||
|   password: "env://POSTGRES_PASSWORD" | ||||
|   use_pgbouncer: false | ||||
|   use_pgpool: false | ||||
|   test: | ||||
|     name: test_authentik | ||||
|   read_replicas: {} | ||||
|  | ||||
| @ -9,7 +9,14 @@ from unittest import mock | ||||
| from django.conf import ImproperlyConfigured | ||||
| from django.test import TestCase | ||||
|  | ||||
| from authentik.lib.config import ENV_PREFIX, UNSET, Attr, AttrEncoder, ConfigLoader | ||||
| from authentik.lib.config import ( | ||||
|     ENV_PREFIX, | ||||
|     UNSET, | ||||
|     Attr, | ||||
|     AttrEncoder, | ||||
|     ConfigLoader, | ||||
|     django_db_config, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class TestConfig(TestCase): | ||||
| @ -175,3 +182,283 @@ class TestConfig(TestCase): | ||||
|         config = ConfigLoader() | ||||
|         config.set("foo.bar", "baz") | ||||
|         self.assertEqual(list(config.get_keys("foo")), ["bar"]) | ||||
|  | ||||
|     def test_db_default(self): | ||||
|         """Test default DB Config""" | ||||
|         config = ConfigLoader() | ||||
|         config.set("postgresql.host", "foo") | ||||
|         config.set("postgresql.name", "foo") | ||||
|         config.set("postgresql.user", "foo") | ||||
|         config.set("postgresql.password", "foo") | ||||
|         config.set("postgresql.port", "foo") | ||||
|         config.set("postgresql.sslmode", "foo") | ||||
|         config.set("postgresql.sslrootcert", "foo") | ||||
|         config.set("postgresql.sslcert", "foo") | ||||
|         config.set("postgresql.sslkey", "foo") | ||||
|         config.set("postgresql.test.name", "foo") | ||||
|         conf = django_db_config(config) | ||||
|         self.assertEqual( | ||||
|             conf, | ||||
|             { | ||||
|                 "default": { | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "foo", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "sslcert": "foo", | ||||
|                         "sslkey": "foo", | ||||
|                         "sslmode": "foo", | ||||
|                         "sslrootcert": "foo", | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|                 } | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_db_read_replicas(self): | ||||
|         """Test read replicas""" | ||||
|         config = ConfigLoader() | ||||
|         config.set("postgresql.host", "foo") | ||||
|         config.set("postgresql.name", "foo") | ||||
|         config.set("postgresql.user", "foo") | ||||
|         config.set("postgresql.password", "foo") | ||||
|         config.set("postgresql.port", "foo") | ||||
|         config.set("postgresql.sslmode", "foo") | ||||
|         config.set("postgresql.sslrootcert", "foo") | ||||
|         config.set("postgresql.sslcert", "foo") | ||||
|         config.set("postgresql.sslkey", "foo") | ||||
|         config.set("postgresql.test.name", "foo") | ||||
|         # Read replica | ||||
|         config.set("postgresql.read_replicas.0.host", "bar") | ||||
|         conf = django_db_config(config) | ||||
|         self.assertEqual( | ||||
|             conf, | ||||
|             { | ||||
|                 "default": { | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "foo", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "sslcert": "foo", | ||||
|                         "sslkey": "foo", | ||||
|                         "sslmode": "foo", | ||||
|                         "sslrootcert": "foo", | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|                 }, | ||||
|                 "replica_0": { | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "bar", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "sslcert": "foo", | ||||
|                         "sslkey": "foo", | ||||
|                         "sslmode": "foo", | ||||
|                         "sslrootcert": "foo", | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|                 }, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_db_read_replicas_pgbouncer(self): | ||||
|         """Test read replicas""" | ||||
|         config = ConfigLoader() | ||||
|         config.set("postgresql.host", "foo") | ||||
|         config.set("postgresql.name", "foo") | ||||
|         config.set("postgresql.user", "foo") | ||||
|         config.set("postgresql.password", "foo") | ||||
|         config.set("postgresql.port", "foo") | ||||
|         config.set("postgresql.sslmode", "foo") | ||||
|         config.set("postgresql.sslrootcert", "foo") | ||||
|         config.set("postgresql.sslcert", "foo") | ||||
|         config.set("postgresql.sslkey", "foo") | ||||
|         config.set("postgresql.test.name", "foo") | ||||
|         config.set("postgresql.use_pgbouncer", True) | ||||
|         # Read replica | ||||
|         config.set("postgresql.read_replicas.0.host", "bar") | ||||
|         # Override conn_max_age | ||||
|         config.set("postgresql.read_replicas.0.conn_max_age", 10) | ||||
|         # This isn't supported | ||||
|         config.set("postgresql.read_replicas.0.use_pgbouncer", False) | ||||
|         conf = django_db_config(config) | ||||
|         self.assertEqual( | ||||
|             conf, | ||||
|             { | ||||
|                 "default": { | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": True, | ||||
|                     "CONN_MAX_AGE": None, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "foo", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "sslcert": "foo", | ||||
|                         "sslkey": "foo", | ||||
|                         "sslmode": "foo", | ||||
|                         "sslrootcert": "foo", | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                 }, | ||||
|                 "replica_0": { | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": True, | ||||
|                     "CONN_MAX_AGE": 10, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "bar", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "sslcert": "foo", | ||||
|                         "sslkey": "foo", | ||||
|                         "sslmode": "foo", | ||||
|                         "sslrootcert": "foo", | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                 }, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_db_read_replicas_pgpool(self): | ||||
|         """Test read replicas""" | ||||
|         config = ConfigLoader() | ||||
|         config.set("postgresql.host", "foo") | ||||
|         config.set("postgresql.name", "foo") | ||||
|         config.set("postgresql.user", "foo") | ||||
|         config.set("postgresql.password", "foo") | ||||
|         config.set("postgresql.port", "foo") | ||||
|         config.set("postgresql.sslmode", "foo") | ||||
|         config.set("postgresql.sslrootcert", "foo") | ||||
|         config.set("postgresql.sslcert", "foo") | ||||
|         config.set("postgresql.sslkey", "foo") | ||||
|         config.set("postgresql.test.name", "foo") | ||||
|         config.set("postgresql.use_pgpool", True) | ||||
|         # Read replica | ||||
|         config.set("postgresql.read_replicas.0.host", "bar") | ||||
|         # This isn't supported | ||||
|         config.set("postgresql.read_replicas.0.use_pgpool", False) | ||||
|         conf = django_db_config(config) | ||||
|         self.assertEqual( | ||||
|             conf, | ||||
|             { | ||||
|                 "default": { | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": True, | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "foo", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "sslcert": "foo", | ||||
|                         "sslkey": "foo", | ||||
|                         "sslmode": "foo", | ||||
|                         "sslrootcert": "foo", | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                 }, | ||||
|                 "replica_0": { | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": True, | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "bar", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "sslcert": "foo", | ||||
|                         "sslkey": "foo", | ||||
|                         "sslmode": "foo", | ||||
|                         "sslrootcert": "foo", | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                 }, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
|     def test_db_read_replicas_diff_ssl(self): | ||||
|         """Test read replicas (with different SSL Settings)""" | ||||
|         """Test read replicas""" | ||||
|         config = ConfigLoader() | ||||
|         config.set("postgresql.host", "foo") | ||||
|         config.set("postgresql.name", "foo") | ||||
|         config.set("postgresql.user", "foo") | ||||
|         config.set("postgresql.password", "foo") | ||||
|         config.set("postgresql.port", "foo") | ||||
|         config.set("postgresql.sslmode", "foo") | ||||
|         config.set("postgresql.sslrootcert", "foo") | ||||
|         config.set("postgresql.sslcert", "foo") | ||||
|         config.set("postgresql.sslkey", "foo") | ||||
|         config.set("postgresql.test.name", "foo") | ||||
|         # Read replica | ||||
|         config.set("postgresql.read_replicas.0.host", "bar") | ||||
|         config.set("postgresql.read_replicas.0.sslcert", "bar") | ||||
|         conf = django_db_config(config) | ||||
|         self.assertEqual( | ||||
|             conf, | ||||
|             { | ||||
|                 "default": { | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "foo", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "sslcert": "foo", | ||||
|                         "sslkey": "foo", | ||||
|                         "sslmode": "foo", | ||||
|                         "sslrootcert": "foo", | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                 }, | ||||
|                 "replica_0": { | ||||
|                     "ENGINE": "authentik.root.db", | ||||
|                     "HOST": "bar", | ||||
|                     "NAME": "foo", | ||||
|                     "OPTIONS": { | ||||
|                         "sslcert": "bar", | ||||
|                         "sslkey": "foo", | ||||
|                         "sslmode": "foo", | ||||
|                         "sslrootcert": "foo", | ||||
|                     }, | ||||
|                     "PASSWORD": "foo", | ||||
|                     "PORT": "foo", | ||||
|                     "TEST": {"NAME": "foo"}, | ||||
|                     "USER": "foo", | ||||
|                     "DISABLE_SERVER_SIDE_CURSORS": False, | ||||
|                     "CONN_MAX_AGE": 0, | ||||
|                     "CONN_HEALTH_CHECKS": False, | ||||
|                 }, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
							
								
								
									
										22
									
								
								authentik/lib/utils/db.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								authentik/lib/utils/db.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,22 @@ | ||||
| """authentik database utilities""" | ||||
|  | ||||
| import gc | ||||
|  | ||||
| from django.db.models import QuerySet | ||||
|  | ||||
|  | ||||
| def qs_batch_iter(qs: QuerySet, batch_size: int = 10_000, gc_collect: bool = True): | ||||
|     pk_iter = qs.values_list("pk", flat=True).order_by("pk").distinct().iterator() | ||||
|     eof = False | ||||
|     while not eof: | ||||
|         pk_buffer = [] | ||||
|         i = 0 | ||||
|         try: | ||||
|             while i < batch_size: | ||||
|                 pk_buffer.append(pk_iter.next()) | ||||
|                 i += 1 | ||||
|         except StopIteration: | ||||
|             eof = True | ||||
|         yield from qs.filter(pk__in=pk_buffer).order_by("pk").iterator() | ||||
|         if gc_collect: | ||||
|             gc.collect() | ||||
| @ -84,19 +84,17 @@ class PolicyBindingSerializer(ModelSerializer): | ||||
|  | ||||
|     def validate(self, attrs: OrderedDict) -> OrderedDict: | ||||
|         """Check that either policy, group or user is set.""" | ||||
|         count = sum( | ||||
|             [ | ||||
|                 bool(attrs.get("policy", None)), | ||||
|                 bool(attrs.get("group", None)), | ||||
|                 bool(attrs.get("user", None)), | ||||
|             ] | ||||
|         ) | ||||
|         target: PolicyBindingModel = attrs.get("target") | ||||
|         supported = target.supported_policy_binding_targets() | ||||
|         supported.sort() | ||||
|         count = sum([bool(attrs.get(x, None)) for x in supported]) | ||||
|         invalid = count > 1 | ||||
|         empty = count < 1 | ||||
|         warning = ", ".join(f"'{x}'" for x in supported) | ||||
|         if invalid: | ||||
|             raise ValidationError("Only one of 'policy', 'group' or 'user' can be set.") | ||||
|             raise ValidationError(f"Only one of {warning} can be set.") | ||||
|         if empty: | ||||
|             raise ValidationError("One of 'policy', 'group' or 'user' must be set.") | ||||
|             raise ValidationError(f"One of {warning} must be set.") | ||||
|         return attrs | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -1,4 +1,6 @@ | ||||
| # Generated by Django 4.2.5 on 2023-09-13 18:07 | ||||
| import authentik.lib.models | ||||
| import django.db.models.deletion | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
| @ -23,4 +25,13 @@ class Migration(migrations.Migration): | ||||
|                 default=30, help_text="Timeout after which Policy execution is terminated." | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="policybinding", | ||||
|             name="target", | ||||
|             field=authentik.lib.models.InheritanceForeignKey( | ||||
|                 on_delete=django.db.models.deletion.CASCADE, | ||||
|                 related_name="bindings", | ||||
|                 to="authentik_policies.policybindingmodel", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
|  | ||||
| @ -47,6 +47,10 @@ class PolicyBindingModel(models.Model): | ||||
|     def __str__(self) -> str: | ||||
|         return f"PolicyBindingModel {self.pbm_uuid}" | ||||
|  | ||||
|     def supported_policy_binding_targets(self): | ||||
|         """Return the list of objects that can be bound to this object.""" | ||||
|         return ["policy", "user", "group"] | ||||
|  | ||||
|  | ||||
| class PolicyBinding(SerializerModel): | ||||
|     """Relationship between a Policy and a PolicyBindingModel.""" | ||||
| @ -81,7 +85,9 @@ class PolicyBinding(SerializerModel): | ||||
|         blank=True, | ||||
|     ) | ||||
|  | ||||
|     target = InheritanceForeignKey(PolicyBindingModel, on_delete=models.CASCADE, related_name="+") | ||||
|     target = InheritanceForeignKey( | ||||
|         PolicyBindingModel, on_delete=models.CASCADE, related_name="bindings" | ||||
|     ) | ||||
|     negate = models.BooleanField( | ||||
|         default=False, | ||||
|         help_text=_("Negates the outcome of the policy. Messages are unaffected."), | ||||
|  | ||||
| @ -38,7 +38,7 @@ class TestBindingsAPI(APITestCase): | ||||
|         ) | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             {"non_field_errors": ["Only one of 'policy', 'group' or 'user' can be set."]}, | ||||
|             {"non_field_errors": ["Only one of 'group', 'policy', 'user' can be set."]}, | ||||
|         ) | ||||
|  | ||||
|     def test_invalid_too_little(self): | ||||
| @ -49,5 +49,5 @@ class TestBindingsAPI(APITestCase): | ||||
|         ) | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             {"non_field_errors": ["One of 'policy', 'group' or 'user' must be set."]}, | ||||
|             {"non_field_errors": ["One of 'group', 'policy', 'user' must be set."]}, | ||||
|         ) | ||||
|  | ||||
| @ -0,0 +1,38 @@ | ||||
| # Generated by Django 5.0.10 on 2024-12-12 17:16 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_core", "0040_provider_invalidation_flow"), | ||||
|         ( | ||||
|             "authentik_providers_oauth2", | ||||
|             "0025_rename_jwks_sources_oauth2provider_jwt_federation_sources_and_more", | ||||
|         ), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="accesstoken", | ||||
|             name="session", | ||||
|             field=models.ForeignKey( | ||||
|                 default=None, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.CASCADE, | ||||
|                 to="authentik_core.authenticatedsession", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="authorizationcode", | ||||
|             name="session", | ||||
|             field=models.ForeignKey( | ||||
|                 default=None, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.CASCADE, | ||||
|                 to="authentik_core.authenticatedsession", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -396,7 +396,7 @@ class BaseGrantModel(models.Model): | ||||
|     _scope = models.TextField(default="", verbose_name=_("Scopes")) | ||||
|     auth_time = models.DateTimeField(verbose_name="Authentication time") | ||||
|     session = models.ForeignKey( | ||||
|         AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None | ||||
|         AuthenticatedSession, null=True, on_delete=models.CASCADE, default=None | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
| @ -497,6 +497,11 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel): | ||||
|  | ||||
|     token = models.TextField(default=generate_client_secret) | ||||
|     _id_token = models.TextField(verbose_name=_("ID Token")) | ||||
|     # Shadow the `session` field from `BaseGrantModel` as we want refresh tokens to persist even | ||||
|     # when the session is terminated. | ||||
|     session = models.ForeignKey( | ||||
|         AuthenticatedSession, null=True, on_delete=models.SET_DEFAULT, default=None | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         indexes = [ | ||||
|  | ||||
| @ -311,7 +311,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|         user = create_test_admin_user() | ||||
|         self.client.force_login(user) | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         self.client.get( | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -320,16 +320,10 @@ class TestAuthorize(OAuthTestCase): | ||||
|                 "redirect_uri": "foo://localhost", | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         ) | ||||
|         self.assertAlmostEqual( | ||||
|             code.expires.timestamp() - now().timestamp(), | ||||
| @ -377,7 +371,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|             ), | ||||
|         ): | ||||
|             # Step 1, initiate params and get redirect to flow | ||||
|             self.client.get( | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_providers_oauth2:authorize"), | ||||
|                 data={ | ||||
|                     "response_type": "id_token", | ||||
| @ -388,22 +382,16 @@ class TestAuthorize(OAuthTestCase): | ||||
|                     "nonce": generate_id(), | ||||
|                 }, | ||||
|             ) | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|             ) | ||||
|             token: AccessToken = AccessToken.objects.filter(user=user).first() | ||||
|             expires = timedelta_from_string(provider.access_token_validity).total_seconds() | ||||
|             self.assertJSONEqual( | ||||
|                 response.content.decode(), | ||||
|                 { | ||||
|                     "component": "xak-flow-redirect", | ||||
|                     "to": ( | ||||
|                         f"http://localhost#access_token={token.token}" | ||||
|                         f"&id_token={provider.encode(token.id_token.to_dict())}" | ||||
|                         f"&token_type={TOKEN_TYPE}" | ||||
|                         f"&expires_in={int(expires)}&state={state}" | ||||
|                     ), | ||||
|                 }, | ||||
|             self.assertEqual( | ||||
|                 response.url, | ||||
|                 ( | ||||
|                     f"http://localhost#access_token={token.token}" | ||||
|                     f"&id_token={provider.encode(token.id_token.to_dict())}" | ||||
|                     f"&token_type={TOKEN_TYPE}" | ||||
|                     f"&expires_in={int(expires)}&state={state}" | ||||
|                 ), | ||||
|             ) | ||||
|             jwt = self.validate_jwt(token, provider) | ||||
|             self.assertEqual(jwt["amr"], ["pwd"]) | ||||
| @ -455,7 +443,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|             ), | ||||
|         ): | ||||
|             # Step 1, initiate params and get redirect to flow | ||||
|             self.client.get( | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_providers_oauth2:authorize"), | ||||
|                 data={ | ||||
|                     "response_type": "id_token", | ||||
| @ -466,10 +454,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|                     "nonce": generate_id(), | ||||
|                 }, | ||||
|             ) | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|             ) | ||||
|             self.assertEqual(response.status_code, 200) | ||||
|             self.assertEqual(response.status_code, 302) | ||||
|             token: AccessToken = AccessToken.objects.filter(user=user).first() | ||||
|             expires = timedelta_from_string(provider.access_token_validity).total_seconds() | ||||
|             jwt = self.validate_jwe(token, provider) | ||||
| @ -506,7 +491,7 @@ class TestAuthorize(OAuthTestCase): | ||||
|             ), | ||||
|         ): | ||||
|             # Step 1, initiate params and get redirect to flow | ||||
|             self.client.get( | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_providers_oauth2:authorize"), | ||||
|                 data={ | ||||
|                     "response_type": "code", | ||||
| @ -518,16 +503,10 @@ class TestAuthorize(OAuthTestCase): | ||||
|                     "nonce": generate_id(), | ||||
|                 }, | ||||
|             ) | ||||
|             response = self.client.get( | ||||
|                 reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|             ) | ||||
|             code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||
|             self.assertJSONEqual( | ||||
|                 response.content.decode(), | ||||
|                 { | ||||
|                     "component": "xak-flow-redirect", | ||||
|                     "to": (f"http://localhost#code={code.code}" f"&state={state}"), | ||||
|                 }, | ||||
|             self.assertEqual( | ||||
|                 response.url, | ||||
|                 f"http://localhost#code={code.code}&state={state}", | ||||
|             ) | ||||
|             self.assertAlmostEqual( | ||||
|                 code.expires.timestamp() - now().timestamp(), | ||||
|  | ||||
| @ -45,7 +45,7 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|         challenge = generate_id() | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         self.client.get( | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -56,16 +56,10 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|                 "code_challenge_method": "S256", | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
| @ -107,7 +101,7 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|         self.client.force_login(user) | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         self.client.get( | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -118,16 +112,10 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|                 # "code_challenge_method": "S256", | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
| @ -174,7 +162,7 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|         ) | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         self.client.get( | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -185,16 +173,10 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|                 "code_challenge_method": "S256", | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
| @ -225,7 +207,7 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|         verifier = generate_id() | ||||
|         header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||
|         # Step 1, initiate params and get redirect to flow | ||||
|         self.client.get( | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_providers_oauth2:authorize"), | ||||
|             data={ | ||||
|                 "response_type": "code", | ||||
| @ -235,16 +217,10 @@ class TestTokenPKCE(OAuthTestCase): | ||||
|                 "code_challenge": verifier, | ||||
|             }, | ||||
|         ) | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), | ||||
|         ) | ||||
|         code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first() | ||||
|         self.assertJSONEqual( | ||||
|             response.content.decode(), | ||||
|             { | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": f"foo://localhost?code={code.code}&state={state}", | ||||
|             }, | ||||
|         self.assertEqual( | ||||
|             response.url, | ||||
|             f"foo://localhost?code={code.code}&state={state}", | ||||
|         ) | ||||
|         response = self.client.post( | ||||
|             reverse("authentik_providers_oauth2:token"), | ||||
|  | ||||
| @ -27,9 +27,7 @@ from authentik.flows.exceptions import FlowNonApplicableException | ||||
| from authentik.flows.models import in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||
| from authentik.flows.stage import StageView | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.time import timedelta_from_string | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.types import PolicyRequest | ||||
| from authentik.policies.views import PolicyAccessView, RequestValidationError | ||||
| @ -454,11 +452,16 @@ class AuthorizationFlowInitView(PolicyAccessView): | ||||
|  | ||||
|         plan.append_stage(in_memory_stage(OAuthFulfillmentStage)) | ||||
|  | ||||
|         self.request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=self.provider.authorization_flow.slug, | ||||
|         return plan.to_redirect( | ||||
|             self.request, | ||||
|             self.provider.authorization_flow, | ||||
|             # We can only skip the flow executor and directly go to the final redirect URL if | ||||
|             #  we can submit the data to the RP via URL | ||||
|             allowed_silent_types=( | ||||
|                 [OAuthFulfillmentStage] | ||||
|                 if self.params.response_mode in [ResponseMode.QUERY, ResponseMode.FRAGMENT] | ||||
|                 else [] | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -16,7 +16,6 @@ from authentik.flows.models import in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.policies.views import PolicyAccessView | ||||
| from authentik.providers.oauth2.models import DeviceToken | ||||
| from authentik.providers.oauth2.views.device_finish import ( | ||||
| @ -73,12 +72,7 @@ class CodeValidatorView(PolicyAccessView): | ||||
|             LOGGER.warning("Flow not applicable to user") | ||||
|             return None | ||||
|         plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             request.GET, | ||||
|             flow_slug=self.token.provider.authorization_flow.slug, | ||||
|         ) | ||||
|         return plan.to_redirect(self.request, self.token.provider.authorization_flow) | ||||
|  | ||||
|  | ||||
| class DeviceEntryView(PolicyAccessView): | ||||
| @ -109,11 +103,7 @@ class DeviceEntryView(PolicyAccessView): | ||||
|         plan.append_stage(in_memory_stage(OAuthDeviceCodeStage)) | ||||
|  | ||||
|         self.request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=device_flow.slug, | ||||
|         ) | ||||
|         return plan.to_redirect(self.request, device_flow) | ||||
|  | ||||
|  | ||||
| class OAuthDeviceCodeChallenge(Challenge): | ||||
|  | ||||
| @ -7,8 +7,6 @@ from authentik.core.models import Application | ||||
| from authentik.flows.models import Flow, in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| from authentik.flows.stage import SessionEndStage | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.policies.views import PolicyAccessView | ||||
|  | ||||
|  | ||||
| @ -37,9 +35,4 @@ class EndSessionView(PolicyAccessView): | ||||
|             }, | ||||
|         ) | ||||
|         plan.insert_stage(in_memory_stage(SessionEndStage)) | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=self.flow.slug, | ||||
|         ) | ||||
|         return plan.to_redirect(self.request, self.flow) | ||||
|  | ||||
| @ -127,6 +127,7 @@ class Traefik3MiddlewareReconciler(KubernetesObjectReconciler[TraefikMiddleware] | ||||
|                     authResponseHeaders=[ | ||||
|                         "X-authentik-username", | ||||
|                         "X-authentik-groups", | ||||
|                         "X-authentik-entitlements", | ||||
|                         "X-authentik-email", | ||||
|                         "X-authentik-name", | ||||
|                         "X-authentik-uid", | ||||
|  | ||||
| @ -147,6 +147,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider): | ||||
|                 "goauthentik.io/providers/oauth2/scope-openid", | ||||
|                 "goauthentik.io/providers/oauth2/scope-profile", | ||||
|                 "goauthentik.io/providers/oauth2/scope-email", | ||||
|                 "goauthentik.io/providers/oauth2/scope-entitlements", | ||||
|                 "goauthentik.io/providers/proxy/scope-proxy", | ||||
|             ] | ||||
|         ) | ||||
|  | ||||
| @ -54,9 +54,23 @@ class SAMLProviderSerializer(ProviderSerializer): | ||||
|         if "request" not in self._context: | ||||
|             return "" | ||||
|         request: HttpRequest = self._context["request"]._request | ||||
|         return request.build_absolute_uri( | ||||
|             reverse("authentik_api:samlprovider-metadata", kwargs={"pk": instance.pk}) + "?download" | ||||
|         ) | ||||
|         try: | ||||
|             return request.build_absolute_uri( | ||||
|                 reverse( | ||||
|                     "authentik_providers_saml:metadata-download", | ||||
|                     kwargs={"application_slug": instance.application.slug}, | ||||
|                 ) | ||||
|             ) | ||||
|         except Provider.application.RelatedObjectDoesNotExist: | ||||
|             return request.build_absolute_uri( | ||||
|                 reverse( | ||||
|                     "authentik_api:samlprovider-metadata", | ||||
|                     kwargs={ | ||||
|                         "pk": instance.pk, | ||||
|                     }, | ||||
|                 ) | ||||
|                 + "?download" | ||||
|             ) | ||||
|  | ||||
|     def get_url_sso_post(self, instance: SAMLProvider) -> str: | ||||
|         """Get SSO Post URL""" | ||||
|  | ||||
| @ -13,8 +13,6 @@ from authentik.events.models import Event, EventAction | ||||
| from authentik.flows.models import Flow, in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner | ||||
| from authentik.flows.stage import SessionEndStage | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.views import PolicyAccessView | ||||
| from authentik.providers.saml.exceptions import CannotHandleAssertion | ||||
| @ -64,12 +62,7 @@ class SAMLSLOView(PolicyAccessView): | ||||
|             }, | ||||
|         ) | ||||
|         plan.insert_stage(in_memory_stage(SessionEndStage)) | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=self.flow.slug, | ||||
|         ) | ||||
|         return plan.to_redirect(self.request, self.flow) | ||||
|  | ||||
|     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: | ||||
|         """GET and POST use the same handler, but we can't | ||||
|  | ||||
| @ -13,12 +13,11 @@ from authentik.events.models import Event, EventAction | ||||
| from authentik.flows.exceptions import FlowNonApplicableException | ||||
| from authentik.flows.models import in_memory_stage | ||||
| from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN, SESSION_KEY_POST | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.flows.views.executor import SESSION_KEY_POST | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.policies.views import PolicyAccessView | ||||
| from authentik.providers.saml.exceptions import CannotHandleAssertion | ||||
| from authentik.providers.saml.models import SAMLProvider | ||||
| from authentik.providers.saml.models import SAMLBindings, SAMLProvider | ||||
| from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser | ||||
| from authentik.providers.saml.views.flows import ( | ||||
|     REQUEST_KEY_RELAY_STATE, | ||||
| @ -74,11 +73,12 @@ class SAMLSSOView(PolicyAccessView): | ||||
|         except FlowNonApplicableException: | ||||
|             raise Http404 from None | ||||
|         plan.append_stage(in_memory_stage(SAMLFlowFinalView)) | ||||
|         request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             request.GET, | ||||
|             flow_slug=self.provider.authorization_flow.slug, | ||||
|         return plan.to_redirect( | ||||
|             request, | ||||
|             self.provider.authorization_flow, | ||||
|             allowed_silent_types=( | ||||
|                 [SAMLFlowFinalView] if self.provider.sp_binding in [SAMLBindings.REDIRECT] else [] | ||||
|             ), | ||||
|         ) | ||||
|  | ||||
|     def post(self, request: HttpRequest, application_slug: str) -> HttpResponse: | ||||
|  | ||||
| @ -12,7 +12,7 @@ from sentry_sdk import set_tag | ||||
| from xmlsec import enable_debug_trace | ||||
|  | ||||
| from authentik import __version__ | ||||
| from authentik.lib.config import CONFIG, redis_url | ||||
| from authentik.lib.config import CONFIG, django_db_config, redis_url | ||||
| from authentik.lib.logging import get_logger_config, structlog_configure | ||||
| from authentik.lib.sentry import sentry_init | ||||
| from authentik.lib.utils.reflection import get_env | ||||
| @ -40,7 +40,6 @@ LANGUAGE_COOKIE_NAME = "authentik_language" | ||||
| SESSION_COOKIE_NAME = "authentik_session" | ||||
| SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None) | ||||
| APPEND_SLASH = False | ||||
| X_FRAME_OPTIONS = "SAMEORIGIN" | ||||
|  | ||||
| AUTHENTICATION_BACKENDS = [ | ||||
|     "django.contrib.auth.backends.ModelBackend", | ||||
| @ -115,6 +114,7 @@ TENANT_APPS = [ | ||||
|     "authentik.stages.invitation", | ||||
|     "authentik.stages.password", | ||||
|     "authentik.stages.prompt", | ||||
|     "authentik.stages.redirect", | ||||
|     "authentik.stages.user_delete", | ||||
|     "authentik.stages.user_login", | ||||
|     "authentik.stages.user_logout", | ||||
| @ -298,47 +298,7 @@ CHANNEL_LAYERS = { | ||||
| # https://docs.djangoproject.com/en/2.1/ref/settings/#databases | ||||
|  | ||||
| ORIGINAL_BACKEND = "django_prometheus.db.backends.postgresql" | ||||
| DATABASES = { | ||||
|     "default": { | ||||
|         "ENGINE": "authentik.root.db", | ||||
|         "HOST": CONFIG.get("postgresql.host"), | ||||
|         "NAME": CONFIG.get("postgresql.name"), | ||||
|         "USER": CONFIG.get("postgresql.user"), | ||||
|         "PASSWORD": CONFIG.get("postgresql.password"), | ||||
|         "PORT": CONFIG.get("postgresql.port"), | ||||
|         "OPTIONS": { | ||||
|             "sslmode": CONFIG.get("postgresql.sslmode"), | ||||
|             "sslrootcert": CONFIG.get("postgresql.sslrootcert"), | ||||
|             "sslcert": CONFIG.get("postgresql.sslcert"), | ||||
|             "sslkey": CONFIG.get("postgresql.sslkey"), | ||||
|         }, | ||||
|         "TEST": { | ||||
|             "NAME": CONFIG.get("postgresql.test.name"), | ||||
|         }, | ||||
|     } | ||||
| } | ||||
|  | ||||
| if CONFIG.get_bool("postgresql.use_pgpool", False): | ||||
|     DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True | ||||
|  | ||||
| if CONFIG.get_bool("postgresql.use_pgbouncer", False): | ||||
|     # https://docs.djangoproject.com/en/4.0/ref/databases/#transaction-pooling-server-side-cursors | ||||
|     DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True | ||||
|     # https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections | ||||
|     DATABASES["default"]["CONN_MAX_AGE"] = None  # persistent | ||||
|  | ||||
| for replica in CONFIG.get_keys("postgresql.read_replicas"): | ||||
|     _database = DATABASES["default"].copy() | ||||
|     for setting in DATABASES["default"].keys(): | ||||
|         default = object() | ||||
|         if setting in ("TEST",): | ||||
|             continue | ||||
|         override = CONFIG.get( | ||||
|             f"postgresql.read_replicas.{replica}.{setting.lower()}", default=default | ||||
|         ) | ||||
|         if override is not default: | ||||
|             _database[setting] = override | ||||
|     DATABASES[f"replica_{replica}"] = _database | ||||
| DATABASES = django_db_config() | ||||
|  | ||||
| DATABASE_ROUTERS = ( | ||||
|     "authentik.tenants.db.FailoverRouter", | ||||
|  | ||||
| @ -32,6 +32,7 @@ class KerberosSourceSerializer(SourceSerializer): | ||||
|             "group_matching_mode", | ||||
|             "realm", | ||||
|             "krb5_conf", | ||||
|             "kadmin_type", | ||||
|             "sync_users", | ||||
|             "sync_users_password", | ||||
|             "sync_principal", | ||||
| @ -69,6 +70,7 @@ class KerberosSourceViewSet(UsedByMixin, ModelViewSet): | ||||
|         "slug", | ||||
|         "enabled", | ||||
|         "realm", | ||||
|         "kadmin_type", | ||||
|         "sync_users", | ||||
|         "sync_users_password", | ||||
|         "sync_principal", | ||||
|  | ||||
| @ -0,0 +1,22 @@ | ||||
| # Generated by Django 5.0.10 on 2024-12-06 19:24 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_sources_kerberos", "0001_initial"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="kerberossource", | ||||
|             name="kadmin_type", | ||||
|             field=models.TextField( | ||||
|                 choices=[("MIT", "Mit"), ("Heimdal", "Heimdal"), ("other", "Other")], | ||||
|                 default="other", | ||||
|                 help_text="KAdmin server type", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @ -13,7 +13,7 @@ from django.http import HttpRequest | ||||
| from django.shortcuts import reverse | ||||
| from django.templatetags.static import static | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from kadmin import KAdmin | ||||
| from kadmin import KAdmin, KAdminApiVersion | ||||
| from kadmin.exceptions import PyKAdminException | ||||
| from rest_framework.serializers import Serializer | ||||
| from structlog.stdlib import get_logger | ||||
| @ -36,6 +36,12 @@ LOGGER = get_logger() | ||||
| _kadmin_connections: dict[str, Any] = {} | ||||
|  | ||||
|  | ||||
| class KAdminType(models.TextChoices): | ||||
|     MIT = "MIT" | ||||
|     HEIMDAL = "Heimdal" | ||||
|     OTHER = "other" | ||||
|  | ||||
|  | ||||
| class KerberosSource(Source): | ||||
|     """Federate Kerberos realm with authentik""" | ||||
|  | ||||
| @ -44,6 +50,9 @@ class KerberosSource(Source): | ||||
|         blank=True, | ||||
|         help_text=_("Custom krb5.conf to use. Uses the system one by default"), | ||||
|     ) | ||||
|     kadmin_type = models.TextField( | ||||
|         choices=KAdminType.choices, default=KAdminType.OTHER, help_text=_("KAdmin server type") | ||||
|     ) | ||||
|  | ||||
|     sync_users = models.BooleanField( | ||||
|         default=False, help_text=_("Sync users from Kerberos into authentik"), db_index=True | ||||
| @ -199,6 +208,14 @@ class KerberosSource(Source): | ||||
|         return str(conf_path) | ||||
|  | ||||
|     def _kadmin_init(self) -> KAdmin | None: | ||||
|         api_version = None | ||||
|         match self.kadmin_type: | ||||
|             case KAdminType.MIT: | ||||
|                 api_version = KAdminApiVersion.Version4 | ||||
|             case KAdminType.HEIMDAL: | ||||
|                 api_version = KAdminApiVersion.Version2 | ||||
|             case KAdminType.OTHER: | ||||
|                 api_version = KAdminApiVersion.Version2 | ||||
|         # kadmin doesn't use a ccache for its connection | ||||
|         # as such, we don't need to create a separate ccache for each source | ||||
|         if not self.sync_principal: | ||||
| @ -207,6 +224,7 @@ class KerberosSource(Source): | ||||
|             return KAdmin.with_password( | ||||
|                 self.sync_principal, | ||||
|                 self.sync_password, | ||||
|                 api_version=api_version, | ||||
|             ) | ||||
|         if self.sync_keytab: | ||||
|             keytab = self.sync_keytab | ||||
| @ -218,11 +236,13 @@ class KerberosSource(Source): | ||||
|             return KAdmin.with_keytab( | ||||
|                 self.sync_principal, | ||||
|                 keytab, | ||||
|                 api_version=api_version, | ||||
|             ) | ||||
|         if self.sync_ccache: | ||||
|             return KAdmin.with_ccache( | ||||
|                 self.sync_principal, | ||||
|                 self.sync_ccache, | ||||
|                 api_version=api_version, | ||||
|             ) | ||||
|         return None | ||||
|  | ||||
|  | ||||
| @ -45,8 +45,10 @@ def kerberos_sync_password(sender, user: User, password: str, **_): | ||||
|             continue | ||||
|         with Krb5ConfContext(source): | ||||
|             try: | ||||
|                 source.connection().getprinc(user_source_connection.identifier).change_password( | ||||
|                     password | ||||
|                 kadm = source.connection() | ||||
|                 kadm.get_principal(user_source_connection.identifier).change_password( | ||||
|                     kadm, | ||||
|                     password, | ||||
|                 ) | ||||
|             except PyKAdminException as exc: | ||||
|                 LOGGER.warning("failed to set Kerberos password", exc=exc, source=source) | ||||
|  | ||||
| @ -43,8 +43,10 @@ class KerberosSync: | ||||
|         self._messages = [] | ||||
|         self._logger = get_logger().bind(source=self._source, syncer=self.__class__.__name__) | ||||
|         self.mapper = SourceMapper(self._source) | ||||
|         self.user_manager = self.mapper.get_manager(User, ["principal"]) | ||||
|         self.group_manager = self.mapper.get_manager(Group, ["group_id", "principal"]) | ||||
|         self.user_manager = self.mapper.get_manager(User, ["principal", "principal_obj"]) | ||||
|         self.group_manager = self.mapper.get_manager( | ||||
|             Group, ["group_id", "principal", "principal_obj"] | ||||
|         ) | ||||
|         self.matcher = SourceMatcher( | ||||
|             self._source, UserKerberosSourceConnection, GroupKerberosSourceConnection | ||||
|         ) | ||||
| @ -67,12 +69,16 @@ class KerberosSync: | ||||
|  | ||||
|     def _handle_principal(self, principal: str) -> bool: | ||||
|         try: | ||||
|             # TODO: handle permission error | ||||
|             principal_obj = self._connection.get_principal(principal) | ||||
|  | ||||
|             defaults = self.mapper.build_object_properties( | ||||
|                 object_type=User, | ||||
|                 manager=self.user_manager, | ||||
|                 user=None, | ||||
|                 request=None, | ||||
|                 principal=principal, | ||||
|                 principal_obj=principal_obj, | ||||
|             ) | ||||
|             self._logger.debug("Writing user with attributes", **defaults) | ||||
|             if "username" not in defaults: | ||||
| @ -91,6 +97,7 @@ class KerberosSync: | ||||
|                     request=None, | ||||
|                     group_id=group_id, | ||||
|                     principal=principal, | ||||
|                     principal_obj=principal_obj, | ||||
|                 ) | ||||
|                 for group_id in defaults.pop("groups", []) | ||||
|             } | ||||
|  | ||||
| @ -17,6 +17,7 @@ class TestMetadataProcessor(TestCase): | ||||
|     def setUp(self): | ||||
|         self.factory = RequestFactory() | ||||
|         self.source = SAMLSource.objects.create( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
|             issuer="authentik", | ||||
|             signing_kp=create_test_cert(), | ||||
|  | ||||
| @ -28,6 +28,7 @@ class TestPropertyMappings(TestCase): | ||||
|     def setUp(self): | ||||
|         self.factory = RequestFactory() | ||||
|         self.source = SAMLSource.objects.create( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
|             issuer="authentik", | ||||
|             allow_idp_initiated=True, | ||||
|  | ||||
| @ -20,6 +20,7 @@ class TestResponseProcessor(TestCase): | ||||
|     def setUp(self): | ||||
|         self.factory = RequestFactory() | ||||
|         self.source = SAMLSource.objects.create( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
|             issuer="authentik", | ||||
|             allow_idp_initiated=True, | ||||
|  | ||||
							
								
								
									
										88
									
								
								authentik/sources/saml/tests/test_views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								authentik/sources/saml/tests/test_views.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,88 @@ | ||||
| """SAML Source tests""" | ||||
|  | ||||
| from base64 import b64encode | ||||
|  | ||||
| from django.test import RequestFactory, TestCase | ||||
| from django.urls import reverse | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_flow | ||||
| from authentik.flows.planner import PLAN_CONTEXT_REDIRECT, FlowPlan | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.lib.tests.utils import load_fixture | ||||
| from authentik.sources.saml.models import SAMLSource | ||||
|  | ||||
|  | ||||
| class TestViews(TestCase): | ||||
|     """Test SAML Views""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         self.factory = RequestFactory() | ||||
|         self.source = SAMLSource.objects.create( | ||||
|             name=generate_id(), | ||||
|             slug=generate_id(), | ||||
|             issuer="authentik", | ||||
|             allow_idp_initiated=True, | ||||
|             pre_authentication_flow=create_test_flow(), | ||||
|         ) | ||||
|  | ||||
|     def test_enroll(self): | ||||
|         """Enroll""" | ||||
|         flow = create_test_flow() | ||||
|         self.source.enrollment_flow = flow | ||||
|         self.source.save() | ||||
|  | ||||
|         response = self.client.post( | ||||
|             reverse( | ||||
|                 "authentik_sources_saml:acs", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                 }, | ||||
|             ), | ||||
|             data={ | ||||
|                 "SAMLResponse": b64encode( | ||||
|                     load_fixture("fixtures/response_success.xml").encode() | ||||
|                 ).decode() | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertRedirects( | ||||
|             response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||
|         ) | ||||
|         plan: FlowPlan = self.client.session.get(SESSION_KEY_PLAN) | ||||
|         self.assertIsNotNone(plan) | ||||
|  | ||||
|     def test_enroll_redirect(self): | ||||
|         """Enroll when attempting to access a provider""" | ||||
|         initial_redirect = f"http://{generate_id()}" | ||||
|  | ||||
|         session = self.client.session | ||||
|         old_plan = FlowPlan(generate_id()) | ||||
|         old_plan.context[PLAN_CONTEXT_REDIRECT] = initial_redirect | ||||
|         session[SESSION_KEY_PLAN] = old_plan | ||||
|         session.save() | ||||
|  | ||||
|         flow = create_test_flow() | ||||
|         self.source.enrollment_flow = flow | ||||
|         self.source.save() | ||||
|  | ||||
|         response = self.client.post( | ||||
|             reverse( | ||||
|                 "authentik_sources_saml:acs", | ||||
|                 kwargs={ | ||||
|                     "source_slug": self.source.slug, | ||||
|                 }, | ||||
|             ), | ||||
|             data={ | ||||
|                 "SAMLResponse": b64encode( | ||||
|                     load_fixture("fixtures/response_success.xml").encode() | ||||
|                 ).decode() | ||||
|             }, | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, 302) | ||||
|         self.assertRedirects( | ||||
|             response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) | ||||
|         ) | ||||
|         plan: FlowPlan = self.client.session.get(SESSION_KEY_PLAN) | ||||
|         self.assertIsNotNone(plan) | ||||
|         self.assertEqual(plan.context.get(PLAN_CONTEXT_REDIRECT), initial_redirect) | ||||
| @ -28,11 +28,11 @@ from authentik.flows.planner import ( | ||||
|     PLAN_CONTEXT_REDIRECT, | ||||
|     PLAN_CONTEXT_SOURCE, | ||||
|     PLAN_CONTEXT_SSO, | ||||
|     FlowPlan, | ||||
|     FlowPlanner, | ||||
| ) | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN | ||||
| from authentik.lib.utils.urls import redirect_with_qs | ||||
| from authentik.lib.views import bad_request_message | ||||
| from authentik.providers.saml.utils.encoding import nice64 | ||||
| from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat | ||||
| @ -89,12 +89,7 @@ class InitiateView(View): | ||||
|             raise Http404 from None | ||||
|         for stage in stages_to_append: | ||||
|             plan.append_stage(stage) | ||||
|         self.request.session[SESSION_KEY_PLAN] = plan | ||||
|         return redirect_with_qs( | ||||
|             "authentik_core:if-flow", | ||||
|             self.request.GET, | ||||
|             flow_slug=source.pre_authentication_flow.slug, | ||||
|         ) | ||||
|         return plan.to_redirect(self.request, source.pre_authentication_flow) | ||||
|  | ||||
|     def get(self, request: HttpRequest, source_slug: str) -> HttpResponse: | ||||
|         """Replies with an XHTML SSO Request.""" | ||||
| @ -154,12 +149,15 @@ class ACSView(View): | ||||
|         processor = ResponseProcessor(source, request) | ||||
|         try: | ||||
|             processor.parse() | ||||
|         except MissingSAMLResponse as exc: | ||||
|             return bad_request_message(request, str(exc)) | ||||
|         except VerificationError as exc: | ||||
|         except (MissingSAMLResponse, VerificationError) as exc: | ||||
|             return bad_request_message(request, str(exc)) | ||||
|  | ||||
|         try: | ||||
|             if SESSION_KEY_PLAN in request.session: | ||||
|                 plan: FlowPlan = self.request.session[SESSION_KEY_PLAN] | ||||
|                 plan_redirect = plan.context.get(PLAN_CONTEXT_REDIRECT) | ||||
|                 if plan_redirect: | ||||
|                     self.request.session[SESSION_KEY_GET] = {NEXT_ARG_NAME: plan_redirect} | ||||
|             return processor.prepare_flow_manager().get_flow() | ||||
|         except (UnsupportedNameIDFormat, ValueError) as exc: | ||||
|             return bad_request_message(request, str(exc)) | ||||
|  | ||||
| @ -332,7 +332,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | ||||
|             serializer = SelectableStageSerializer( | ||||
|                 data={ | ||||
|                     "pk": stage.pk, | ||||
|                     "name": getattr(stage, "friendly_name", stage.name), | ||||
|                     "name": getattr(stage, "friendly_name", stage.name) or stage.name, | ||||
|                     "verbose_name": str(stage._meta.verbose_name) | ||||
|                     .replace("Setup Stage", "") | ||||
|                     .strip(), | ||||
|  | ||||
| @ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch | ||||
|  | ||||
| from django.test.client import RequestFactory | ||||
| from django.urls.base import reverse | ||||
| from django.utils.timezone import now | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||
| from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction | ||||
| @ -13,6 +14,7 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN | ||||
| from authentik.lib.generators import generate_id, generate_key | ||||
| from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice | ||||
| from authentik.stages.authenticator_static.models import AuthenticatorStaticStage | ||||
| from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDigits | ||||
| from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer | ||||
| from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses | ||||
| from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES | ||||
| @ -76,8 +78,8 @@ class AuthenticatorValidateStageTests(FlowTestCase): | ||||
|         conf_stage = AuthenticatorStaticStage.objects.create( | ||||
|             name=generate_id(), | ||||
|         ) | ||||
|         conf_stage2 = AuthenticatorStaticStage.objects.create( | ||||
|             name=generate_id(), | ||||
|         conf_stage2 = AuthenticatorTOTPStage.objects.create( | ||||
|             name=generate_id(), digits=TOTPDigits.SIX | ||||
|         ) | ||||
|         stage = AuthenticatorValidateStage.objects.create( | ||||
|             name=generate_id(), | ||||
| @ -153,10 +155,14 @@ class AuthenticatorValidateStageTests(FlowTestCase): | ||||
|             { | ||||
|                 "device_class": "static", | ||||
|                 "device_uid": "1", | ||||
|                 "challenge": {}, | ||||
|                 "last_used": now(), | ||||
|             }, | ||||
|             { | ||||
|                 "device_class": "totp", | ||||
|                 "device_uid": "2", | ||||
|                 "challenge": {}, | ||||
|                 "last_used": now(), | ||||
|             }, | ||||
|         ] | ||||
|         session[SESSION_KEY_PLAN] = plan | ||||
|  | ||||
| @ -141,5 +141,10 @@ | ||||
|         "name": "Devolutions", | ||||
|         "icon_dark": "data:image/svg+xml;base64,CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iNzJweCIgaGVpZ2h0PSI3MnB4IiB2aWV3Qm94PSIwIDAgNzIgNzIiPgk8ZGVmcz4KICAgICAgICA8ZmlsdGVyIGlkPSJhIiB3aWR0aD0iMjAwJSIgaGVpZ2h0PSIyMDAlIj4KICAgICAgICAgICAgPGZlT2Zmc2V0IHJlc3VsdD0ib2ZmT3V0IiBpbj0iU291cmNlQWxwaGEiIGR5PSIyLjIiLz4KICAgICAgICAgICAgPGZlR2F1c3NpYW5CbHVyIHJlc3VsdD0iYmx1ck91dCIgaW49Im9mZk91dCIgc3RkRGV2aWF0aW9uPSIxLjUiLz4KICAgICAgICAgICAgPGZlQ29sb3JNYXRyaXggdmFsdWVzPSIwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwLjQgMCIvPgogICAgICAgICAgICA8ZmVNZXJnZT4KICAgICAgICAgICAgICAgIDxmZU1lcmdlTm9kZS8+CiAgICAgICAgICAgICAgICA8ZmVNZXJnZU5vZGUgaW49IlNvdXJjZUdyYXBoaWMiLz4KICAgICAgICAgICAgPC9mZU1lcmdlPgogICAgICAgIDwvZmlsdGVyPgogICAgPC9kZWZzPgo8cGF0aCBmaWxsPSIjZmZmZmZmIiBmaWx0ZXI9InVybCgjYSkiIGQ9Ik0zMS4wNTksNS4zOTVjMTYuODktMi43MjcsMzIuODE3LDguNzcyLDM1LjU0NSwyNS42NjJjMi43MjcsMTYuODktOC43NzIsMzIuODE3LTI1LjY2MiwzNS41NDUKCUMyNC4wNTEsNjkuMzI5LDguMTI0LDU3LjgzLDUuMzk3LDQwLjk0QzIuNjcsMjQuMDQ5LDE0LjE2OSw4LjEyMiwzMS4wNTksNS4zOTV6Ii8+CjxwYXRoIGZpbGw9IiMwMDY4YzMiIGQ9Ik01NS4zNjQsMTcuMjAyYy01LjA0Ny01LjE5Ny0xMS44MDItOC4xMDktMTkuMDItOC4yYy03LjE5MS0wLjA5LTEzLjk4NSwyLjY2OS0xOS4xNDksNy43NjgKCUMxMS45MSwyMS45ODksOSwyOC45NTUsOSwzNi4zOTJsMC4wNDQsMS4yNmMwLDEuMTgxLDAuOTYxLDIuMTQyLDIuMTQyLDIuMTQyczIuMTQxLTAuOTYsMi4xNDItMi4xNGwwLjAxLTAuOTEzCgljMC05Ljk0NSw4LjQ1My0yMC41OTMsMjEuMDM1LTIwLjU5M2MxMy4xMzIsMCwyMS4yNjEsMTAuNzEyLDIxLjI2MSwyMC42MzdjMCw1LjE3My0yLjA2Myw5LjkxOS01LjgwOCwxMy4zNjMKCUM0Ni4xNyw1My41MDksNDEuMjYsNTUuMzYsMzYsNTUuMzZjLTMuMTMsMC02LjIxOS0wLjc1NS04Ljk1OC0yLjE4NmwxOC44Ny04LjY4NmMxLjI2Ny0wLjU4MywyLjExNS0xLjgxLDIuMjEzLTMuMjAxCglzLTAuNTY5LTIuNzI1LTEuNzU0LTMuNDg3bC0xNS43MDYtOC40MTVjLTAuNTU0LTAuMzU3LTEuMjEzLTAuNDc3LTEuODU4LTAuMzM3Yy0wLjY0NCwwLjEzOS0xLjE5NSwwLjUyMS0xLjU1MiwxLjA3NQoJYy0wLjczNywxLjE0NC0wLjQwNiwyLjY3MywwLjcyMSwzLjM5N2w4LjQ1NCw2LjkyM2wtMTguNDE5LDguNDc4Yy0xLjQyNywwLjY1Ny0yLjI5OCwyLjEtMi4yMTgsMy42NzUKCWMwLjA0OCwwLjk0OSwwLjQ5MywxLjg4NCwxLjI1MywyLjYzNEMyMi4xNTgsNjAuMjY5LDI4Ljg0LDYzLDM1Ljk4NSw2M2MwLjQ0NSwwLDAuODkyLTAuMDExLDEuMzQxLTAuMDMyCgljMTQuMTU2LTAuNjczLDI1LjQzMi0xMi4zMTUsMjUuNjcxLTI2LjUwNUM2My4xMTgsMjkuMjM2LDYwLjQwNywyMi4zOTYsNTUuMzY0LDE3LjIwMnoiLz4KPC9zdmc+Cg==", | ||||
|         "icon_light": "data:image/svg+xml;base64,CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iNzJweCIgaGVpZ2h0PSI3MnB4IiB2aWV3Qm94PSIwIDAgNzIgNzIiPgk8ZGVmcz4KICAgICAgICA8ZmlsdGVyIGlkPSJhIiB3aWR0aD0iMjAwJSIgaGVpZ2h0PSIyMDAlIj4KICAgICAgICAgICAgPGZlT2Zmc2V0IHJlc3VsdD0ib2ZmT3V0IiBpbj0iU291cmNlQWxwaGEiIGR5PSIyLjIiLz4KICAgICAgICAgICAgPGZlR2F1c3NpYW5CbHVyIHJlc3VsdD0iYmx1ck91dCIgaW49Im9mZk91dCIgc3RkRGV2aWF0aW9uPSIxLjUiLz4KICAgICAgICAgICAgPGZlQ29sb3JNYXRyaXggdmFsdWVzPSIwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwLjQgMCIvPgogICAgICAgICAgICA8ZmVNZXJnZT4KICAgICAgICAgICAgICAgIDxmZU1lcmdlTm9kZS8+CiAgICAgICAgICAgICAgICA8ZmVNZXJnZU5vZGUgaW49IlNvdXJjZUdyYXBoaWMiLz4KICAgICAgICAgICAgPC9mZU1lcmdlPgogICAgICAgIDwvZmlsdGVyPgogICAgPC9kZWZzPgo8cGF0aCBmaWxsPSIjZmZmZmZmIiBmaWx0ZXI9InVybCgjYSkiIGQ9Ik0zMS4wNTksNS4zOTVjMTYuODktMi43MjcsMzIuODE3LDguNzcyLDM1LjU0NSwyNS42NjJjMi43MjcsMTYuODktOC43NzIsMzIuODE3LTI1LjY2MiwzNS41NDUKCUMyNC4wNTEsNjkuMzI5LDguMTI0LDU3LjgzLDUuMzk3LDQwLjk0QzIuNjcsMjQuMDQ5LDE0LjE2OSw4LjEyMiwzMS4wNTksNS4zOTV6Ii8+CjxwYXRoIGZpbGw9IiMwMDY4YzMiIGQ9Ik01NS4zNjQsMTcuMjAyYy01LjA0Ny01LjE5Ny0xMS44MDItOC4xMDktMTkuMDItOC4yYy03LjE5MS0wLjA5LTEzLjk4NSwyLjY2OS0xOS4xNDksNy43NjgKCUMxMS45MSwyMS45ODksOSwyOC45NTUsOSwzNi4zOTJsMC4wNDQsMS4yNmMwLDEuMTgxLDAuOTYxLDIuMTQyLDIuMTQyLDIuMTQyczIuMTQxLTAuOTYsMi4xNDItMi4xNGwwLjAxLTAuOTEzCgljMC05Ljk0NSw4LjQ1My0yMC41OTMsMjEuMDM1LTIwLjU5M2MxMy4xMzIsMCwyMS4yNjEsMTAuNzEyLDIxLjI2MSwyMC42MzdjMCw1LjE3My0yLjA2Myw5LjkxOS01LjgwOCwxMy4zNjMKCUM0Ni4xNyw1My41MDksNDEuMjYsNTUuMzYsMzYsNTUuMzZjLTMuMTMsMC02LjIxOS0wLjc1NS04Ljk1OC0yLjE4NmwxOC44Ny04LjY4NmMxLjI2Ny0wLjU4MywyLjExNS0xLjgxLDIuMjEzLTMuMjAxCglzLTAuNTY5LTIuNzI1LTEuNzU0LTMuNDg3bC0xNS43MDYtOC40MTVjLTAuNTU0LTAuMzU3LTEuMjEzLTAuNDc3LTEuODU4LTAuMzM3Yy0wLjY0NCwwLjEzOS0xLjE5NSwwLjUyMS0xLjU1MiwxLjA3NQoJYy0wLjczNywxLjE0NC0wLjQwNiwyLjY3MywwLjcyMSwzLjM5N2w4LjQ1NCw2LjkyM2wtMTguNDE5LDguNDc4Yy0xLjQyNywwLjY1Ny0yLjI5OCwyLjEtMi4yMTgsMy42NzUKCWMwLjA0OCwwLjk0OSwwLjQ5MywxLjg4NCwxLjI1MywyLjYzNEMyMi4xNTgsNjAuMjY5LDI4Ljg0LDYzLDM1Ljk4NSw2M2MwLjQ0NSwwLDAuODkyLTAuMDExLDEuMzQxLTAuMDMyCgljMTQuMTU2LTAuNjczLDI1LjQzMi0xMi4zMTUsMjUuNjcxLTI2LjUwNUM2My4xMTgsMjkuMjM2LDYwLjQwNywyMi4zOTYsNTUuMzY0LDE3LjIwMnoiLz4KPC9zdmc+Cg==" | ||||
|     } | ||||
| } | ||||
|     }, | ||||
|     "22248c4c-7a12-46e2-9a41-44291b373a4d": { | ||||
| 		"name": "LogMeOnce", | ||||
| 		"icon_dark": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcgdmVyc2lvbj0iMS4xIiB2aWV3Qm94PSIwIDAgMjA0OCAyMDQ4IiB3aWR0aD0iMTI4IiBoZWlnaHQ9IjEyOCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTSAwIDAgTCAtNSAxIEwgLTE1IDQgTCAtMzggMTEgTCAtNTcgMTggTCAtNzYgMjkgTCAtOTAgMzggTCAtMTA5IDUyIEwgLTExNiA1OSBMIC0xMjcgNjggTCAtMTQwIDgxIEwgLTE0OCA4OCBMIC0xNDkgOTEgTCAtMTUxIDkxIEwgLTE2MSAxMDEgTCAtMTY5IDEwOCBMIC0xNjkgMTEwIEwgLTE3MSAxMTAgTCAtMTgwIDExOSBMIC0xODggMTI2IEwgLTIwMSAxMzcgTCAtMjEyIDE0NiBMIC0yMjYgMTU3IEwgLTIzOSAxNjcgTCAtMjUwIDE3NSBMIC0yNzAgMTg4IEwgLTI5MSAyMDIgTCAtMzA5IDIxMiBMIC0zMzAgMjI0IEwgLTM1MyAyMzUgTCAtMzg2IDI1MCBMIC00MDcgMjU5IEwgLTQ0OSAyNzMgTCAtNDcyIDI4MSBMIC01MDIgMjg5IEwgLTU1MSAyOTggTCAtNjE1IDMwOSBMIC02NDcgMzE2IEwgLTY4MSAzMjQgTCAtNjk4IDMzMCBMIC03MTQgMzM4IEwgLTczNSAzNTEgTCAtNzQ1IDM1OCBMIC03NTYgMzY3IEwgLTc2NCAzNzUgTCAtNzY2IDM3OSBMIC03NjggMzc5IEwgLTc3MyAzODUgTCAtNzgxIDM5NSBMIC03OTEgNDEwIEwgLTgwMSA0MjggTCAtODEwIDQ0NyBMIC04MTYgNDY1IEwgLTgyMiA1MDAgTCAtODI3IDU0NSBMIC04MzAgNTkyIEwgLTgzMiA2NTMgTCAtODMyIDcxMiBMIC04MzEgNzQyIEwgLTgyOCA3OTQgTCAtODIyIDg2NiBMIC04MTYgOTI4IEwgLTgxMiA5NTcgTCAtODAwIDEwMjAgTCAtNzg5IDEwNzIgTCAtNzgzIDEwOTggTCAtNzY1IDExNjIgTCAtNzUxIDEyMDcgTCAtNzQ0IDEyMjUgTCAtNzM0IDEyNTQgTCAtNzIwIDEyOTAgTCAtNzA0IDEzMjggTCAtNjg5IDEzNjEgTCAtNjY4IDE0MDMgTCAtNjUzIDE0MzEgTCAtNjM5IDE0NTYgTCAtNjIwIDE0ODggTCAtNjA3IDE1MDkgTCAtNTk0IDE1MjkgTCAtNTg0IDE1NDQgTCAtNTcxIDE1NjMgTCAtNTU4IDE1ODEgTCAtNTQ0IDE2MDAgTCAtNTMzIDE2MTUgTCAtNTIwIDE2MzIgTCAtNTA3IDE2NDggTCAtNDc5IDE2ODIgTCAtNDcwIDE2OTMgTCAtNDYwIDE3MDQgTCAtNDQ5IDE3MTYgTCAtNDQyIDE3MjQgTCAtNDMyIDE3MzQgTCAtNDI1IDE3NDIgTCAtMzY4IDE3OTkgTCAtMzUxIDE4MTUgTCAtMzM1IDE4MzAgTCAtMzI3IDE4MzcgTCAtMzE0IDE4NDkgTCAtMjg4IDE4NzEgTCAtMjcxIDE4ODUgTCAtMjYxIDE4OTMgTCAtMjQ0IDE5MDcgTCAtMjI4IDE5MTkgTCAtMjE1IDE5MjkgTCAtMTk2IDE5NDMgTCAtMTg0IDE5NTIgTCAtMTY4IDE5NjMgTCAtMTQ4IDE5NzcgTCAtMTMzIDE5ODcgTCAtMTEyIDIwMDAgTCAtODggMjAxMyBMIC03MiAyMDIxIEwgLTUyIDIwMzAgTCAtMzIgMjAzNyBMIDEyIDIwNDggTCA2MCAyMDQ4IEwgNjEgMjA0NyBMIDczIDIwNDQgTCAxMDAgMjAzNyBMIDEyMSAyMDI5IEwgMTQ5IDIwMTUgTCAxNjcgMjAwNSBMIDE4NiAxOTk0IEwgMjEwIDE5NzkgTCAyMzEgMTk2NSBMIDI2NiAxOTQxIEwgMjg0IDE5MjggTCAyOTUgMTkyMCBMIDMwNiAxOTExIEwgMzI0IDE4OTggTCAzMzggMTg4NyBMIDM0OSAxODc3IEwgMzU4IDE4NzAgTCAzNjkgMTg2MCBMIDM4MCAxODUxIEwgMzgwIDE4NDkgTCAzODIgMTg0OSBMIDM4NSAxODQ2IEwgMzk2IDE4MzcgTCA0MDMgMTgzMCBMIDQxMSAxODIzIEwgNDE4IDE4MTYgTCA0MjYgMTgwOSBMIDQ3NSAxNzYwIEwgNDc3IDE3NTYgTCA0NzkgMTc1NiBMIDQ4NyAxNzQ3IEwgNTAzIDE3MzAgTCA1MTAgMTcyMiBMIDUxOSAxNzEyIEwgNTI2IDE3MDQgTCA1MzggMTY5MSBMIDU0NyAxNjgwIEwgNTU4IDE2NjcgTCA1NjYgMTY1NiBMIDU3OSAxNjQwIEwgNTkwIDE2MjYgTCA2MDYgMTYwNSBMIDYyMSAxNTgzIEwgNjMxIDE1NjkgTCA2NDUgMTU0OSBMIDY1NSAxNTMzIEwgNjcwIDE1MTAgTCA2ODUgMTQ4NSBMIDY5NiAxNDY3IEwgNzA2IDE0NDkgTCA3MzAgMTQwNSBMIDc0NSAxMzc0IEwgNzYyIDEzMzggTCA3NzQgMTMxMCBMIDc5MiAxMjY3IEwgODExIDEyMTYgTCA4MjcgMTE2NiBMIDg0MiAxMTE3IEwgODU4IDEwNTEgTCA4NzAgOTk2IEwgODc1IDk2NyBMIDg4MSA5MTcgTCA4ODYgODgyIEwgODkxIDg0NiBMIDg5NCA4MTIgTCA4OTYgNzc0IEwgODk2IDY1NSBMIDg5NSA2MzAgTCA4OTIgNTkzIEwgODg4IDU1OSBMIDg3NyA0NzAgTCA4NzIgNDQ1IEwgODY1IDQyNiBMIDg1NyA0MTEgTCA4NDcgMzk1IEwgODM0IDM3OCBMIDgxNCAzNTggTCA4MDAgMzQ3IEwgNzg1IDMzNyBMIDc2NiAzMjcgTCA3NTAgMzIxIEwgNzI3IDMxNSBMIDY5MyAzMDggTCA2NTAgMzAxIEwgNTk3IDI5MiBMIDU3MCAyODYgTCA1NDggMjgwIEwgNTE1IDI3MCBMIDQ4NyAyNjEgTCA0NTkgMjUwIEwgNDM4IDI0MSBMIDQxNCAyMzAgTCAzODIgMjEzIEwgMzU4IDE5OSBMIDM0NyAxOTIgTCAzMzAgMTgwIEwgMzEyIDE2NyBMIDMwMCAxNTcgTCAyODYgMTQ2IEwgMjcxIDEzMyBMIDI2MyAxMjYgTCAyNTEgMTE1IEwgMjQwIDEwNiBMIDIyOCA5NSBMIDIwMCA3MSBMIDE4OSA2MiBMIDE3NCA1MCBMIDE1OCAzOCBMIDE0MiAyNyBMIDEyNyAxOSBMIDExNSAxNCBMIDk1IDggTCA3MSAwIEwgMCAwIHogTSAzMCA2MjIgTCA1MSA2MjMgTCA3OSA2MjcgTCAxMDAgNjMzIEwgMTIxIDY0MSBMIDE0MSA2NTIgTCAxNTcgNjYzIEwgMTcyIDY3NiBMIDE4NiA2OTAgTCAxODYgNjkyIEwgMTg4IDY5MiBMIDIwNiA3MTYgTCAyMTYgNzM0IEwgMjIxIDc0NSBMIDIyNiA3NTggTCAyMzMgNzg0IEwgMjM2IDc5OCBMIDIzNyA4MDggTCAyMzcgODQwIEwgMjMzIDg2NSBMIDIyNiA4OTEgTCAyMTkgOTA5IEwgMjA5IDkyOCBMIDE5NSA5NDkgTCAxODYgOTU5IEwgMTc5IDk2NyBMIDE2NiA5ODAgTCAxNTUgOTg5IEwgMTQyIDk5OCBMIDEyNiAxMDA3IEwgMTEzIDEwMTIgTCAxMTQgMTAyNCBMIDEyMiAxMDYwIEwgMTM3IDExMjMgTCAxNTYgMTIwOCBMIDE3MCAxMjcxIEwgMTc0IDEyOTYgTCAxNzYgMTMxMCBMIDE3NiAxMzM1IEwgMTczIDEzNDkgTCAxNjYgMTM1OSBMIDE1NyAxMzY3IEwgMTQ2IDEzNzIgTCAxNDAgMTM3NCBMIDExOSAxMzc2IEwgNTEgMTM3NiBMIC0zMiAxMzc3IEwgLTY0IDEzNzcgTCAtNzggMTM3NCBMIC04NSAxMzcwIEwgLTk4IDEzNTkgTCAtMTA2IDEzNDkgTCAtMTEwIDEzNDEgTCAtMTEyIDEzMzMgTCAtMTEyIDEzMTggTCAtMTA3IDEyODggTCAtMTAwIDEyNTYgTCAtODUgMTE5NiBMIC02OSAxMTI0IEwgLTU3IDEwNzIgTCAtNTAgMTAzNiBMIC00NyAxMDExIEwgLTUzIDEwMDkgTCAtNjkgMTAwMSBMIC04MSA5OTQgTCAtOTMgOTg1IEwgLTEwMyA5NzYgTCAtMTExIDk2OSBMIC0xMjAgOTYwIEwgLTEzMSA5NDYgTCAtMTQyIDkyOSBMIC0xNTIgOTEwIEwgLTE2MCA4OTAgTCAtMTY2IDg3MCBMIC0xNjkgODUzIEwgLTE3MCA4NDQgTCAtMTcwIDgxNCBMIC0xNjcgNzk0IEwgLTE2MSA3NjcgTCAtMTU0IDc0NiBMIC0xMzcgNzEzIEwgLTEyNiA2OTggTCAtMTE5IDY5MCBMIC0xMDggNjc4IEwgLTkzIDY2NSBMIC03NyA2NTMgTCAtNTYgNjQxIEwgLTM4IDYzMyBMIC0xNiA2MjcgTCAzIDYyNCBMIDMwIDYyMiB6ICIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoOTkxKSIgc3R5bGU9ImZpbGw6I2ZmZmZmZiIgLz4KPC9zdmc+Cg==", | ||||
| 		"icon_light": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB2ZXJzaW9uPSIxLjEiIHZpZXdCb3g9IjAgMCAyMDQ4IDIwNDgiIHdpZHRoPSIxMjgiIGhlaWdodD0iMTI4IiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgo8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSg5OTEpIiBkPSJtMCAwaDcxbDI0IDggMjAgNiAxMiA1IDE1IDggMTYgMTEgMTYgMTIgMTUgMTIgMTEgOSAyOCAyNCAxMiAxMSAxMSA5IDEyIDExIDggNyAxNSAxMyAxNCAxMSAxMiAxMCAxOCAxMyAxNyAxMiAxMSA3IDI0IDE0IDMyIDE3IDI0IDExIDIxIDkgMjggMTEgMjggOSAzMyAxMCAyMiA2IDI3IDYgNTMgOSA0MyA3IDM0IDcgMjMgNiAxNiA2IDE5IDEwIDE1IDEwIDE0IDExIDIwIDIwIDEzIDE3IDEwIDE2IDggMTUgNyAxOSA1IDI1IDExIDg5IDQgMzQgMyAzNyAxIDI1djExOWwtMiAzOC0zIDM0LTUgMzYtNSAzNS02IDUwLTUgMjktMTIgNTUtMTYgNjYtMTUgNDktMTYgNTAtMTkgNTEtMTggNDMtMTIgMjgtMTcgMzYtMTUgMzEtMjQgNDQtMTAgMTgtMTEgMTgtMTUgMjUtMTUgMjMtMTAgMTYtMTQgMjAtMTAgMTQtMTUgMjItMTYgMjEtMTEgMTQtMTMgMTYtOCAxMS0xMSAxMy05IDExLTEyIDEzLTcgOC05IDEwLTcgOC0xNiAxNy04IDloLTJsLTIgNC00OSA0OS04IDctNyA3LTggNy03IDctMTEgOS0zIDNoLTJ2MmwtMTEgOS0xMSAxMC05IDctMTEgMTAtMTQgMTEtMTggMTMtMTEgOS0xMSA4LTE4IDEzLTM1IDI0LTIxIDE0LTI0IDE1LTE5IDExLTE4IDEwLTI4IDE0LTIxIDgtMjcgNy0xMiAzLTEgMWgtNDhsLTQ0LTExLTIwLTctMjAtOS0xNi04LTI0LTEzLTIxLTEzLTE1LTEwLTIwLTE0LTE2LTExLTEyLTktMTktMTQtMTMtMTAtMTYtMTItMTctMTQtMTAtOC0xNy0xNC0yNi0yMi0xMy0xMi04LTctMTYtMTUtMTctMTYtNTctNTctNy04LTEwLTEwLTctOC0xMS0xMi0xMC0xMS05LTExLTI4LTM0LTEzLTE2LTEzLTE3LTExLTE1LTE0LTE5LTEzLTE4LTEzLTE5LTEwLTE1LTEzLTIwLTEzLTIxLTE5LTMyLTE0LTI1LTE1LTI4LTIxLTQyLTE1LTMzLTE2LTM4LTE0LTM2LTEwLTI5LTctMTgtMTQtNDUtMTgtNjQtNi0yNi0xMS01Mi0xMi02My00LTI5LTYtNjItNi03Mi0zLTUyLTEtMzB2LTU5bDItNjEgMy00NyA1LTQ1IDYtMzUgNi0xOCA5LTE5IDEwLTE4IDEwLTE1IDgtMTAgNS02aDJsMi00IDgtOCAxMS05IDEwLTcgMjEtMTMgMTYtOCAxNy02IDM0LTggMzItNyA2NC0xMSA0OS05IDMwLTggMjMtOCA0Mi0xNCAyMS05IDMzLTE1IDIzLTExIDIxLTEyIDE4LTEwIDIxLTE0IDIwLTEzIDExLTggMTMtMTAgMTQtMTEgMTEtOSAxMy0xMSA4LTcgOS05aDJ2LTJsOC03IDEwLTEwaDJsMS0zIDgtNyAxMy0xMyAxMS05IDctNyAxOS0xNCAxNC05IDE5LTExIDE5LTcgMjMtNyAxMC0zeiIgZmlsbD0iI0YxODQyOSIvPgo8cGF0aCB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxMDIxLDYyMikiIGQ9Im0wIDAgMjEgMSAyOCA0IDIxIDYgMjEgOCAyMCAxMSAxNiAxMSAxNSAxMyAxNCAxNHYyaDJsMTggMjQgMTAgMTggNSAxMSA1IDEzIDcgMjYgMyAxNCAxIDEwdjMybC00IDI1LTcgMjYtNyAxOC0xMCAxOS0xNCAyMS05IDEwLTcgOC0xMyAxMy0xMSA5LTEzIDktMTYgOS0xMyA1IDEgMTIgOCAzNiAxNSA2MyAxOSA4NSAxNCA2MyA0IDI1IDIgMTR2MjVsLTMgMTQtNyAxMC05IDgtMTEgNS02IDItMjEgMmgtNjhsLTgzIDFoLTMybC0xNC0zLTctNC0xMy0xMS04LTEwLTQtOC0yLTh2LTE1bDUtMzAgNy0zMiAxNS02MCAxNi03MiAxMi01MiA3LTM2IDMtMjUtNi0yLTE2LTgtMTItNy0xMi05LTEwLTktOC03LTktOS0xMS0xNC0xMS0xNy0xMC0xOS04LTIwLTYtMjAtMy0xNy0xLTl2LTMwbDMtMjAgNi0yNyA3LTIxIDE3LTMzIDExLTE1IDctOCAxMS0xMiAxNS0xMyAxNi0xMiAyMS0xMiAxOC04IDIyLTYgMTktM3oiIGZpbGw9IiNGRUZGRkUiLz4KPC9zdmc+Cg==" | ||||
| 	} | ||||
| } | ||||
| @ -37,7 +37,7 @@ | ||||
| <tr> | ||||
|   <td style="padding: 20px; font-size: 12px; color: #212124;" align="center"> | ||||
|     {% blocktrans with expires=expires|naturaltime %} | ||||
|     If you did not request a password change, please ignore this Email. The link above is valid for {{ expires }}. | ||||
|     If you did not request a password change, please ignore this email. The link above is valid for {{ expires }}. | ||||
|     {% endblocktrans %} | ||||
|   </td> | ||||
| </tr> | ||||
|  | ||||
| @ -5,7 +5,7 @@ You recently requested to change your password for your authentik account. Use t | ||||
| {% endblocktrans %} | ||||
| {{ url }} | ||||
| {% blocktrans with expires=expires|naturaltime %} | ||||
| If you did not request a password change, please ignore this Email. The link above is valid for {{ expires }}. | ||||
| If you did not request a password change, please ignore this email. The link above is valid for {{ expires }}. | ||||
| {% endblocktrans %} | ||||
|  | ||||
| --  | ||||
|  | ||||
| @ -26,6 +26,7 @@ from authentik.flows.models import FlowDesignation | ||||
| from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER | ||||
| from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView | ||||
| from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_GET | ||||
| from authentik.lib.avatars import DEFAULT_AVATAR | ||||
| from authentik.lib.utils.reflection import all_subclasses | ||||
| from authentik.lib.utils.urls import reverse_with_qs | ||||
| from authentik.root.middleware import ClientIPMiddleware | ||||
| @ -76,7 +77,7 @@ class IdentificationChallenge(Challenge): | ||||
|     allow_show_password = BooleanField(default=False) | ||||
|     application_pre = CharField(required=False) | ||||
|     flow_designation = ChoiceField(FlowDesignation.choices) | ||||
|     captcha_stage = CaptchaChallenge(required=False) | ||||
|     captcha_stage = CaptchaChallenge(required=False, allow_null=True) | ||||
|  | ||||
|     enroll_url = CharField(required=False) | ||||
|     recovery_url = CharField(required=False) | ||||
| @ -224,6 +225,8 @@ class IdentificationStageView(ChallengeStageView): | ||||
|                         "js_url": current_stage.captcha_stage.js_url, | ||||
|                         "site_key": current_stage.captcha_stage.public_key, | ||||
|                         "interactive": current_stage.captcha_stage.interactive, | ||||
|                         "pending_user": "", | ||||
|                         "pending_user_avatar": DEFAULT_AVATAR, | ||||
|                     } | ||||
|                     if current_stage.captcha_stage | ||||
|                     else None | ||||
|  | ||||
							
								
								
									
										0
									
								
								authentik/stages/redirect/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/stages/redirect/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										42
									
								
								authentik/stages/redirect/api.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								authentik/stages/redirect/api.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,42 @@ | ||||
| """RedirectStage API Views""" | ||||
|  | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from rest_framework.serializers import ValidationError | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
|  | ||||
| from authentik.core.api.used_by import UsedByMixin | ||||
| from authentik.flows.api.stages import StageSerializer | ||||
| from authentik.stages.redirect.models import RedirectMode, RedirectStage | ||||
|  | ||||
|  | ||||
| class RedirectStageSerializer(StageSerializer): | ||||
|     """RedirectStage Serializer""" | ||||
|  | ||||
|     def validate(self, attrs): | ||||
|         mode = attrs.get("mode") | ||||
|         target_static = attrs.get("target_static") | ||||
|         target_flow = attrs.get("target_flow") | ||||
|         if mode == RedirectMode.STATIC and not target_static: | ||||
|             raise ValidationError(_("Target URL should be present when mode is Static.")) | ||||
|         if mode == RedirectMode.FLOW and not target_flow: | ||||
|             raise ValidationError(_("Target Flow should be present when mode is Flow.")) | ||||
|         return attrs | ||||
|  | ||||
|     class Meta: | ||||
|         model = RedirectStage | ||||
|         fields = StageSerializer.Meta.fields + [ | ||||
|             "keep_context", | ||||
|             "mode", | ||||
|             "target_static", | ||||
|             "target_flow", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class RedirectStageViewSet(UsedByMixin, ModelViewSet): | ||||
|     """RedirectStage Viewset""" | ||||
|  | ||||
|     queryset = RedirectStage.objects.all() | ||||
|     serializer_class = RedirectStageSerializer | ||||
|     filterset_fields = ["name"] | ||||
|     search_fields = ["name"] | ||||
|     ordering = ["name"] | ||||
							
								
								
									
										11
									
								
								authentik/stages/redirect/apps.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								authentik/stages/redirect/apps.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,11 @@ | ||||
| """authentik redirect app""" | ||||
|  | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class AuthentikStageRedirectConfig(AppConfig): | ||||
|     """authentik redirect app""" | ||||
|  | ||||
|     name = "authentik.stages.redirect" | ||||
|     label = "authentik_stages_redirect" | ||||
|     verbose_name = "authentik Stages.Redirect" | ||||
							
								
								
									
										49
									
								
								authentik/stages/redirect/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								authentik/stages/redirect/migrations/0001_initial.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,49 @@ | ||||
| # Generated by Django 5.0.10 on 2024-12-11 14:40 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("authentik_flows", "0027_auto_20231028_1424"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name="RedirectStage", | ||||
|             fields=[ | ||||
|                 ( | ||||
|                     "stage_ptr", | ||||
|                     models.OneToOneField( | ||||
|                         auto_created=True, | ||||
|                         on_delete=django.db.models.deletion.CASCADE, | ||||
|                         parent_link=True, | ||||
|                         primary_key=True, | ||||
|                         serialize=False, | ||||
|                         to="authentik_flows.stage", | ||||
|                     ), | ||||
|                 ), | ||||
|                 ("keep_context", models.BooleanField(default=True)), | ||||
|                 ("mode", models.TextField(choices=[("static", "Static"), ("flow", "Flow")])), | ||||
|                 ("target_static", models.CharField(blank=True, default="")), | ||||
|                 ( | ||||
|                     "target_flow", | ||||
|                     models.ForeignKey( | ||||
|                         blank=True, | ||||
|                         null=True, | ||||
|                         on_delete=django.db.models.deletion.SET_NULL, | ||||
|                         to="authentik_flows.flow", | ||||
|                     ), | ||||
|                 ), | ||||
|             ], | ||||
|             options={ | ||||
|                 "verbose_name": "Redirect Stage", | ||||
|                 "verbose_name_plural": "Redirect Stages", | ||||
|             }, | ||||
|             bases=("authentik_flows.stage",), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										0
									
								
								authentik/stages/redirect/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								authentik/stages/redirect/migrations/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										49
									
								
								authentik/stages/redirect/models.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								authentik/stages/redirect/models.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,49 @@ | ||||
| """authentik redirect stage""" | ||||
|  | ||||
| from django.db import models | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from django.views import View | ||||
| from rest_framework.serializers import BaseSerializer | ||||
|  | ||||
| from authentik.flows.models import Flow, Stage | ||||
|  | ||||
|  | ||||
| class RedirectMode(models.TextChoices): | ||||
|     """Mode a Redirect stage can operate in""" | ||||
|  | ||||
|     STATIC = "static" | ||||
|     FLOW = "flow" | ||||
|  | ||||
|  | ||||
| class RedirectStage(Stage): | ||||
|     """Redirect the user to another flow, potentially with all gathered context""" | ||||
|  | ||||
|     keep_context = models.BooleanField(default=True) | ||||
|     mode = models.TextField(choices=RedirectMode.choices) | ||||
|     target_static = models.CharField(blank=True, default="") | ||||
|     target_flow = models.ForeignKey( | ||||
|         Flow, | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|  | ||||
|     @property | ||||
|     def serializer(self) -> type[BaseSerializer]: | ||||
|         from authentik.stages.redirect.api import RedirectStageSerializer | ||||
|  | ||||
|         return RedirectStageSerializer | ||||
|  | ||||
|     @property | ||||
|     def view(self) -> type[View]: | ||||
|         from authentik.stages.redirect.stage import RedirectStageView | ||||
|  | ||||
|         return RedirectStageView | ||||
|  | ||||
|     @property | ||||
|     def component(self) -> str: | ||||
|         return "ak-stage-redirect-form" | ||||
|  | ||||
|     class Meta: | ||||
|         verbose_name = _("Redirect Stage") | ||||
|         verbose_name_plural = _("Redirect Stages") | ||||
							
								
								
									
										110
									
								
								authentik/stages/redirect/stage.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								authentik/stages/redirect/stage.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,110 @@ | ||||
| """authentik redirect stage""" | ||||
|  | ||||
| from urllib.parse import urlsplit | ||||
|  | ||||
| from django.http.response import HttpResponse | ||||
| from rest_framework.fields import CharField | ||||
|  | ||||
| from authentik.flows.challenge import ( | ||||
|     Challenge, | ||||
|     ChallengeResponse, | ||||
|     RedirectChallenge, | ||||
| ) | ||||
| from authentik.flows.exceptions import FlowNonApplicableException | ||||
| from authentik.flows.models import ( | ||||
|     Flow, | ||||
| ) | ||||
| from authentik.flows.planner import ( | ||||
|     PLAN_CONTEXT_IS_REDIRECTED, | ||||
|     PLAN_CONTEXT_REDIRECT_STAGE_TARGET, | ||||
|     FlowPlanner, | ||||
| ) | ||||
| from authentik.flows.stage import ChallengeStageView | ||||
| from authentik.flows.views.executor import SESSION_KEY_PLAN, InvalidStageError | ||||
| from authentik.lib.utils.urls import reverse_with_qs | ||||
| from authentik.stages.redirect.models import RedirectMode, RedirectStage | ||||
|  | ||||
| URL_SCHEME_FLOW = "ak-flow" | ||||
|  | ||||
|  | ||||
| class RedirectChallengeResponse(ChallengeResponse): | ||||
|     """Redirect challenge response""" | ||||
|  | ||||
|     component = CharField(default="xak-flow-redirect") | ||||
|     to = CharField() | ||||
|  | ||||
|  | ||||
| class RedirectStageView(ChallengeStageView): | ||||
|     """Redirect stage to redirect to other Flows with context""" | ||||
|  | ||||
|     response_class = RedirectChallengeResponse | ||||
|  | ||||
|     def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: | ||||
|         return self.executor.stage_ok() | ||||
|  | ||||
|     def parse_target(self, target: str) -> str | Flow: | ||||
|         parsed_target = urlsplit(target) | ||||
|  | ||||
|         if parsed_target.scheme != URL_SCHEME_FLOW: | ||||
|             return target | ||||
|  | ||||
|         flow = Flow.objects.filter(slug=parsed_target.netloc).first() | ||||
|         if not flow: | ||||
|             self.logger.warning( | ||||
|                 f"Flow set by {PLAN_CONTEXT_REDIRECT_STAGE_TARGET} does not exist", | ||||
|                 flow_slug=parsed_target.path, | ||||
|             ) | ||||
|         return flow | ||||
|  | ||||
|     def switch_flow_with_context(self, flow: Flow, keep_context=True) -> str: | ||||
|         """Switch to another flow, optionally keeping all context""" | ||||
|         self.logger.info( | ||||
|             "f(exec): Switching to new flow", new_flow=flow.slug, keep_context=keep_context | ||||
|         ) | ||||
|         planner = FlowPlanner(flow) | ||||
|         planner.use_cache = False | ||||
|         default_context = self.executor.plan.context if keep_context else {} | ||||
|         try: | ||||
|             default_context[PLAN_CONTEXT_IS_REDIRECTED] = self.executor.flow | ||||
|             plan = planner.plan(self.request, default_context) | ||||
|         except FlowNonApplicableException as exc: | ||||
|             raise InvalidStageError() from exc | ||||
|         self.request.session[SESSION_KEY_PLAN] = plan | ||||
|         kwargs = self.executor.kwargs | ||||
|         kwargs.update({"flow_slug": flow.slug}) | ||||
|         return reverse_with_qs("authentik_core:if-flow", self.request.GET, kwargs=kwargs) | ||||
|  | ||||
|     def get_challenge(self, *args, **kwargs) -> Challenge: | ||||
|         """Get the redirect target. Prioritize `redirect_stage_target` if present.""" | ||||
|  | ||||
|         current_stage: RedirectStage = self.executor.current_stage | ||||
|         target: str | Flow = "" | ||||
|  | ||||
|         target_url_override = self.executor.plan.context.get(PLAN_CONTEXT_REDIRECT_STAGE_TARGET, "") | ||||
|         if target_url_override: | ||||
|             target = self.parse_target(target_url_override) | ||||
|         # `target` is falsy if the override was to a Flow but that Flow doesn't exist. | ||||
|         if not target: | ||||
|             if current_stage.mode == RedirectMode.STATIC: | ||||
|                 target = current_stage.target_static | ||||
|             if current_stage.mode == RedirectMode.FLOW: | ||||
|                 target = current_stage.target_flow | ||||
|  | ||||
|         if isinstance(target, str): | ||||
|             redirect_to = target | ||||
|         else: | ||||
|             redirect_to = self.switch_flow_with_context( | ||||
|                 target, keep_context=current_stage.keep_context | ||||
|             ) | ||||
|  | ||||
|         if not redirect_to: | ||||
|             raise InvalidStageError( | ||||
|                 "No target found for Redirect stage. The stage's target_flow may have been deleted." | ||||
|             ) | ||||
|  | ||||
|         return RedirectChallenge( | ||||
|             data={ | ||||
|                 "component": "xak-flow-redirect", | ||||
|                 "to": redirect_to, | ||||
|             } | ||||
|         ) | ||||
							
								
								
									
										172
									
								
								authentik/stages/redirect/tests.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								authentik/stages/redirect/tests.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,172 @@ | ||||
| """Test Redirect stage""" | ||||
|  | ||||
| from django.urls.base import reverse | ||||
| from rest_framework.exceptions import ValidationError | ||||
|  | ||||
| from authentik.core.tests.utils import create_test_flow | ||||
| from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignation, FlowStageBinding | ||||
| from authentik.flows.tests import FlowTestCase | ||||
| from authentik.lib.generators import generate_id | ||||
| from authentik.policies.expression.models import ExpressionPolicy | ||||
| from authentik.policies.models import PolicyBinding | ||||
| from authentik.stages.dummy.models import DummyStage | ||||
| from authentik.stages.redirect.api import RedirectStageSerializer | ||||
| from authentik.stages.redirect.models import RedirectMode, RedirectStage | ||||
|  | ||||
| URL = "https://url.test/" | ||||
| URL_OVERRIDE = "https://urloverride.test/" | ||||
|  | ||||
|  | ||||
| class TestRedirectStage(FlowTestCase): | ||||
|     """Test Redirect stage API""" | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|         self.target_flow = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|         self.dummy_stage = DummyStage.objects.create(name="dummy") | ||||
|         FlowStageBinding.objects.create(target=self.target_flow, stage=self.dummy_stage, order=0) | ||||
|         self.flow = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|         self.stage = RedirectStage.objects.create( | ||||
|             name="redirect", | ||||
|             keep_context=True, | ||||
|             mode=RedirectMode.STATIC, | ||||
|             target_static=URL, | ||||
|             target_flow=self.target_flow, | ||||
|         ) | ||||
|         self.binding = FlowStageBinding.objects.create( | ||||
|             target=self.flow, | ||||
|             stage=self.stage, | ||||
|             order=0, | ||||
|         ) | ||||
|  | ||||
|     def test_static(self): | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|  | ||||
|         self.assertStageRedirects(response, URL) | ||||
|  | ||||
|     def test_flow(self): | ||||
|         self.stage.mode = RedirectMode.FLOW | ||||
|         self.stage.save() | ||||
|  | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|  | ||||
|         self.assertStageRedirects( | ||||
|             response, reverse("authentik_core:if-flow", kwargs={"flow_slug": self.target_flow.slug}) | ||||
|         ) | ||||
|  | ||||
|     def test_override_static(self): | ||||
|         policy = ExpressionPolicy.objects.create( | ||||
|             name=generate_id(), | ||||
|             expression=f"context['flow_plan'].context['redirect_stage_target'] = " | ||||
|             f"'{URL_OVERRIDE}'; return True", | ||||
|         ) | ||||
|         PolicyBinding.objects.create(policy=policy, target=self.binding, order=0) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|  | ||||
|         self.assertStageRedirects(response, URL_OVERRIDE) | ||||
|  | ||||
|     def test_override_flow(self): | ||||
|         target_flow_override = create_test_flow(FlowDesignation.AUTHENTICATION) | ||||
|         dummy_stage_override = DummyStage.objects.create(name="dummy_override") | ||||
|         FlowStageBinding.objects.create( | ||||
|             target=target_flow_override, stage=dummy_stage_override, order=0 | ||||
|         ) | ||||
|         policy = ExpressionPolicy.objects.create( | ||||
|             name=generate_id(), | ||||
|             expression=f"context['flow_plan'].context['redirect_stage_target'] = " | ||||
|             f"'ak-flow://{target_flow_override.slug}'; return True", | ||||
|         ) | ||||
|         PolicyBinding.objects.create(policy=policy, target=self.binding, order=0) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|  | ||||
|         self.assertStageRedirects( | ||||
|             response, | ||||
|             reverse("authentik_core:if-flow", kwargs={"flow_slug": target_flow_override.slug}), | ||||
|         ) | ||||
|  | ||||
|     def test_override_nonexistant_flow(self): | ||||
|         policy = ExpressionPolicy.objects.create( | ||||
|             name=generate_id(), | ||||
|             expression="context['flow_plan'].context['redirect_stage_target'] = " | ||||
|             "'ak-flow://nonexistent'; return True", | ||||
|         ) | ||||
|         PolicyBinding.objects.create(policy=policy, target=self.binding, order=0) | ||||
|  | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|  | ||||
|         self.assertStageRedirects(response, URL) | ||||
|  | ||||
|     def test_target_flow_requires_redirect(self): | ||||
|         self.target_flow.authentication = FlowAuthenticationRequirement.REQUIRE_REDIRECT | ||||
|         self.target_flow.save() | ||||
|         self.stage.mode = RedirectMode.FLOW | ||||
|         self.stage.save() | ||||
|  | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|  | ||||
|         self.assertStageRedirects( | ||||
|             response, reverse("authentik_core:if-flow", kwargs={"flow_slug": self.target_flow.slug}) | ||||
|         ) | ||||
|  | ||||
|     def test_target_flow_non_applicable(self): | ||||
|         self.target_flow.authentication = FlowAuthenticationRequirement.REQUIRE_AUTHENTICATED | ||||
|         self.target_flow.save() | ||||
|         self.stage.mode = RedirectMode.FLOW | ||||
|         self.stage.save() | ||||
|  | ||||
|         response = self.client.get( | ||||
|             reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) | ||||
|         ) | ||||
|  | ||||
|         self.assertStageResponse(response, component="ak-stage-access-denied") | ||||
|  | ||||
|     def test_serializer(self): | ||||
|         with self.assertRaises(ValidationError): | ||||
|             RedirectStageSerializer( | ||||
|                 data={ | ||||
|                     "name": generate_id(20), | ||||
|                     "mode": RedirectMode.STATIC, | ||||
|                 } | ||||
|             ).is_valid(raise_exception=True) | ||||
|  | ||||
|         self.assertTrue( | ||||
|             RedirectStageSerializer( | ||||
|                 data={ | ||||
|                     "name": generate_id(20), | ||||
|                     "mode": RedirectMode.STATIC, | ||||
|                     "target_static": URL, | ||||
|                 } | ||||
|             ).is_valid(raise_exception=True) | ||||
|         ) | ||||
|  | ||||
|         with self.assertRaises(ValidationError): | ||||
|             RedirectStageSerializer( | ||||
|                 data={ | ||||
|                     "name": generate_id(20), | ||||
|                     "mode": RedirectMode.FLOW, | ||||
|                 } | ||||
|             ).is_valid(raise_exception=True) | ||||
|  | ||||
|         self.assertTrue( | ||||
|             RedirectStageSerializer( | ||||
|                 data={ | ||||
|                     "name": generate_id(20), | ||||
|                     "mode": RedirectMode.FLOW, | ||||
|                     "target_flow": create_test_flow().flow_uuid, | ||||
|                 } | ||||
|             ).is_valid(raise_exception=True) | ||||
|         ) | ||||
							
								
								
									
										5
									
								
								authentik/stages/redirect/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								authentik/stages/redirect/urls.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| """API URLs""" | ||||
|  | ||||
| from authentik.stages.redirect.api import RedirectStageViewSet | ||||
|  | ||||
| api_urlpatterns = [("stages/redirect", RedirectStageViewSet)] | ||||
| @ -2,7 +2,7 @@ | ||||
|     "$schema": "http://json-schema.org/draft-07/schema", | ||||
|     "$id": "https://goauthentik.io/blueprints/schema.json", | ||||
|     "type": "object", | ||||
|     "title": "authentik 2024.10.4 Blueprint schema", | ||||
|     "title": "authentik 2024.12.0 Blueprint schema", | ||||
|     "required": [ | ||||
|         "version", | ||||
|         "entries" | ||||
| @ -2801,6 +2801,46 @@ | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                     { | ||||
|                         "type": "object", | ||||
|                         "required": [ | ||||
|                             "model", | ||||
|                             "identifiers" | ||||
|                         ], | ||||
|                         "properties": { | ||||
|                             "model": { | ||||
|                                 "const": "authentik_stages_redirect.redirectstage" | ||||
|                             }, | ||||
|                             "id": { | ||||
|                                 "type": "string" | ||||
|                             }, | ||||
|                             "state": { | ||||
|                                 "type": "string", | ||||
|                                 "enum": [ | ||||
|                                     "absent", | ||||
|                                     "present", | ||||
|                                     "created", | ||||
|                                     "must_created" | ||||
|                                 ], | ||||
|                                 "default": "present" | ||||
|                             }, | ||||
|                             "conditions": { | ||||
|                                 "type": "array", | ||||
|                                 "items": { | ||||
|                                     "type": "boolean" | ||||
|                                 } | ||||
|                             }, | ||||
|                             "permissions": { | ||||
|                                 "$ref": "#/$defs/model_authentik_stages_redirect.redirectstage_permissions" | ||||
|                             }, | ||||
|                             "attrs": { | ||||
|                                 "$ref": "#/$defs/model_authentik_stages_redirect.redirectstage" | ||||
|                             }, | ||||
|                             "identifiers": { | ||||
|                                 "$ref": "#/$defs/model_authentik_stages_redirect.redirectstage" | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                     { | ||||
|                         "type": "object", | ||||
|                         "required": [ | ||||
| @ -3161,6 +3201,46 @@ | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                     { | ||||
|                         "type": "object", | ||||
|                         "required": [ | ||||
|                             "model", | ||||
|                             "identifiers" | ||||
|                         ], | ||||
|                         "properties": { | ||||
|                             "model": { | ||||
|                                 "const": "authentik_core.applicationentitlement" | ||||
|                             }, | ||||
|                             "id": { | ||||
|                                 "type": "string" | ||||
|                             }, | ||||
|                             "state": { | ||||
|                                 "type": "string", | ||||
|                                 "enum": [ | ||||
|                                     "absent", | ||||
|                                     "present", | ||||
|                                     "created", | ||||
|                                     "must_created" | ||||
|                                 ], | ||||
|                                 "default": "present" | ||||
|                             }, | ||||
|                             "conditions": { | ||||
|                                 "type": "array", | ||||
|                                 "items": { | ||||
|                                     "type": "boolean" | ||||
|                                 } | ||||
|                             }, | ||||
|                             "permissions": { | ||||
|                                 "$ref": "#/$defs/model_authentik_core.applicationentitlement_permissions" | ||||
|                             }, | ||||
|                             "attrs": { | ||||
|                                 "$ref": "#/$defs/model_authentik_core.applicationentitlement" | ||||
|                             }, | ||||
|                             "identifiers": { | ||||
|                                 "$ref": "#/$defs/model_authentik_core.applicationentitlement" | ||||
|                             } | ||||
|                         } | ||||
|                     }, | ||||
|                     { | ||||
|                         "type": "object", | ||||
|                         "required": [ | ||||
| @ -3804,8 +3884,7 @@ | ||||
|                     { | ||||
|                         "type": "object", | ||||
|                         "required": [ | ||||
|                             "model", | ||||
|                             "identifiers" | ||||
|                             "model" | ||||
|                         ], | ||||
|                         "properties": { | ||||
|                             "model": { | ||||
| @ -3835,9 +3914,6 @@ | ||||
|                             }, | ||||
|                             "attrs": { | ||||
|                                 "$ref": "#/$defs/model_authentik_blueprints.metaapplyblueprint" | ||||
|                             }, | ||||
|                             "identifiers": { | ||||
|                                 "$ref": "#/$defs/model_authentik_blueprints.metaapplyblueprint" | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| @ -4023,6 +4099,7 @@ | ||||
|                         "require_authenticated", | ||||
|                         "require_unauthenticated", | ||||
|                         "require_superuser", | ||||
|                         "require_redirect", | ||||
|                         "require_outpost" | ||||
|                     ], | ||||
|                     "title": "Authentication", | ||||
| @ -4493,6 +4570,7 @@ | ||||
|                         "authentik.stages.invitation", | ||||
|                         "authentik.stages.password", | ||||
|                         "authentik.stages.prompt", | ||||
|                         "authentik.stages.redirect", | ||||
|                         "authentik.stages.user_delete", | ||||
|                         "authentik.stages.user_login", | ||||
|                         "authentik.stages.user_logout", | ||||
| @ -4588,6 +4666,7 @@ | ||||
|                         "authentik_stages_password.passwordstage", | ||||
|                         "authentik_stages_prompt.prompt", | ||||
|                         "authentik_stages_prompt.promptstage", | ||||
|                         "authentik_stages_redirect.redirectstage", | ||||
|                         "authentik_stages_user_delete.userdeletestage", | ||||
|                         "authentik_stages_user_login.userloginstage", | ||||
|                         "authentik_stages_user_logout.userlogoutstage", | ||||
| @ -4597,6 +4676,7 @@ | ||||
|                         "authentik_core.group", | ||||
|                         "authentik_core.user", | ||||
|                         "authentik_core.application", | ||||
|                         "authentik_core.applicationentitlement", | ||||
|                         "authentik_core.token", | ||||
|                         "authentik_enterprise.license", | ||||
|                         "authentik_providers_google_workspace.googleworkspaceprovider", | ||||
| @ -6326,6 +6406,7 @@ | ||||
|                             "authentik_brands.delete_brand", | ||||
|                             "authentik_brands.view_brand", | ||||
|                             "authentik_core.add_application", | ||||
|                             "authentik_core.add_applicationentitlement", | ||||
|                             "authentik_core.add_authenticatedsession", | ||||
|                             "authentik_core.add_group", | ||||
|                             "authentik_core.add_groupsourceconnection", | ||||
| @ -6338,6 +6419,7 @@ | ||||
|                             "authentik_core.add_usersourceconnection", | ||||
|                             "authentik_core.assign_user_permissions", | ||||
|                             "authentik_core.change_application", | ||||
|                             "authentik_core.change_applicationentitlement", | ||||
|                             "authentik_core.change_authenticatedsession", | ||||
|                             "authentik_core.change_group", | ||||
|                             "authentik_core.change_groupsourceconnection", | ||||
| @ -6348,6 +6430,7 @@ | ||||
|                             "authentik_core.change_user", | ||||
|                             "authentik_core.change_usersourceconnection", | ||||
|                             "authentik_core.delete_application", | ||||
|                             "authentik_core.delete_applicationentitlement", | ||||
|                             "authentik_core.delete_authenticatedsession", | ||||
|                             "authentik_core.delete_group", | ||||
|                             "authentik_core.delete_groupsourceconnection", | ||||
| @ -6363,6 +6446,7 @@ | ||||
|                             "authentik_core.reset_user_password", | ||||
|                             "authentik_core.unassign_user_permissions", | ||||
|                             "authentik_core.view_application", | ||||
|                             "authentik_core.view_applicationentitlement", | ||||
|                             "authentik_core.view_authenticatedsession", | ||||
|                             "authentik_core.view_group", | ||||
|                             "authentik_core.view_groupsourceconnection", | ||||
| @ -6813,6 +6897,10 @@ | ||||
|                             "authentik_stages_prompt.delete_promptstage", | ||||
|                             "authentik_stages_prompt.view_prompt", | ||||
|                             "authentik_stages_prompt.view_promptstage", | ||||
|                             "authentik_stages_redirect.add_redirectstage", | ||||
|                             "authentik_stages_redirect.change_redirectstage", | ||||
|                             "authentik_stages_redirect.delete_redirectstage", | ||||
|                             "authentik_stages_redirect.view_redirectstage", | ||||
|                             "authentik_stages_source.add_sourcestage", | ||||
|                             "authentik_stages_source.change_sourcestage", | ||||
|                             "authentik_stages_source.delete_sourcestage", | ||||
| @ -6976,6 +7064,16 @@ | ||||
|                     "title": "Krb5 conf", | ||||
|                     "description": "Custom krb5.conf to use. Uses the system one by default" | ||||
|                 }, | ||||
|                 "kadmin_type": { | ||||
|                     "type": "string", | ||||
|                     "enum": [ | ||||
|                         "MIT", | ||||
|                         "Heimdal", | ||||
|                         "other" | ||||
|                     ], | ||||
|                     "title": "Kadmin type", | ||||
|                     "description": "KAdmin server type" | ||||
|                 }, | ||||
|                 "sync_users": { | ||||
|                     "type": "boolean", | ||||
|                     "title": "Sync users", | ||||
| @ -7113,6 +7211,10 @@ | ||||
|                     "type": "integer", | ||||
|                     "title": "User" | ||||
|                 }, | ||||
|                 "source": { | ||||
|                     "type": "integer", | ||||
|                     "title": "Source" | ||||
|                 }, | ||||
|                 "identifier": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
| @ -7155,6 +7257,20 @@ | ||||
|         "model_authentik_sources_kerberos.groupkerberossourceconnection": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|                 "group": { | ||||
|                     "type": "string", | ||||
|                     "format": "uuid", | ||||
|                     "title": "Group" | ||||
|                 }, | ||||
|                 "source": { | ||||
|                     "type": "integer", | ||||
|                     "title": "Source" | ||||
|                 }, | ||||
|                 "identifier": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
|                     "title": "Identifier" | ||||
|                 }, | ||||
|                 "icon": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
| @ -7698,6 +7814,14 @@ | ||||
|         "model_authentik_sources_oauth.useroauthsourceconnection": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|                 "user": { | ||||
|                     "type": "integer", | ||||
|                     "title": "User" | ||||
|                 }, | ||||
|                 "source": { | ||||
|                     "type": "integer", | ||||
|                     "title": "Source" | ||||
|                 }, | ||||
|                 "identifier": { | ||||
|                     "type": "string", | ||||
|                     "maxLength": 255, | ||||
| @ -7748,6 +7872,20 @@ | ||||
|         "model_authentik_sources_oauth.groupoauthsourceconnection": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|                 "group": { | ||||
|                     "type": "string", | ||||
|                     "format": "uuid", | ||||
|                     "title": "Group" | ||||
|                 }, | ||||
|                 "source": { | ||||
|                     "type": "integer", | ||||
|                     "title": "Source" | ||||
|                 }, | ||||
|                 "identifier": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
|                     "title": "Identifier" | ||||
|                 }, | ||||
|                 "icon": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
| @ -7981,6 +8119,14 @@ | ||||
|         "model_authentik_sources_plex.userplexsourceconnection": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|                 "user": { | ||||
|                     "type": "integer", | ||||
|                     "title": "User" | ||||
|                 }, | ||||
|                 "source": { | ||||
|                     "type": "integer", | ||||
|                     "title": "Source" | ||||
|                 }, | ||||
|                 "identifier": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
| @ -8028,6 +8174,20 @@ | ||||
|         "model_authentik_sources_plex.groupplexsourceconnection": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|                 "group": { | ||||
|                     "type": "string", | ||||
|                     "format": "uuid", | ||||
|                     "title": "Group" | ||||
|                 }, | ||||
|                 "source": { | ||||
|                     "type": "integer", | ||||
|                     "title": "Source" | ||||
|                 }, | ||||
|                 "identifier": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
|                     "title": "Identifier" | ||||
|                 }, | ||||
|                 "icon": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
| @ -8338,6 +8498,14 @@ | ||||
|         "model_authentik_sources_saml.usersamlsourceconnection": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|                 "user": { | ||||
|                     "type": "integer", | ||||
|                     "title": "User" | ||||
|                 }, | ||||
|                 "source": { | ||||
|                     "type": "integer", | ||||
|                     "title": "Source" | ||||
|                 }, | ||||
|                 "identifier": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
| @ -8380,6 +8548,20 @@ | ||||
|         "model_authentik_sources_saml.groupsamlsourceconnection": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|                 "group": { | ||||
|                     "type": "string", | ||||
|                     "format": "uuid", | ||||
|                     "title": "Group" | ||||
|                 }, | ||||
|                 "source": { | ||||
|                     "type": "integer", | ||||
|                     "title": "Source" | ||||
|                 }, | ||||
|                 "identifier": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
|                     "title": "Identifier" | ||||
|                 }, | ||||
|                 "icon": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
| @ -11475,6 +11657,146 @@ | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         "model_authentik_stages_redirect.redirectstage": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|                 "name": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
|                     "title": "Name" | ||||
|                 }, | ||||
|                 "flow_set": { | ||||
|                     "type": "array", | ||||
|                     "items": { | ||||
|                         "type": "object", | ||||
|                         "properties": { | ||||
|                             "name": { | ||||
|                                 "type": "string", | ||||
|                                 "minLength": 1, | ||||
|                                 "title": "Name" | ||||
|                             }, | ||||
|                             "slug": { | ||||
|                                 "type": "string", | ||||
|                                 "maxLength": 50, | ||||
|                                 "minLength": 1, | ||||
|                                 "pattern": "^[-a-zA-Z0-9_]+$", | ||||
|                                 "title": "Slug", | ||||
|                                 "description": "Visible in the URL." | ||||
|                             }, | ||||
|                             "title": { | ||||
|                                 "type": "string", | ||||
|                                 "minLength": 1, | ||||
|                                 "title": "Title", | ||||
|                                 "description": "Shown as the Title in Flow pages." | ||||
|                             }, | ||||
|                             "designation": { | ||||
|                                 "type": "string", | ||||
|                                 "enum": [ | ||||
|                                     "authentication", | ||||
|                                     "authorization", | ||||
|                                     "invalidation", | ||||
|                                     "enrollment", | ||||
|                                     "unenrollment", | ||||
|                                     "recovery", | ||||
|                                     "stage_configuration" | ||||
|                                 ], | ||||
|                                 "title": "Designation", | ||||
|                                 "description": "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik." | ||||
|                             }, | ||||
|                             "policy_engine_mode": { | ||||
|                                 "type": "string", | ||||
|                                 "enum": [ | ||||
|                                     "all", | ||||
|                                     "any" | ||||
|                                 ], | ||||
|                                 "title": "Policy engine mode" | ||||
|                             }, | ||||
|                             "compatibility_mode": { | ||||
|                                 "type": "boolean", | ||||
|                                 "title": "Compatibility mode", | ||||
|                                 "description": "Enable compatibility mode, increases compatibility with password managers on mobile devices." | ||||
|                             }, | ||||
|                             "layout": { | ||||
|                                 "type": "string", | ||||
|                                 "enum": [ | ||||
|                                     "stacked", | ||||
|                                     "content_left", | ||||
|                                     "content_right", | ||||
|                                     "sidebar_left", | ||||
|                                     "sidebar_right" | ||||
|                                 ], | ||||
|                                 "title": "Layout" | ||||
|                             }, | ||||
|                             "denied_action": { | ||||
|                                 "type": "string", | ||||
|                                 "enum": [ | ||||
|                                     "message_continue", | ||||
|                                     "message", | ||||
|                                     "continue" | ||||
|                                 ], | ||||
|                                 "title": "Denied action", | ||||
|                                 "description": "Configure what should happen when a flow denies access to a user." | ||||
|                             } | ||||
|                         }, | ||||
|                         "required": [ | ||||
|                             "name", | ||||
|                             "slug", | ||||
|                             "title", | ||||
|                             "designation" | ||||
|                         ] | ||||
|                     }, | ||||
|                     "title": "Flow set" | ||||
|                 }, | ||||
|                 "keep_context": { | ||||
|                     "type": "boolean", | ||||
|                     "title": "Keep context" | ||||
|                 }, | ||||
|                 "mode": { | ||||
|                     "type": "string", | ||||
|                     "enum": [ | ||||
|                         "static", | ||||
|                         "flow" | ||||
|                     ], | ||||
|                     "title": "Mode" | ||||
|                 }, | ||||
|                 "target_static": { | ||||
|                     "type": "string", | ||||
|                     "title": "Target static" | ||||
|                 }, | ||||
|                 "target_flow": { | ||||
|                     "type": "string", | ||||
|                     "format": "uuid", | ||||
|                     "title": "Target flow" | ||||
|                 } | ||||
|             }, | ||||
|             "required": [] | ||||
|         }, | ||||
|         "model_authentik_stages_redirect.redirectstage_permissions": { | ||||
|             "type": "array", | ||||
|             "items": { | ||||
|                 "type": "object", | ||||
|                 "required": [ | ||||
|                     "permission" | ||||
|                 ], | ||||
|                 "properties": { | ||||
|                     "permission": { | ||||
|                         "type": "string", | ||||
|                         "enum": [ | ||||
|                             "add_redirectstage", | ||||
|                             "change_redirectstage", | ||||
|                             "delete_redirectstage", | ||||
|                             "view_redirectstage" | ||||
|                         ] | ||||
|                     }, | ||||
|                     "user": { | ||||
|                         "type": "integer" | ||||
|                     }, | ||||
|                     "role": { | ||||
|                         "type": "string" | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         "model_authentik_stages_user_delete.userdeletestage": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
| @ -12333,6 +12655,7 @@ | ||||
|                             "authentik_brands.delete_brand", | ||||
|                             "authentik_brands.view_brand", | ||||
|                             "authentik_core.add_application", | ||||
|                             "authentik_core.add_applicationentitlement", | ||||
|                             "authentik_core.add_authenticatedsession", | ||||
|                             "authentik_core.add_group", | ||||
|                             "authentik_core.add_groupsourceconnection", | ||||
| @ -12345,6 +12668,7 @@ | ||||
|                             "authentik_core.add_usersourceconnection", | ||||
|                             "authentik_core.assign_user_permissions", | ||||
|                             "authentik_core.change_application", | ||||
|                             "authentik_core.change_applicationentitlement", | ||||
|                             "authentik_core.change_authenticatedsession", | ||||
|                             "authentik_core.change_group", | ||||
|                             "authentik_core.change_groupsourceconnection", | ||||
| @ -12355,6 +12679,7 @@ | ||||
|                             "authentik_core.change_user", | ||||
|                             "authentik_core.change_usersourceconnection", | ||||
|                             "authentik_core.delete_application", | ||||
|                             "authentik_core.delete_applicationentitlement", | ||||
|                             "authentik_core.delete_authenticatedsession", | ||||
|                             "authentik_core.delete_group", | ||||
|                             "authentik_core.delete_groupsourceconnection", | ||||
| @ -12370,6 +12695,7 @@ | ||||
|                             "authentik_core.reset_user_password", | ||||
|                             "authentik_core.unassign_user_permissions", | ||||
|                             "authentik_core.view_application", | ||||
|                             "authentik_core.view_applicationentitlement", | ||||
|                             "authentik_core.view_authenticatedsession", | ||||
|                             "authentik_core.view_group", | ||||
|                             "authentik_core.view_groupsourceconnection", | ||||
| @ -12820,6 +13146,10 @@ | ||||
|                             "authentik_stages_prompt.delete_promptstage", | ||||
|                             "authentik_stages_prompt.view_prompt", | ||||
|                             "authentik_stages_prompt.view_promptstage", | ||||
|                             "authentik_stages_redirect.add_redirectstage", | ||||
|                             "authentik_stages_redirect.change_redirectstage", | ||||
|                             "authentik_stages_redirect.delete_redirectstage", | ||||
|                             "authentik_stages_redirect.view_redirectstage", | ||||
|                             "authentik_stages_source.add_sourcestage", | ||||
|                             "authentik_stages_source.change_sourcestage", | ||||
|                             "authentik_stages_source.delete_sourcestage", | ||||
| @ -12978,6 +13308,52 @@ | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         "model_authentik_core.applicationentitlement": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|                 "name": { | ||||
|                     "type": "string", | ||||
|                     "minLength": 1, | ||||
|                     "title": "Name" | ||||
|                 }, | ||||
|                 "app": { | ||||
|                     "type": "integer", | ||||
|                     "title": "App" | ||||
|                 }, | ||||
|                 "attributes": { | ||||
|                     "type": "object", | ||||
|                     "additionalProperties": true, | ||||
|                     "title": "Attributes" | ||||
|                 } | ||||
|             }, | ||||
|             "required": [] | ||||
|         }, | ||||
|         "model_authentik_core.applicationentitlement_permissions": { | ||||
|             "type": "array", | ||||
|             "items": { | ||||
|                 "type": "object", | ||||
|                 "required": [ | ||||
|                     "permission" | ||||
|                 ], | ||||
|                 "properties": { | ||||
|                     "permission": { | ||||
|                         "type": "string", | ||||
|                         "enum": [ | ||||
|                             "add_applicationentitlement", | ||||
|                             "change_applicationentitlement", | ||||
|                             "delete_applicationentitlement", | ||||
|                             "view_applicationentitlement" | ||||
|                         ] | ||||
|                     }, | ||||
|                     "user": { | ||||
|                         "type": "integer" | ||||
|                     }, | ||||
|                     "role": { | ||||
|                         "type": "string" | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         "model_authentik_core.token": { | ||||
|             "type": "object", | ||||
|             "properties": { | ||||
|  | ||||
| @ -42,9 +42,21 @@ entries: | ||||
|             "given_name": request.user.name, | ||||
|             "preferred_username": request.user.username, | ||||
|             "nickname": request.user.username, | ||||
|             # groups is not part of the official userinfo schema, but is a quasi-standard | ||||
|             "groups": [group.name for group in request.user.ak_groups.all()], | ||||
|         } | ||||
|   - identifiers: | ||||
|       managed: goauthentik.io/providers/oauth2/scope-entitlements | ||||
|     model: authentik_providers_oauth2.scopemapping | ||||
|     attrs: | ||||
|       name: "authentik default OAuth Mapping: Application Entitlements" | ||||
|       scope_name: entitlements | ||||
|       description: "Application entitlements" | ||||
|       expression: | | ||||
|         entitlements = [entitlement.name for entitlement in request.user.app_entitlements(provider.application)] | ||||
|         return { | ||||
|             "entitlements": entitlements, | ||||
|             "roles": entitlements, | ||||
|         } | ||||
|   - identifiers: | ||||
|       managed: goauthentik.io/providers/oauth2/scope-offline_access | ||||
|     model: authentik_providers_oauth2.scopemapping | ||||
|  | ||||
| @ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redis:/data | ||||
|   server: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.4} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.12.0} | ||||
|     restart: unless-stopped | ||||
|     command: server | ||||
|     environment: | ||||
| @ -49,10 +49,12 @@ services: | ||||
|       - "${COMPOSE_PORT_HTTP:-9000}:9000" | ||||
|       - "${COMPOSE_PORT_HTTPS:-9443}:9443" | ||||
|     depends_on: | ||||
|       - postgresql | ||||
|       - redis | ||||
|       postgresql: | ||||
|         condition: service_healthy | ||||
|       redis: | ||||
|         condition: service_healthy | ||||
|   worker: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.4} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.12.0} | ||||
|     restart: unless-stopped | ||||
|     command: worker | ||||
|     environment: | ||||
| @ -76,8 +78,10 @@ services: | ||||
|     env_file: | ||||
|       - .env | ||||
|     depends_on: | ||||
|       - postgresql | ||||
|       - redis | ||||
|       postgresql: | ||||
|         condition: service_healthy | ||||
|       redis: | ||||
|         condition: service_healthy | ||||
|  | ||||
| volumes: | ||||
|   database: | ||||
|  | ||||
							
								
								
									
										16
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								go.mod
									
									
									
									
									
								
							| @ -7,9 +7,9 @@ toolchain go1.23.0 | ||||
| require ( | ||||
| 	beryju.io/ldap v0.1.0 | ||||
| 	github.com/coreos/go-oidc/v3 v3.11.0 | ||||
| 	github.com/getsentry/sentry-go v0.29.1 | ||||
| 	github.com/getsentry/sentry-go v0.30.0 | ||||
| 	github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 | ||||
| 	github.com/go-ldap/ldap/v3 v3.4.8 | ||||
| 	github.com/go-ldap/ldap/v3 v3.4.9 | ||||
| 	github.com/go-openapi/runtime v0.28.0 | ||||
| 	github.com/golang-jwt/jwt/v5 v5.2.1 | ||||
| 	github.com/google/uuid v1.6.0 | ||||
| @ -29,10 +29,10 @@ require ( | ||||
| 	github.com/spf13/cobra v1.8.1 | ||||
| 	github.com/stretchr/testify v1.10.0 | ||||
| 	github.com/wwt/guac v1.3.2 | ||||
| 	goauthentik.io/api/v3 v3.2024104.1 | ||||
| 	goauthentik.io/api/v3 v3.2024105.5 | ||||
| 	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab | ||||
| 	golang.org/x/oauth2 v0.24.0 | ||||
| 	golang.org/x/sync v0.9.0 | ||||
| 	golang.org/x/sync v0.10.0 | ||||
| 	gopkg.in/yaml.v2 v2.4.0 | ||||
| 	layeh.com/radius v0.0.0-20210819152912-ad72663a72ab | ||||
| ) | ||||
| @ -45,7 +45,7 @@ require ( | ||||
| 	github.com/davecgh/go-spew v1.1.1 // indirect | ||||
| 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect | ||||
| 	github.com/felixge/httpsnoop v1.0.3 // indirect | ||||
| 	github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect | ||||
| 	github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect | ||||
| 	github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 // indirect | ||||
| 	github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a // indirect | ||||
| 	github.com/go-jose/go-jose/v4 v4.0.2 // indirect | ||||
| @ -76,9 +76,9 @@ require ( | ||||
| 	go.opentelemetry.io/otel v1.24.0 // indirect | ||||
| 	go.opentelemetry.io/otel/metric v1.24.0 // indirect | ||||
| 	go.opentelemetry.io/otel/trace v1.24.0 // indirect | ||||
| 	golang.org/x/crypto v0.25.0 // indirect | ||||
| 	golang.org/x/sys v0.22.0 // indirect | ||||
| 	golang.org/x/text v0.16.0 // indirect | ||||
| 	golang.org/x/crypto v0.31.0 // indirect | ||||
| 	golang.org/x/sys v0.28.0 // indirect | ||||
| 	golang.org/x/text v0.21.0 // indirect | ||||
| 	google.golang.org/protobuf v1.34.2 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| ) | ||||
|  | ||||
							
								
								
									
										61
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										61
									
								
								go.sum
									
									
									
									
									
								
							| @ -69,10 +69,10 @@ 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.29.1 h1:DyZuChN8Hz3ARxGVV8ePaNXh1dQ7d76AiB117xcREwA= | ||||
| github.com/getsentry/sentry-go v0.29.1/go.mod h1:x3AtIzN01d6SiWkderzaH28Tm0lgkafpJ5Bm3li39O0= | ||||
| github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= | ||||
| github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= | ||||
| github.com/getsentry/sentry-go v0.30.0 h1:lWUwDnY7sKHaVIoZ9wYqRHJ5iEmoc0pqcRqFkosKzBo= | ||||
| github.com/getsentry/sentry-go v0.30.0/go.mod h1:WU9B9/1/sHDqeV8T+3VwwbjeR5MSXs/6aqG3mqZrezA= | ||||
| github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= | ||||
| github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= | ||||
| github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= | ||||
| github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= | ||||
| github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= | ||||
| @ -86,8 +86,8 @@ github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9 | ||||
| github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw= | ||||
| github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= | ||||
| github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= | ||||
| github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= | ||||
| github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= | ||||
| github.com/go-ldap/ldap/v3 v3.4.9 h1:KxX9eO44/MpqPXVVMPJDB+k/35GEePHE/Jfvl7oRMUo= | ||||
| github.com/go-ldap/ldap/v3 v3.4.9/go.mod h1:+CE/4PPOOdEPGTi2B7qXKQOq+pNBvXZtlBNcVZY0AWI= | ||||
| github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | ||||
| github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= | ||||
| github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | ||||
| @ -299,8 +299,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.2024104.1 h1:N09HAJ66W965QEYpx6sJzcaQxPsnFykVwkzVjVK/zH0= | ||||
| goauthentik.io/api/v3 v3.2024104.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||
| goauthentik.io/api/v3 v3.2024105.5 h1:zBDqIjWN5QNuL6iBLL4o9QwBsSkFQdAnyTjASsyE/fw= | ||||
| goauthentik.io/api/v3 v3.2024105.5/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= | ||||
| @ -309,10 +309,12 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh | ||||
| golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= | ||||
| golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= | ||||
| golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= | ||||
| golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= | ||||
| golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= | ||||
| golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= | ||||
| golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= | ||||
| golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= | ||||
| golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= | ||||
| golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= | ||||
| golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= | ||||
| golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= | ||||
| golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | ||||
| golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= | ||||
| golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= | ||||
| @ -347,6 +349,9 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= | ||||
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= | ||||
| golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | ||||
| golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= | ||||
| golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= | ||||
| golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= | ||||
| golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= | ||||
| @ -378,10 +383,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug | ||||
| golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= | ||||
| golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= | ||||
| golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= | ||||
| golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= | ||||
| golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= | ||||
| golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= | ||||
| golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= | ||||
| golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= | ||||
| golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= | ||||
| golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= | ||||
| golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= | ||||
| golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= | ||||
| golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= | ||||
| @ -400,8 +406,11 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ | ||||
| golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= | ||||
| golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||
| golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= | ||||
| golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||
| golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||
| golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= | ||||
| golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= | ||||
| golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||
| golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||
| @ -435,16 +444,20 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc | ||||
| golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= | ||||
| golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= | ||||
| golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= | ||||
| golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= | ||||
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | ||||
| golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= | ||||
| golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= | ||||
| golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= | ||||
| golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= | ||||
| golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= | ||||
| golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= | ||||
| golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= | ||||
| golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | ||||
| @ -453,9 +466,11 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | ||||
| golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= | ||||
| golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= | ||||
| golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= | ||||
| golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= | ||||
| golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | ||||
| golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= | ||||
| golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= | ||||
| golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= | ||||
| golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= | ||||
| golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= | ||||
| golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= | ||||
| @ -501,6 +516,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc | ||||
| golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= | ||||
| golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= | ||||
| golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= | ||||
| golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= | ||||
| golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= | ||||
| golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
|  | ||||
| @ -29,4 +29,4 @@ func UserAgent() string { | ||||
| 	return fmt.Sprintf("authentik@%s", FullVersion()) | ||||
| } | ||||
|  | ||||
| const VERSION = "2024.10.4" | ||||
| const VERSION = "2024.12.0" | ||||
|  | ||||
| @ -14,6 +14,7 @@ type Claims struct { | ||||
| 	Name              string       `json:"name"` | ||||
| 	PreferredUsername string       `json:"preferred_username"` | ||||
| 	Groups            []string     `json:"groups"` | ||||
| 	Entitlements      []string     `json:"entitlements"` | ||||
| 	Sid               string       `json:"sid"` | ||||
| 	Proxy             *ProxyClaims `json:"ak_proxy"` | ||||
|  | ||||
|  | ||||
| @ -41,6 +41,7 @@ func (a *Application) addHeaders(headers http.Header, c *Claims) { | ||||
| 	// https://goauthentik.io/docs/providers/proxy/proxy | ||||
| 	headers.Set("X-authentik-username", c.PreferredUsername) | ||||
| 	headers.Set("X-authentik-groups", strings.Join(c.Groups, "|")) | ||||
| 	headers.Set("X-authentik-entitlements", strings.Join(c.Entitlements, "|")) | ||||
| 	headers.Set("X-authentik-email", c.Email) | ||||
| 	headers.Set("X-authentik-name", c.Name) | ||||
| 	headers.Set("X-authentik-uid", c.Sub) | ||||
|  | ||||
| @ -8,7 +8,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2024-11-26 00:09+0000\n" | ||||
| "POT-Creation-Date: 2024-12-20 00:08+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" | ||||
| @ -101,6 +101,10 @@ msgstr "" | ||||
| msgid "Brands" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/core/api/application_entitlements.py | ||||
| msgid "User does not have access to application." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/core/api/devices.py | ||||
| msgid "Extra description not available" | ||||
| msgstr "" | ||||
| @ -225,6 +229,14 @@ msgstr "" | ||||
| msgid "Applications" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/core/models.py | ||||
| msgid "Application Entitlement" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/core/models.py | ||||
| msgid "Application Entitlements" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/core/models.py | ||||
| msgid "Use the source-specific identifier" | ||||
| msgstr "" | ||||
| @ -1873,6 +1885,10 @@ msgstr "" | ||||
| msgid "Custom krb5.conf to use. Uses the system one by default" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/sources/kerberos/models.py | ||||
| msgid "KAdmin server type" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/sources/kerberos/models.py | ||||
| msgid "Sync users from Kerberos into authentik" | ||||
| msgstr "" | ||||
| @ -2812,7 +2828,7 @@ msgstr "" | ||||
| #, python-format | ||||
| msgid "" | ||||
| "\n" | ||||
| "    If you did not request a password change, please ignore this Email. The " | ||||
| "    If you did not request a password change, please ignore this email. The " | ||||
| "link above is valid for %(expires)s.\n" | ||||
| "    " | ||||
| msgstr "" | ||||
| @ -2833,7 +2849,7 @@ msgstr "" | ||||
| #, python-format | ||||
| msgid "" | ||||
| "\n" | ||||
| "If you did not request a password change, please ignore this Email. The link " | ||||
| "If you did not request a password change, please ignore this email. The link " | ||||
| "above is valid for %(expires)s.\n" | ||||
| msgstr "" | ||||
|  | ||||
| @ -3098,6 +3114,22 @@ msgstr "" | ||||
| msgid "Passwords don't match." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/stages/redirect/api.py | ||||
| msgid "Target URL should be present when mode is Static." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/stages/redirect/api.py | ||||
| msgid "Target Flow should be present when mode is Flow." | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/stages/redirect/models.py | ||||
| msgid "Redirect Stage" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/stages/redirect/models.py | ||||
| msgid "Redirect Stages" | ||||
| msgstr "" | ||||
|  | ||||
| #: authentik/stages/user_delete/models.py | ||||
| msgid "User Delete Stage" | ||||
| msgstr "" | ||||
|  | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											Binary file not shown.
										
									
								
							| @ -19,7 +19,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2024-11-26 00:09+0000\n" | ||||
| "POT-Creation-Date: 2024-12-18 13:31+0000\n" | ||||
| "PO-Revision-Date: 2022-09-26 16:47+0000\n" | ||||
| "Last-Translator: Marc Schmitt, 2024\n" | ||||
| "Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n" | ||||
| @ -2084,6 +2084,10 @@ msgid "Custom krb5.conf to use. Uses the system one by default" | ||||
| msgstr "" | ||||
| "krb5.conf personnalisé à utiliser. Utilise celui du système par défault" | ||||
|  | ||||
| #: authentik/sources/kerberos/models.py | ||||
| msgid "KAdmin server type" | ||||
| msgstr "Type de serveur KAdmin" | ||||
|  | ||||
| #: authentik/sources/kerberos/models.py | ||||
| msgid "Sync users from Kerberos into authentik" | ||||
| msgstr "Synchroniser les utilisateurs Kerberos dans authentik" | ||||
| @ -3105,7 +3109,7 @@ msgstr "" | ||||
| #, python-format | ||||
| msgid "" | ||||
| "\n" | ||||
| "    If you did not request a password change, please ignore this Email. The link above is valid for %(expires)s.\n" | ||||
| "    If you did not request a password change, please ignore this email. The link above is valid for %(expires)s.\n" | ||||
| "    " | ||||
| msgstr "" | ||||
| "\n" | ||||
| @ -3129,7 +3133,7 @@ msgstr "" | ||||
| #, python-format | ||||
| msgid "" | ||||
| "\n" | ||||
| "If you did not request a password change, please ignore this Email. The link above is valid for %(expires)s.\n" | ||||
| "If you did not request a password change, please ignore this email. The link above is valid for %(expires)s.\n" | ||||
| msgstr "" | ||||
| "\n" | ||||
| "Si vous n'avez pas requis de changement de mot de passe, veuillez ignorer cet e-mail. Le lien ci-dessus est valide pendant %(expires)s.\n" | ||||
| @ -3434,6 +3438,22 @@ msgstr "Étapes invite" | ||||
| msgid "Passwords don't match." | ||||
| msgstr "Les mots de passe ne correspondent pas." | ||||
|  | ||||
| #: authentik/stages/redirect/api.py | ||||
| msgid "Target URL should be present when mode is Static." | ||||
| msgstr "L'URL destination doit être présente lorsque le mode est Statique." | ||||
|  | ||||
| #: authentik/stages/redirect/api.py | ||||
| msgid "Target Flow should be present when mode is Flow." | ||||
| msgstr "Le flux destination doit être présent lorsque le mode est Flux." | ||||
|  | ||||
| #: authentik/stages/redirect/models.py | ||||
| msgid "Redirect Stage" | ||||
| msgstr "Étape de redirection" | ||||
|  | ||||
| #: authentik/stages/redirect/models.py | ||||
| msgid "Redirect Stages" | ||||
| msgstr "Étapes de redirection" | ||||
|  | ||||
| #: authentik/stages/user_delete/models.py | ||||
| msgid "User Delete Stage" | ||||
| msgstr "Étape de suppression utilisateur" | ||||
|  | ||||
										
											Binary file not shown.
										
									
								
							| @ -13,15 +13,16 @@ | ||||
| # albanobattistella <albanobattistella@gmail.com>, 2024 | ||||
| # Nicola Mersi, 2024 | ||||
| # tom max, 2024 | ||||
| # Marc Schmitt, 2024 | ||||
| #  | ||||
| #, fuzzy | ||||
| msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2024-11-18 00:09+0000\n" | ||||
| "POT-Creation-Date: 2024-11-26 00:09+0000\n" | ||||
| "PO-Revision-Date: 2022-09-26 16:47+0000\n" | ||||
| "Last-Translator: tom max, 2024\n" | ||||
| "Last-Translator: Marc Schmitt, 2024\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" | ||||
| @ -89,9 +90,9 @@ msgid "authentik Export - {date}" | ||||
| msgstr "Esportazione authentik - {date}" | ||||
|  | ||||
| #: authentik/blueprints/v1/tasks.py authentik/crypto/tasks.py | ||||
| #, python-format | ||||
| msgid "Successfully imported %(count)d files." | ||||
| msgstr "Importato con successo %(count)d file." | ||||
| #, python-brace-format | ||||
| msgid "Successfully imported {count} files." | ||||
| msgstr "Importato con successo {count} file." | ||||
|  | ||||
| #: authentik/brands/models.py | ||||
| msgid "" | ||||
| @ -635,7 +636,7 @@ msgstr "Fasi Sorgenti" | ||||
| #: authentik/events/api/tasks.py | ||||
| #, python-brace-format | ||||
| msgid "Successfully started task {name}." | ||||
| msgstr "Attività {nome} avviata correttamente." | ||||
| msgstr "Attività {name} avviata correttamente." | ||||
|  | ||||
| #: authentik/events/models.py | ||||
| msgid "Event" | ||||
| @ -937,14 +938,14 @@ msgid "Starting full provider sync" | ||||
| msgstr "Avvio della sincronizzazione completa del provider" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| #, python-format | ||||
| msgid "Syncing page %(page)d of users" | ||||
| msgstr "Sincronizzando pagina %(page)d degli utenti" | ||||
| #, python-brace-format | ||||
| msgid "Syncing page {page} of users" | ||||
| msgstr "Sincronizzando pagina {page} degli utenti" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| #, python-format | ||||
| msgid "Syncing page %(page)d of groups" | ||||
| msgstr "Sincronizzando pagina %(page)d dei gruppi" | ||||
| #, python-brace-format | ||||
| msgid "Syncing page {page} of groups" | ||||
| msgstr "Sincronizzando pagina {page} dei gruppi" | ||||
|  | ||||
| #: authentik/lib/sync/outgoing/tasks.py | ||||
| #, python-brace-format | ||||
| @ -1117,10 +1118,10 @@ msgid "Event Matcher Policies" | ||||
| msgstr "Criteri Corrispondenza Evento" | ||||
|  | ||||
| #: authentik/policies/expiry/models.py | ||||
| #, python-format | ||||
| msgid "Password expired %(days)d days ago. Please update your password." | ||||
| #, python-brace-format | ||||
| msgid "Password expired {days} days ago. Please update your password." | ||||
| msgstr "" | ||||
| "Password scaduta %(days)d giorni fa. Si prega di aggiornare la password." | ||||
| "Password scaduta {days} giorni fa. Si prega di aggiornare la password." | ||||
|  | ||||
| #: authentik/policies/expiry/models.py | ||||
| msgid "Password has expired." | ||||
| @ -1254,9 +1255,9 @@ msgid "Invalid password." | ||||
| msgstr "Password invalida." | ||||
|  | ||||
| #: authentik/policies/password/models.py | ||||
| #, python-format | ||||
| msgid "Password exists on %(count)d online lists." | ||||
| msgstr "Password esistente in %(count)d lite online." | ||||
| #, python-brace-format | ||||
| msgid "Password exists on {count} online lists." | ||||
| msgstr "Password esistente in {count} lite online." | ||||
|  | ||||
| #: authentik/policies/password/models.py | ||||
| msgid "Password is too weak." | ||||
| @ -1383,6 +1384,11 @@ msgstr "Providers LDAP" | ||||
| msgid "Search full LDAP directory" | ||||
| msgstr "Ricerca completa nella directory LDAP" | ||||
|  | ||||
| #: authentik/providers/oauth2/api/providers.py | ||||
| #, python-brace-format | ||||
| msgid "Invalid Regex Pattern: {url}" | ||||
| msgstr "Modello Regex non valido: {url}" | ||||
|  | ||||
| #: authentik/providers/oauth2/id_token.py | ||||
| msgid "Based on the Hashed User ID" | ||||
| msgstr "Basato sull'ID utente hashato" | ||||
| @ -1428,6 +1434,14 @@ msgid "Each provider has a different issuer, based on the application slug." | ||||
| msgstr "" | ||||
| "Ogni provider ha un issuer differente, basato sullo slug dell'applicazione." | ||||
|  | ||||
| #: authentik/providers/oauth2/models.py | ||||
| msgid "Strict URL comparison" | ||||
| msgstr "Confronto URL rigoroso" | ||||
|  | ||||
| #: authentik/providers/oauth2/models.py | ||||
| msgid "Regular Expression URL matching" | ||||
| msgstr "Corrispondenza URL espressione regolare" | ||||
|  | ||||
| #: authentik/providers/oauth2/models.py | ||||
| msgid "code (Authorization Code Flow)" | ||||
| msgstr "code (Flusso di autorizzazione del codice)" | ||||
| @ -1508,10 +1522,6 @@ msgstr "Client Secret" | ||||
| msgid "Redirect URIs" | ||||
| msgstr "URL di reindirizzamento" | ||||
|  | ||||
| #: authentik/providers/oauth2/models.py | ||||
| msgid "Enter each URI on a new line." | ||||
| msgstr "Inserisci ogni URI su una nuova riga." | ||||
|  | ||||
| #: authentik/providers/oauth2/models.py | ||||
| msgid "Include claims in id_token" | ||||
| msgstr "Includere le richieste in id_token" | ||||
|  | ||||
| @ -15,7 +15,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PACKAGE VERSION\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2024-11-26 00:09+0000\n" | ||||
| "POT-Creation-Date: 2024-12-18 13:31+0000\n" | ||||
| "PO-Revision-Date: 2022-09-26 16:47+0000\n" | ||||
| "Last-Translator: deluxghost, 2024\n" | ||||
| "Language-Team: Chinese Simplified (https://app.transifex.com/authentik/teams/119923/zh-Hans/)\n" | ||||
| @ -1898,6 +1898,10 @@ msgstr "Kerberos 领域" | ||||
| msgid "Custom krb5.conf to use. Uses the system one by default" | ||||
| msgstr "要使用的自定义 krb5.conf。默认使用系统自带" | ||||
|  | ||||
| #: authentik/sources/kerberos/models.py | ||||
| msgid "KAdmin server type" | ||||
| msgstr "KAdmin 服务器类型" | ||||
|  | ||||
| #: authentik/sources/kerberos/models.py | ||||
| msgid "Sync users from Kerberos into authentik" | ||||
| msgstr "从 Kerberos 同步用户到 authentik" | ||||
| @ -2858,7 +2862,7 @@ msgstr "" | ||||
| #, python-format | ||||
| msgid "" | ||||
| "\n" | ||||
| "    If you did not request a password change, please ignore this Email. The link above is valid for %(expires)s.\n" | ||||
| "    If you did not request a password change, please ignore this email. The link above is valid for %(expires)s.\n" | ||||
| "    " | ||||
| msgstr "" | ||||
| "\n" | ||||
| @ -2882,7 +2886,7 @@ msgstr "" | ||||
| #, python-format | ||||
| msgid "" | ||||
| "\n" | ||||
| "If you did not request a password change, please ignore this Email. The link above is valid for %(expires)s.\n" | ||||
| "If you did not request a password change, please ignore this email. The link above is valid for %(expires)s.\n" | ||||
| msgstr "" | ||||
| "\n" | ||||
| "如果您没有请求更改密码,请忽略此电子邮件。上面的链接在 %(expires)s 内有效。\n" | ||||
| @ -3151,6 +3155,22 @@ msgstr "输入阶段" | ||||
| msgid "Passwords don't match." | ||||
| msgstr "密码不匹配。" | ||||
|  | ||||
| #: authentik/stages/redirect/api.py | ||||
| msgid "Target URL should be present when mode is Static." | ||||
| msgstr "当模式为静态时,目标 URL 应存在。" | ||||
|  | ||||
| #: authentik/stages/redirect/api.py | ||||
| msgid "Target Flow should be present when mode is Flow." | ||||
| msgstr "当模式为流程时,目标流程应存在。" | ||||
|  | ||||
| #: authentik/stages/redirect/models.py | ||||
| msgid "Redirect Stage" | ||||
| msgstr "重定向阶段" | ||||
|  | ||||
| #: authentik/stages/redirect/models.py | ||||
| msgid "Redirect Stages" | ||||
| msgstr "重定向阶段" | ||||
|  | ||||
| #: authentik/stages/user_delete/models.py | ||||
| msgid "User Delete Stage" | ||||
| msgstr "用户删除阶段" | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	