Compare commits
	
		
			47 Commits
		
	
	
		
			version/20
			...
			version-20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 8469213d82 | |||
| 78f7b04d5a | |||
| 22e586bd8c | |||
| 8a0b31b922 | |||
| 359b343f51 | |||
| b727656b05 | |||
| 8f09c2c21c | |||
| 8f207c7504 | |||
| 34d30bb549 | |||
| b4f04881e0 | |||
| 5314485426 | |||
| ad6b6e4576 | |||
| fb9aa9d7f7 | |||
| fe7662f80d | |||
| d6904b6aa1 | |||
| cd581efacd | |||
| 6c159d120b | |||
| 4ddd4e7f88 | |||
| 441912414f | |||
| 9e177ed5c0 | |||
| 881548176f | |||
| 56739d0dc4 | |||
| b23972e9c9 | |||
| 0a9595089e | |||
| 72c22b5fab | |||
| 84cdbb0a03 | |||
| 9fc659f121 | |||
| db6abf61b8 | |||
| 6426a1d177 | |||
| 9075270b01 | |||
| d17a39a431 | |||
| db1d091d2e | |||
| f98204e78e | |||
| 3f663cab0f | |||
| 3fe129e107 | |||
| f26d41aef9 | |||
| 5d8b5998ae | |||
| 7a5e136346 | |||
| bfbab6357a | |||
| 5997b93f15 | |||
| 6cdae09dc0 | |||
| ff0ef7a2b3 | |||
| 3986104a20 | |||
| 1aa60e7864 | |||
| 045578dd07 | |||
| f23d70dc75 | |||
| 496f3426d9 | 
| @ -1,5 +1,5 @@ | |||||||
| [bumpversion] | [bumpversion] | ||||||
| current_version = 2024.6.0 | current_version = 2024.6.5 | ||||||
| tag = True | tag = True | ||||||
| commit = 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*))? | parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | ||||||
|  | |||||||
							
								
								
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -21,7 +21,10 @@ updates: | |||||||
|     labels: |     labels: | ||||||
|       - dependencies |       - dependencies | ||||||
|   - package-ecosystem: npm |   - package-ecosystem: npm | ||||||
|     directory: "/web" |     directories: | ||||||
|  |       - "/web" | ||||||
|  |       - "/tests/wdio" | ||||||
|  |       - "/web/sfe" | ||||||
|     schedule: |     schedule: | ||||||
|       interval: daily |       interval: daily | ||||||
|       time: "04:00" |       time: "04:00" | ||||||
| @ -30,7 +33,6 @@ updates: | |||||||
|     open-pull-requests-limit: 10 |     open-pull-requests-limit: 10 | ||||||
|     commit-message: |     commit-message: | ||||||
|       prefix: "web:" |       prefix: "web:" | ||||||
|     # TODO: deduplicate these groups |  | ||||||
|     groups: |     groups: | ||||||
|       sentry: |       sentry: | ||||||
|         patterns: |         patterns: | ||||||
| @ -56,38 +58,6 @@ updates: | |||||||
|         patterns: |         patterns: | ||||||
|           - "@rollup/*" |           - "@rollup/*" | ||||||
|           - "rollup-*" |           - "rollup-*" | ||||||
|   - package-ecosystem: npm |  | ||||||
|     directory: "/tests/wdio" |  | ||||||
|     schedule: |  | ||||||
|       interval: daily |  | ||||||
|       time: "04:00" |  | ||||||
|     labels: |  | ||||||
|       - dependencies |  | ||||||
|     open-pull-requests-limit: 10 |  | ||||||
|     commit-message: |  | ||||||
|       prefix: "web:" |  | ||||||
|     # TODO: deduplicate these groups |  | ||||||
|     groups: |  | ||||||
|       sentry: |  | ||||||
|         patterns: |  | ||||||
|           - "@sentry/*" |  | ||||||
|           - "@spotlightjs/*" |  | ||||||
|       babel: |  | ||||||
|         patterns: |  | ||||||
|           - "@babel/*" |  | ||||||
|           - "babel-*" |  | ||||||
|       eslint: |  | ||||||
|         patterns: |  | ||||||
|           - "@typescript-eslint/*" |  | ||||||
|           - "eslint" |  | ||||||
|           - "eslint-*" |  | ||||||
|       storybook: |  | ||||||
|         patterns: |  | ||||||
|           - "@storybook/*" |  | ||||||
|           - "*storybook*" |  | ||||||
|       esbuild: |  | ||||||
|         patterns: |  | ||||||
|           - "@esbuild/*" |  | ||||||
|       wdio: |       wdio: | ||||||
|         patterns: |         patterns: | ||||||
|           - "@wdio/*" |           - "@wdio/*" | ||||||
|  | |||||||
							
								
								
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -31,7 +31,12 @@ jobs: | |||||||
|         env: |         env: | ||||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} |           NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} | ||||||
|       - name: Upgrade /web |       - name: Upgrade /web | ||||||
|         working-directory: web/ |         working-directory: web | ||||||
|  |         run: | | ||||||
|  |           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||||
|  |           npm i @goauthentik/api@$VERSION | ||||||
|  |       - name: Upgrade /web/sfe | ||||||
|  |         working-directory: web/sfe | ||||||
|         run: | |         run: | | ||||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` |           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'` | ||||||
|           npm i @goauthentik/api@$VERSION |           npm i @goauthentik/api@$VERSION | ||||||
|  | |||||||
							
								
								
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								.github/workflows/ci-web.yml
									
									
									
									
										vendored
									
									
								
							| @ -20,6 +20,16 @@ jobs: | |||||||
|         project: |         project: | ||||||
|           - web |           - web | ||||||
|           - tests/wdio |           - tests/wdio | ||||||
|  |         include: | ||||||
|  |           - command: tsc | ||||||
|  |             project: web | ||||||
|  |             extra_setup: | | ||||||
|  |               cd sfe/ && npm ci | ||||||
|  |         exclude: | ||||||
|  |           - command: lint:lockfile | ||||||
|  |             project: tests/wdio | ||||||
|  |           - command: tsc | ||||||
|  |             project: tests/wdio | ||||||
|     steps: |     steps: | ||||||
|       - uses: actions/checkout@v4 |       - uses: actions/checkout@v4 | ||||||
|       - uses: actions/setup-node@v4 |       - uses: actions/setup-node@v4 | ||||||
|  | |||||||
| @ -30,7 +30,12 @@ WORKDIR /work/web | |||||||
|  |  | ||||||
| RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \ | ||||||
|     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ |     --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ | ||||||
|  |     --mount=type=bind,target=/work/web/sfe/package.json,src=./web/sfe/package.json \ | ||||||
|  |     --mount=type=bind,target=/work/web/sfe/package-lock.json,src=./web/sfe/package-lock.json \ | ||||||
|  |     --mount=type=bind,target=/work/web/scripts,src=./web/scripts \ | ||||||
|     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ |     --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \ | ||||||
|  |     npm ci --include=dev && \ | ||||||
|  |     cd sfe && \ | ||||||
|     npm ci --include=dev |     npm ci --include=dev | ||||||
|  |  | ||||||
| COPY ./package.json /work | COPY ./package.json /work | ||||||
| @ -38,7 +43,9 @@ COPY ./web /work/web/ | |||||||
| COPY ./website /work/website/ | COPY ./website /work/website/ | ||||||
| COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api | ||||||
|  |  | ||||||
| RUN npm run build | RUN npm run build && \ | ||||||
|  |     cd sfe && \ | ||||||
|  |     npm run build | ||||||
|  |  | ||||||
| # Stage 3: Build go proxy | # Stage 3: Build go proxy | ||||||
| FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder | FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.22-fips-bookworm AS go-builder | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|  |  | ||||||
| from os import environ | from os import environ | ||||||
|  |  | ||||||
| __version__ = "2024.6.0" | __version__ = "2024.6.5" | ||||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -24,7 +24,7 @@ from authentik.tenants.utils import get_current_tenant | |||||||
| class FooterLinkSerializer(PassiveSerializer): | class FooterLinkSerializer(PassiveSerializer): | ||||||
|     """Links returned in Config API""" |     """Links returned in Config API""" | ||||||
|  |  | ||||||
|     href = CharField(read_only=True) |     href = CharField(read_only=True, allow_null=True) | ||||||
|     name = CharField(read_only=True) |     name = CharField(read_only=True) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ from rest_framework.request import Request | |||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
|  |  | ||||||
| from authentik.core.api.utils import PassiveSerializer | from authentik.core.api.utils import PassiveSerializer | ||||||
|  | from authentik.rbac.filters import ObjectFilter | ||||||
|  |  | ||||||
|  |  | ||||||
| class DeleteAction(Enum): | class DeleteAction(Enum): | ||||||
| @ -53,7 +54,7 @@ class UsedByMixin: | |||||||
|     @extend_schema( |     @extend_schema( | ||||||
|         responses={200: UsedBySerializer(many=True)}, |         responses={200: UsedBySerializer(many=True)}, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) | ||||||
|     def used_by(self, request: Request, *args, **kwargs) -> Response: |     def used_by(self, request: Request, *args, **kwargs) -> Response: | ||||||
|         """Get a list of all objects that use this object""" |         """Get a list of all objects that use this object""" | ||||||
|         model: Model = self.get_object() |         model: Model = self.get_object() | ||||||
|  | |||||||
| @ -7,12 +7,13 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor | |||||||
|  |  | ||||||
|  |  | ||||||
| def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def backport_is_backchannel(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|     from authentik.providers.ldap.models import LDAPProvider |     from authentik.providers.ldap.models import LDAPProvider | ||||||
|     from authentik.providers.scim.models import SCIMProvider |     from authentik.providers.scim.models import SCIMProvider | ||||||
|  |  | ||||||
|     for model in [LDAPProvider, SCIMProvider]: |     for model in [LDAPProvider, SCIMProvider]: | ||||||
|         try: |         try: | ||||||
|             for obj in model.objects.only("is_backchannel"): |             for obj in model.objects.using(db_alias).only("is_backchannel"): | ||||||
|                 obj.is_backchannel = True |                 obj.is_backchannel = True | ||||||
|                 obj.save() |                 obj.save() | ||||||
|         except (DatabaseError, InternalError, ProgrammingError): |         except (DatabaseError, InternalError, ProgrammingError): | ||||||
|  | |||||||
| @ -212,7 +212,7 @@ class SourceFlowManager: | |||||||
|  |  | ||||||
|     def _prepare_flow( |     def _prepare_flow( | ||||||
|         self, |         self, | ||||||
|         flow: Flow, |         flow: Flow | None, | ||||||
|         connection: UserSourceConnection, |         connection: UserSourceConnection, | ||||||
|         stages: list[StageView] | None = None, |         stages: list[StageView] | None = None, | ||||||
|         **kwargs, |         **kwargs, | ||||||
| @ -309,7 +309,9 @@ class SourceFlowManager: | |||||||
|         # When request isn't authenticated we jump straight to auth |         # When request isn't authenticated we jump straight to auth | ||||||
|         if not self.request.user.is_authenticated: |         if not self.request.user.is_authenticated: | ||||||
|             return self.handle_auth(connection) |             return self.handle_auth(connection) | ||||||
|         # Connection has already been saved |         if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session: | ||||||
|  |             return self._prepare_flow(None, connection) | ||||||
|  |         connection.save() | ||||||
|         Event.new( |         Event.new( | ||||||
|             EventAction.SOURCE_LINKED, |             EventAction.SOURCE_LINKED, | ||||||
|             message="Linked Source", |             message="Linked Source", | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ | |||||||
|         versionSubdomain: "{{ version_subdomain }}", |         versionSubdomain: "{{ version_subdomain }}", | ||||||
|         build: "{{ build }}", |         build: "{{ build }}", | ||||||
|     }; |     }; | ||||||
|     window.addEventListener("DOMContentLoaded", () => { |     window.addEventListener("DOMContentLoaded", function () { | ||||||
|         {% for message in messages %} |         {% for message in messages %} | ||||||
|         window.dispatchEvent( |         window.dispatchEvent( | ||||||
|             new CustomEvent("ak-message", { |             new CustomEvent("ak-message", { | ||||||
|  | |||||||
| @ -4,7 +4,7 @@ | |||||||
|  |  | ||||||
| <!DOCTYPE html> | <!DOCTYPE html> | ||||||
|  |  | ||||||
| <html lang="en"> | <html> | ||||||
|     <head> |     <head> | ||||||
|         <meta charset="UTF-8"> |         <meta charset="UTF-8"> | ||||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|  | |||||||
| @ -71,9 +71,9 @@ | |||||||
|                 </li> |                 </li> | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|                 <li> |                 <li> | ||||||
|                     <a href="https://goauthentik.io?utm_source=authentik"> |                     <span> | ||||||
|                         {% trans 'Powered by authentik' %} |                         {% trans 'Powered by authentik' %} | ||||||
|                     </a> |                     </span> | ||||||
|                 </li> |                 </li> | ||||||
|             </ul> |             </ul> | ||||||
|         </footer> |         </footer> | ||||||
|  | |||||||
| @ -17,11 +17,5 @@ def versioned_script(path: str) -> str: | |||||||
|             f'<script src="{static_loader(path.replace("%v", get_full_version()))}' |             f'<script src="{static_loader(path.replace("%v", get_full_version()))}' | ||||||
|             '" type="module"></script>' |             '" type="module"></script>' | ||||||
|         ), |         ), | ||||||
|         # Legacy method of loading scripts used as a fallback, without the version in the filename |  | ||||||
|         # TODO: Remove after 2024.6 or later |  | ||||||
|         ( |  | ||||||
|             f'<script src="{static_loader(path.replace("-%v", ""))}?' |  | ||||||
|             f'version={get_full_version()}" type="module"></script>' |  | ||||||
|         ), |  | ||||||
|     ] |     ] | ||||||
|     return mark_safe("".join(returned_lines))  # nosec |     return mark_safe("".join(returned_lines))  # nosec | ||||||
|  | |||||||
| @ -20,8 +20,9 @@ from authentik.core.api.transactional_applications import TransactionalApplicati | |||||||
| from authentik.core.api.users import UserViewSet | from authentik.core.api.users import UserViewSet | ||||||
| from authentik.core.views import apps | from authentik.core.views import apps | ||||||
| from authentik.core.views.debug import AccessDeniedView | from authentik.core.views.debug import AccessDeniedView | ||||||
| from authentik.core.views.interface import FlowInterfaceView, InterfaceView | from authentik.core.views.interface import InterfaceView | ||||||
| from authentik.core.views.session import EndSessionView | from authentik.core.views.session import EndSessionView | ||||||
|  | from authentik.flows.views.interface import FlowInterfaceView | ||||||
| from authentik.root.asgi_middleware import SessionMiddleware | from authentik.root.asgi_middleware import SessionMiddleware | ||||||
| from authentik.root.messages.consumer import MessageConsumer | from authentik.root.messages.consumer import MessageConsumer | ||||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | from authentik.root.middleware import ChannelsLoggingMiddleware | ||||||
| @ -53,6 +54,8 @@ urlpatterns = [ | |||||||
|     ), |     ), | ||||||
|     path( |     path( | ||||||
|         "if/flow/<slug:flow_slug>/", |         "if/flow/<slug:flow_slug>/", | ||||||
|  |         # FIXME: move this url to the flows app...also will cause all | ||||||
|  |         # of the reverse calls to be adjusted | ||||||
|         ensure_csrf_cookie(FlowInterfaceView.as_view()), |         ensure_csrf_cookie(FlowInterfaceView.as_view()), | ||||||
|         name="if-flow", |         name="if-flow", | ||||||
|     ), |     ), | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ | |||||||
| from json import dumps | from json import dumps | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from django.shortcuts import get_object_or_404 |  | ||||||
| from django.views.generic.base import TemplateView | from django.views.generic.base import TemplateView | ||||||
| from rest_framework.request import Request | from rest_framework.request import Request | ||||||
|  |  | ||||||
| @ -11,7 +10,6 @@ from authentik import get_build_hash | |||||||
| from authentik.admin.tasks import LOCAL_VERSION | from authentik.admin.tasks import LOCAL_VERSION | ||||||
| from authentik.api.v3.config import ConfigView | from authentik.api.v3.config import ConfigView | ||||||
| from authentik.brands.api import CurrentBrandSerializer | from authentik.brands.api import CurrentBrandSerializer | ||||||
| from authentik.flows.models import Flow |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class InterfaceView(TemplateView): | class InterfaceView(TemplateView): | ||||||
| @ -25,14 +23,3 @@ class InterfaceView(TemplateView): | |||||||
|         kwargs["build"] = get_build_hash() |         kwargs["build"] = get_build_hash() | ||||||
|         kwargs["url_kwargs"] = self.kwargs |         kwargs["url_kwargs"] = self.kwargs | ||||||
|         return super().get_context_data(**kwargs) |         return super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |  | ||||||
| class FlowInterfaceView(InterfaceView): |  | ||||||
|     """Flow interface""" |  | ||||||
|  |  | ||||||
|     template_name = "if/flow.html" |  | ||||||
|  |  | ||||||
|     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: |  | ||||||
|         kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) |  | ||||||
|         kwargs["inspector"] = "inspector" in self.request.GET |  | ||||||
|         return super().get_context_data(**kwargs) |  | ||||||
|  | |||||||
| @ -35,6 +35,7 @@ from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg | |||||||
| from authentik.crypto.models import CertificateKeyPair | from authentik.crypto.models import CertificateKeyPair | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.rbac.decorators import permission_required | from authentik.rbac.decorators import permission_required | ||||||
|  | from authentik.rbac.filters import ObjectFilter | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -265,7 +266,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|         ], |         ], | ||||||
|         responses={200: CertificateDataSerializer(many=False)}, |         responses={200: CertificateDataSerializer(many=False)}, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) | ||||||
|     def view_certificate(self, request: Request, pk: str) -> Response: |     def view_certificate(self, request: Request, pk: str) -> Response: | ||||||
|         """Return certificate-key pairs certificate and log access""" |         """Return certificate-key pairs certificate and log access""" | ||||||
|         certificate: CertificateKeyPair = self.get_object() |         certificate: CertificateKeyPair = self.get_object() | ||||||
| @ -295,7 +296,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): | |||||||
|         ], |         ], | ||||||
|         responses={200: CertificateDataSerializer(many=False)}, |         responses={200: CertificateDataSerializer(many=False)}, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) | ||||||
|     def view_private_key(self, request: Request, pk: str) -> Response: |     def view_private_key(self, request: Request, pk: str) -> Response: | ||||||
|         """Return certificate-key pairs private key and log access""" |         """Return certificate-key pairs private key and log access""" | ||||||
|         certificate: CertificateKeyPair = self.get_object() |         certificate: CertificateKeyPair = self.get_object() | ||||||
|  | |||||||
| @ -214,6 +214,46 @@ class TestCrypto(APITestCase): | |||||||
|         self.assertEqual(200, response.status_code) |         self.assertEqual(200, response.status_code) | ||||||
|         self.assertIn("Content-Disposition", response) |         self.assertIn("Content-Disposition", response) | ||||||
|  |  | ||||||
|  |     def test_certificate_download_denied(self): | ||||||
|  |         """Test certificate export (download)""" | ||||||
|  |         self.client.logout() | ||||||
|  |         keypair = create_test_cert() | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:certificatekeypair-view-certificate", | ||||||
|  |                 kwargs={"pk": keypair.pk}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(403, response.status_code) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:certificatekeypair-view-certificate", | ||||||
|  |                 kwargs={"pk": keypair.pk}, | ||||||
|  |             ), | ||||||
|  |             data={"download": True}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(403, response.status_code) | ||||||
|  |  | ||||||
|  |     def test_private_key_download_denied(self): | ||||||
|  |         """Test private_key export (download)""" | ||||||
|  |         self.client.logout() | ||||||
|  |         keypair = create_test_cert() | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:certificatekeypair-view-private-key", | ||||||
|  |                 kwargs={"pk": keypair.pk}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(403, response.status_code) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:certificatekeypair-view-private-key", | ||||||
|  |                 kwargs={"pk": keypair.pk}, | ||||||
|  |             ), | ||||||
|  |             data={"download": True}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(403, response.status_code) | ||||||
|  |  | ||||||
|     def test_used_by(self): |     def test_used_by(self): | ||||||
|         """Test used_by endpoint""" |         """Test used_by endpoint""" | ||||||
|         self.client.force_login(create_test_admin_user()) |         self.client.force_login(create_test_admin_user()) | ||||||
| @ -246,6 +286,26 @@ class TestCrypto(APITestCase): | |||||||
|             ], |             ], | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def test_used_by_denied(self): | ||||||
|  |         """Test used_by endpoint""" | ||||||
|  |         self.client.logout() | ||||||
|  |         keypair = create_test_cert() | ||||||
|  |         OAuth2Provider.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             client_id="test", | ||||||
|  |             client_secret=generate_key(), | ||||||
|  |             authorization_flow=create_test_flow(), | ||||||
|  |             redirect_uris="http://localhost", | ||||||
|  |             signing_key=keypair, | ||||||
|  |         ) | ||||||
|  |         response = self.client.get( | ||||||
|  |             reverse( | ||||||
|  |                 "authentik_api:certificatekeypair-used-by", | ||||||
|  |                 kwargs={"pk": keypair.pk}, | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(403, response.status_code) | ||||||
|  |  | ||||||
|     def test_discovery(self): |     def test_discovery(self): | ||||||
|         """Test certificate discovery""" |         """Test certificate discovery""" | ||||||
|         name = generate_id() |         name = generate_id() | ||||||
|  | |||||||
| @ -34,6 +34,12 @@ class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer): | |||||||
|         ] |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ConnectionTokenOwnerFilter(OwnerFilter): | ||||||
|  |     """Owner filter for connection tokens (checks session's user)""" | ||||||
|  |  | ||||||
|  |     owner_key = "session__user" | ||||||
|  |  | ||||||
|  |  | ||||||
| class ConnectionTokenViewSet( | class ConnectionTokenViewSet( | ||||||
|     mixins.RetrieveModelMixin, |     mixins.RetrieveModelMixin, | ||||||
|     mixins.UpdateModelMixin, |     mixins.UpdateModelMixin, | ||||||
| @ -50,4 +56,9 @@ class ConnectionTokenViewSet( | |||||||
|     search_fields = ["endpoint__name", "provider__name"] |     search_fields = ["endpoint__name", "provider__name"] | ||||||
|     ordering = ["endpoint__name", "provider__name"] |     ordering = ["endpoint__name", "provider__name"] | ||||||
|     permission_classes = [OwnerSuperuserPermissions] |     permission_classes = [OwnerSuperuserPermissions] | ||||||
|     filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter] |     filter_backends = [ | ||||||
|  |         ConnectionTokenOwnerFilter, | ||||||
|  |         DjangoFilterBackend, | ||||||
|  |         OrderingFilter, | ||||||
|  |         SearchFilter, | ||||||
|  |     ] | ||||||
|  | |||||||
| @ -5,7 +5,6 @@ from channels.sessions import CookieMiddleware | |||||||
| from django.urls import path | from django.urls import path | ||||||
| from django.views.decorators.csrf import ensure_csrf_cookie | from django.views.decorators.csrf import ensure_csrf_cookie | ||||||
|  |  | ||||||
| from authentik.core.channels import TokenOutpostMiddleware |  | ||||||
| from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet | from authentik.enterprise.providers.rac.api.connection_tokens import ConnectionTokenViewSet | ||||||
| from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet | from authentik.enterprise.providers.rac.api.endpoints import EndpointViewSet | ||||||
| from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet | from authentik.enterprise.providers.rac.api.property_mappings import RACPropertyMappingViewSet | ||||||
| @ -13,6 +12,7 @@ from authentik.enterprise.providers.rac.api.providers import RACProviderViewSet | |||||||
| from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer | from authentik.enterprise.providers.rac.consumer_client import RACClientConsumer | ||||||
| from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer | from authentik.enterprise.providers.rac.consumer_outpost import RACOutpostConsumer | ||||||
| from authentik.enterprise.providers.rac.views import RACInterface, RACStartView | from authentik.enterprise.providers.rac.views import RACInterface, RACStartView | ||||||
|  | from authentik.outposts.channels import TokenOutpostMiddleware | ||||||
| from authentik.root.asgi_middleware import SessionMiddleware | from authentik.root.asgi_middleware import SessionMiddleware | ||||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | from authentik.root.middleware import ChannelsLoggingMiddleware | ||||||
|  |  | ||||||
|  | |||||||
| @ -35,6 +35,7 @@ IGNORED_MODELS = tuple( | |||||||
|  |  | ||||||
| _CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None) | _CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None) | ||||||
| _CTX_IGNORE = ContextVar[bool]("authentik_events_log_ignore", default=False) | _CTX_IGNORE = ContextVar[bool]("authentik_events_log_ignore", default=False) | ||||||
|  | _CTX_REQUEST = ContextVar[HttpRequest | None]("authentik_events_log_request", default=None) | ||||||
|  |  | ||||||
|  |  | ||||||
| def should_log_model(model: Model) -> bool: | def should_log_model(model: Model) -> bool: | ||||||
| @ -149,11 +150,13 @@ class AuditMiddleware: | |||||||
|         m2m_changed.disconnect(dispatch_uid=request.request_id) |         m2m_changed.disconnect(dispatch_uid=request.request_id) | ||||||
|  |  | ||||||
|     def __call__(self, request: HttpRequest) -> HttpResponse: |     def __call__(self, request: HttpRequest) -> HttpResponse: | ||||||
|  |         _CTX_REQUEST.set(request) | ||||||
|         self.connect(request) |         self.connect(request) | ||||||
|  |  | ||||||
|         response = self.get_response(request) |         response = self.get_response(request) | ||||||
|  |  | ||||||
|         self.disconnect(request) |         self.disconnect(request) | ||||||
|  |         _CTX_REQUEST.set(None) | ||||||
|         return response |         return response | ||||||
|  |  | ||||||
|     def process_exception(self, request: HttpRequest, exception: Exception): |     def process_exception(self, request: HttpRequest, exception: Exception): | ||||||
| @ -167,7 +170,7 @@ class AuditMiddleware: | |||||||
|             thread = EventNewThread( |             thread = EventNewThread( | ||||||
|                 EventAction.SUSPICIOUS_REQUEST, |                 EventAction.SUSPICIOUS_REQUEST, | ||||||
|                 request, |                 request, | ||||||
|                 message=str(exception), |                 message=exception_to_string(exception), | ||||||
|             ) |             ) | ||||||
|             thread.run() |             thread.run() | ||||||
|         elif before_send({}, {"exc_info": (None, exception, None)}) is not None: |         elif before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||||
| @ -192,6 +195,8 @@ class AuditMiddleware: | |||||||
|             return |             return | ||||||
|         if _CTX_IGNORE.get(): |         if _CTX_IGNORE.get(): | ||||||
|             return |             return | ||||||
|  |         if request.request_id != _CTX_REQUEST.get().request_id: | ||||||
|  |             return | ||||||
|         user = self.get_user(request) |         user = self.get_user(request) | ||||||
|  |  | ||||||
|         action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED |         action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED | ||||||
| @ -205,6 +210,8 @@ class AuditMiddleware: | |||||||
|             return |             return | ||||||
|         if _CTX_IGNORE.get(): |         if _CTX_IGNORE.get(): | ||||||
|             return |             return | ||||||
|  |         if request.request_id != _CTX_REQUEST.get().request_id: | ||||||
|  |             return | ||||||
|         user = self.get_user(request) |         user = self.get_user(request) | ||||||
|  |  | ||||||
|         EventNewThread( |         EventNewThread( | ||||||
| @ -230,6 +237,8 @@ class AuditMiddleware: | |||||||
|             return |             return | ||||||
|         if _CTX_IGNORE.get(): |         if _CTX_IGNORE.get(): | ||||||
|             return |             return | ||||||
|  |         if request.request_id != _CTX_REQUEST.get().request_id: | ||||||
|  |             return | ||||||
|         user = self.get_user(request) |         user = self.get_user(request) | ||||||
|  |  | ||||||
|         EventNewThread( |         EventNewThread( | ||||||
|  | |||||||
| @ -238,6 +238,8 @@ class Event(SerializerModel, ExpiringModel): | |||||||
|                 "args": cleanse_dict(QueryDict(request.META.get("QUERY_STRING", ""))), |                 "args": cleanse_dict(QueryDict(request.META.get("QUERY_STRING", ""))), | ||||||
|                 "user_agent": request.META.get("HTTP_USER_AGENT", ""), |                 "user_agent": request.META.get("HTTP_USER_AGENT", ""), | ||||||
|             } |             } | ||||||
|  |             if hasattr(request, "request_id"): | ||||||
|  |                 self.context["http_request"]["request_id"] = request.request_id | ||||||
|             # Special case for events created during flow execution |             # Special case for events created during flow execution | ||||||
|             # since they keep the http query within a wrapped query |             # since they keep the http query within a wrapped query | ||||||
|             if QS_QUERY in self.context["http_request"]["args"]: |             if QS_QUERY in self.context["http_request"]["args"]: | ||||||
|  | |||||||
| @ -75,7 +75,10 @@ def on_login_failed( | |||||||
|     **kwargs, |     **kwargs, | ||||||
| ): | ): | ||||||
|     """Failed Login, authentik custom event""" |     """Failed Login, authentik custom event""" | ||||||
|     Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **kwargs).from_http(request) |     user = User.objects.filter(username=credentials.get("username")).first() | ||||||
|  |     Event.new(EventAction.LOGIN_FAILED, **credentials, stage=stage, **kwargs).from_http( | ||||||
|  |         request, user | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(invitation_used) | @receiver(invitation_used) | ||||||
|  | |||||||
| @ -37,6 +37,7 @@ from authentik.lib.utils.file import ( | |||||||
| ) | ) | ||||||
| from authentik.lib.views import bad_request_message | from authentik.lib.views import bad_request_message | ||||||
| from authentik.rbac.decorators import permission_required | from authentik.rbac.decorators import permission_required | ||||||
|  | from authentik.rbac.filters import ObjectFilter | ||||||
|  |  | ||||||
| LOGGER = get_logger() | LOGGER = get_logger() | ||||||
|  |  | ||||||
| @ -281,7 +282,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet): | |||||||
|             400: OpenApiResponse(description="Flow not applicable"), |             400: OpenApiResponse(description="Flow not applicable"), | ||||||
|         }, |         }, | ||||||
|     ) |     ) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) | ||||||
|     def execute(self, request: Request, slug: str): |     def execute(self, request: Request, slug: str): | ||||||
|         """Execute flow for current user""" |         """Execute flow for current user""" | ||||||
|         # Because we pre-plan the flow here, and not in the planner, we need to manually clear |         # Because we pre-plan the flow here, and not in the planner, we need to manually clear | ||||||
|  | |||||||
| @ -21,7 +21,9 @@ def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEd | |||||||
|         pass |         pass | ||||||
|  |  | ||||||
|     if users.exists(): |     if users.exists(): | ||||||
|         Flow.objects.filter(slug="initial-setup").update(authentication="require_superuser") |         Flow.objects.using(db_alias).filter(slug="initial-setup").update( | ||||||
|  |             authentication="require_superuser" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
|  | |||||||
							
								
								
									
										54
									
								
								authentik/flows/templates/if/flow-sfe.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								authentik/flows/templates/if/flow-sfe.html
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,54 @@ | |||||||
|  | {% load static %} | ||||||
|  | {% load i18n %} | ||||||
|  | {% load authentik_core %} | ||||||
|  |  | ||||||
|  | <!DOCTYPE html> | ||||||
|  |  | ||||||
|  | <html lang="en"> | ||||||
|  |     <head> | ||||||
|  |         <meta charset="UTF-8"> | ||||||
|  |         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||||
|  |         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title> | ||||||
|  |         <link rel="icon" href="{{ brand.branding_favicon }}"> | ||||||
|  |         <link rel="shortcut icon" href="{{ brand.branding_favicon }}"> | ||||||
|  |         {% block head_before %} | ||||||
|  |         {% endblock %} | ||||||
|  |         <link rel="stylesheet" type="text/css" href="{% static 'dist/sfe/bootstrap.min.css' %}"> | ||||||
|  |         <meta name="sentry-trace" content="{{ sentry_trace }}" /> | ||||||
|  |         {% include "base/header_js.html" %} | ||||||
|  |         <style> | ||||||
|  |           html, | ||||||
|  |           body { | ||||||
|  |             height: 100%; | ||||||
|  |           } | ||||||
|  |           body { | ||||||
|  |             background-image: url("{{ flow.background_url }}"); | ||||||
|  |             background-repeat: no-repeat; | ||||||
|  |             background-size: cover; | ||||||
|  |           } | ||||||
|  |           .card { | ||||||
|  |             padding: 3rem; | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           .form-signin { | ||||||
|  |             max-width: 330px; | ||||||
|  |             padding: 1rem; | ||||||
|  |           } | ||||||
|  |  | ||||||
|  |           .form-signin .form-floating:focus-within { | ||||||
|  |             z-index: 2; | ||||||
|  |           } | ||||||
|  |           .brand-icon { | ||||||
|  |             max-width: 100%; | ||||||
|  |           } | ||||||
|  |         </style> | ||||||
|  |     </head> | ||||||
|  |     <body class="d-flex align-items-center py-4 bg-body-tertiary"> | ||||||
|  |       <div class="card m-auto"> | ||||||
|  |         <main class="form-signin w-100 m-auto" id="flow-sfe-container"> | ||||||
|  |         </main> | ||||||
|  |         <span class="mt-3 mb-0 text-muted text-center">{% trans 'Powered by authentik' %}</span> | ||||||
|  |       </div> | ||||||
|  |       <script src="{% static 'dist/sfe/index.js' %}"></script> | ||||||
|  |     </body> | ||||||
|  | </html> | ||||||
							
								
								
									
										41
									
								
								authentik/flows/views/interface.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								authentik/flows/views/interface.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,41 @@ | |||||||
|  | """Interface views""" | ||||||
|  |  | ||||||
|  | from typing import Any | ||||||
|  |  | ||||||
|  | from django.shortcuts import get_object_or_404 | ||||||
|  | from ua_parser.user_agent_parser import Parse | ||||||
|  |  | ||||||
|  | from authentik.core.views.interface import InterfaceView | ||||||
|  | from authentik.flows.models import Flow | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class FlowInterfaceView(InterfaceView): | ||||||
|  |     """Flow interface""" | ||||||
|  |  | ||||||
|  |     def get_context_data(self, **kwargs: Any) -> dict[str, Any]: | ||||||
|  |         kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) | ||||||
|  |         kwargs["inspector"] = "inspector" in self.request.GET | ||||||
|  |         return super().get_context_data(**kwargs) | ||||||
|  |  | ||||||
|  |     def compat_needs_sfe(self) -> bool: | ||||||
|  |         """Check if we need to use the simplified flow executor for compatibility""" | ||||||
|  |         ua = Parse(self.request.META.get("HTTP_USER_AGENT", "")) | ||||||
|  |         if ua["user_agent"]["family"] == "IE": | ||||||
|  |             return True | ||||||
|  |         # Only use SFE for Edge 18 and older, after Edge 18 MS switched to chromium which supports | ||||||
|  |         # the default flow executor | ||||||
|  |         if ( | ||||||
|  |             ua["user_agent"]["family"] == "Edge" | ||||||
|  |             and int(ua["user_agent"]["major"]) <= 18  # noqa: PLR2004 | ||||||
|  |         ):  # noqa: PLR2004 | ||||||
|  |             return True | ||||||
|  |         # https://github.com/AzureAD/microsoft-authentication-library-for-objc | ||||||
|  |         # Used by Microsoft Teams/Office on macOS, and also uses a very outdated browser engine | ||||||
|  |         if "PKeyAuth" in ua["string"]: | ||||||
|  |             return True | ||||||
|  |         return False | ||||||
|  |  | ||||||
|  |     def get_template_names(self) -> list[str]: | ||||||
|  |         if self.compat_needs_sfe() or "sfe" in self.request.GET: | ||||||
|  |             return ["if/flow-sfe.html"] | ||||||
|  |         return ["if/flow.html"] | ||||||
| @ -2,6 +2,7 @@ from collections.abc import Callable | |||||||
|  |  | ||||||
| from django.core.paginator import Paginator | from django.core.paginator import Paginator | ||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
|  | from django.db.models.query import Q | ||||||
| from django.db.models.signals import m2m_changed, post_save, pre_delete | from django.db.models.signals import m2m_changed, post_save, pre_delete | ||||||
|  |  | ||||||
| from authentik.core.models import Group, User | from authentik.core.models import Group, User | ||||||
| @ -34,7 +35,9 @@ def register_signals( | |||||||
|  |  | ||||||
|     def model_post_save(sender: type[Model], instance: User | Group, created: bool, **_): |     def model_post_save(sender: type[Model], instance: User | Group, created: bool, **_): | ||||||
|         """Post save handler""" |         """Post save handler""" | ||||||
|         if not provider_type.objects.filter(backchannel_application__isnull=False).exists(): |         if not provider_type.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ).exists(): | ||||||
|             return |             return | ||||||
|         task_sync_direct.delay(class_to_path(instance.__class__), instance.pk, Direction.add.value) |         task_sync_direct.delay(class_to_path(instance.__class__), instance.pk, Direction.add.value) | ||||||
|  |  | ||||||
| @ -43,7 +46,9 @@ def register_signals( | |||||||
|  |  | ||||||
|     def model_pre_delete(sender: type[Model], instance: User | Group, **_): |     def model_pre_delete(sender: type[Model], instance: User | Group, **_): | ||||||
|         """Pre-delete handler""" |         """Pre-delete handler""" | ||||||
|         if not provider_type.objects.filter(backchannel_application__isnull=False).exists(): |         if not provider_type.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ).exists(): | ||||||
|             return |             return | ||||||
|         task_sync_direct.delay( |         task_sync_direct.delay( | ||||||
|             class_to_path(instance.__class__), instance.pk, Direction.remove.value |             class_to_path(instance.__class__), instance.pk, Direction.remove.value | ||||||
| @ -58,7 +63,9 @@ def register_signals( | |||||||
|         """Sync group membership""" |         """Sync group membership""" | ||||||
|         if action not in ["post_add", "post_remove"]: |         if action not in ["post_add", "post_remove"]: | ||||||
|             return |             return | ||||||
|         if not provider_type.objects.filter(backchannel_application__isnull=False).exists(): |         if not provider_type.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ).exists(): | ||||||
|             return |             return | ||||||
|         # reverse: instance is a Group, pk_set is a list of user pks |         # reverse: instance is a Group, pk_set is a list of user pks | ||||||
|         # non-reverse: instance is a User, pk_set is a list of groups |         # non-reverse: instance is a User, pk_set is a list of groups | ||||||
|  | |||||||
| @ -5,6 +5,7 @@ from celery.exceptions import Retry | |||||||
| from celery.result import allow_join_result | from celery.result import allow_join_result | ||||||
| from django.core.paginator import Paginator | from django.core.paginator import Paginator | ||||||
| from django.db.models import Model, QuerySet | from django.db.models import Model, QuerySet | ||||||
|  | from django.db.models.query import Q | ||||||
| from django.utils.text import slugify | from django.utils.text import slugify | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| from structlog.stdlib import BoundLogger, get_logger | from structlog.stdlib import BoundLogger, get_logger | ||||||
| @ -37,7 +38,9 @@ class SyncTasks: | |||||||
|         self._provider_model = provider_model |         self._provider_model = provider_model | ||||||
|  |  | ||||||
|     def sync_all(self, single_sync: Callable[[int], None]): |     def sync_all(self, single_sync: Callable[[int], None]): | ||||||
|         for provider in self._provider_model.objects.filter(backchannel_application__isnull=False): |         for provider in self._provider_model.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ): | ||||||
|             self.trigger_single_task(provider, single_sync) |             self.trigger_single_task(provider, single_sync) | ||||||
|  |  | ||||||
|     def trigger_single_task(self, provider: OutgoingSyncProvider, sync_task: Callable[[int], None]): |     def trigger_single_task(self, provider: OutgoingSyncProvider, sync_task: Callable[[int], None]): | ||||||
| @ -62,7 +65,8 @@ class SyncTasks: | |||||||
|             provider_pk=provider_pk, |             provider_pk=provider_pk, | ||||||
|         ) |         ) | ||||||
|         provider = self._provider_model.objects.filter( |         provider = self._provider_model.objects.filter( | ||||||
|             pk=provider_pk, backchannel_application__isnull=False |             Q(backchannel_application__isnull=False) | Q(application__isnull=False), | ||||||
|  |             pk=provider_pk, | ||||||
|         ).first() |         ).first() | ||||||
|         if not provider: |         if not provider: | ||||||
|             return |             return | ||||||
| @ -204,7 +208,9 @@ class SyncTasks: | |||||||
|         if not instance: |         if not instance: | ||||||
|             return |             return | ||||||
|         operation = Direction(raw_op) |         operation = Direction(raw_op) | ||||||
|         for provider in self._provider_model.objects.filter(backchannel_application__isnull=False): |         for provider in self._provider_model.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ): | ||||||
|             client = provider.client_for_model(instance.__class__) |             client = provider.client_for_model(instance.__class__) | ||||||
|             # Check if the object is allowed within the provider's restrictions |             # Check if the object is allowed within the provider's restrictions | ||||||
|             queryset = provider.get_object_qs(instance.__class__) |             queryset = provider.get_object_qs(instance.__class__) | ||||||
| @ -223,6 +229,8 @@ class SyncTasks: | |||||||
|                     client.delete(instance) |                     client.delete(instance) | ||||||
|             except TransientSyncException as exc: |             except TransientSyncException as exc: | ||||||
|                 raise Retry() from exc |                 raise Retry() from exc | ||||||
|  |             except SkipObjectException: | ||||||
|  |                 continue | ||||||
|             except StopSync as exc: |             except StopSync as exc: | ||||||
|                 self.logger.warning(exc, provider_pk=provider.pk) |                 self.logger.warning(exc, provider_pk=provider.pk) | ||||||
|  |  | ||||||
| @ -233,7 +241,9 @@ class SyncTasks: | |||||||
|         group = Group.objects.filter(pk=group_pk).first() |         group = Group.objects.filter(pk=group_pk).first() | ||||||
|         if not group: |         if not group: | ||||||
|             return |             return | ||||||
|         for provider in self._provider_model.objects.filter(backchannel_application__isnull=False): |         for provider in self._provider_model.objects.filter( | ||||||
|  |             Q(backchannel_application__isnull=False) | Q(application__isnull=False) | ||||||
|  |         ): | ||||||
|             # Check if the object is allowed within the provider's restrictions |             # Check if the object is allowed within the provider's restrictions | ||||||
|             queryset: QuerySet = provider.get_object_qs(Group) |             queryset: QuerySet = provider.get_object_qs(Group) | ||||||
|             # The queryset we get from the provider must include the instance we've got given |             # The queryset we get from the provider must include the instance we've got given | ||||||
| @ -251,5 +261,7 @@ class SyncTasks: | |||||||
|                 client.update_group(group, operation, pk_set) |                 client.update_group(group, operation, pk_set) | ||||||
|             except TransientSyncException as exc: |             except TransientSyncException as exc: | ||||||
|                 raise Retry() from exc |                 raise Retry() from exc | ||||||
|  |             except SkipObjectException: | ||||||
|  |                 continue | ||||||
|             except StopSync as exc: |             except StopSync as exc: | ||||||
|                 self.logger.warning(exc, provider_pk=provider.pk) |                 self.logger.warning(exc, provider_pk=provider.pk) | ||||||
|  | |||||||
| @ -30,6 +30,11 @@ class TestHTTP(TestCase): | |||||||
|         request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2") |         request = self.factory.get("/", HTTP_X_FORWARDED_FOR="127.0.0.2") | ||||||
|         self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.2") |         self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.2") | ||||||
|  |  | ||||||
|  |     def test_forward_for_invalid(self): | ||||||
|  |         """Test invalid forward for""" | ||||||
|  |         request = self.factory.get("/", HTTP_X_FORWARDED_FOR="foobar") | ||||||
|  |         self.assertEqual(ClientIPMiddleware.get_client_ip(request), ClientIPMiddleware.default_ip) | ||||||
|  |  | ||||||
|     def test_fake_outpost(self): |     def test_fake_outpost(self): | ||||||
|         """Test faked IP which is overridden by an outpost""" |         """Test faked IP which is overridden by an outpost""" | ||||||
|         token = Token.objects.create( |         token = Token.objects.create( | ||||||
| @ -53,6 +58,17 @@ class TestHTTP(TestCase): | |||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|         self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1") |         self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1") | ||||||
|  |         # Invalid, not a real IP | ||||||
|  |         self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT | ||||||
|  |         self.user.save() | ||||||
|  |         request = self.factory.get( | ||||||
|  |             "/", | ||||||
|  |             **{ | ||||||
|  |                 ClientIPMiddleware.outpost_remote_ip_header: "foobar", | ||||||
|  |                 ClientIPMiddleware.outpost_token_header: token.key, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(ClientIPMiddleware.get_client_ip(request), "127.0.0.1") | ||||||
|         # Valid |         # Valid | ||||||
|         self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT |         self.user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT | ||||||
|         self.user.save() |         self.user.save() | ||||||
|  | |||||||
| @ -20,6 +20,7 @@ from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSeri | |||||||
| from authentik.core.models import Provider | from authentik.core.models import Provider | ||||||
| from authentik.enterprise.license import LicenseKey | from authentik.enterprise.license import LicenseKey | ||||||
| from authentik.enterprise.providers.rac.models import RACProvider | from authentik.enterprise.providers.rac.models import RACProvider | ||||||
|  | from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator | ||||||
| from authentik.outposts.api.service_connections import ServiceConnectionSerializer | from authentik.outposts.api.service_connections import ServiceConnectionSerializer | ||||||
| from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME | from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME | ||||||
| from authentik.outposts.models import ( | from authentik.outposts.models import ( | ||||||
| @ -49,6 +50,10 @@ class OutpostSerializer(ModelSerializer): | |||||||
|     service_connection_obj = ServiceConnectionSerializer( |     service_connection_obj = ServiceConnectionSerializer( | ||||||
|         source="service_connection", read_only=True |         source="service_connection", read_only=True | ||||||
|     ) |     ) | ||||||
|  |     refresh_interval_s = SerializerMethodField() | ||||||
|  |  | ||||||
|  |     def get_refresh_interval_s(self, obj: Outpost) -> int: | ||||||
|  |         return int(timedelta_from_string(obj.config.refresh_interval).total_seconds()) | ||||||
|  |  | ||||||
|     def validate_name(self, name: str) -> str: |     def validate_name(self, name: str) -> str: | ||||||
|         """Validate name (especially for embedded outpost)""" |         """Validate name (especially for embedded outpost)""" | ||||||
| @ -84,7 +89,8 @@ class OutpostSerializer(ModelSerializer): | |||||||
|     def validate_config(self, config) -> dict: |     def validate_config(self, config) -> dict: | ||||||
|         """Check that the config has all required fields""" |         """Check that the config has all required fields""" | ||||||
|         try: |         try: | ||||||
|             from_dict(OutpostConfig, config) |             parsed = from_dict(OutpostConfig, config) | ||||||
|  |             timedelta_string_validator(parsed.refresh_interval) | ||||||
|         except DaciteError as exc: |         except DaciteError as exc: | ||||||
|             raise ValidationError(f"Failed to validate config: {str(exc)}") from exc |             raise ValidationError(f"Failed to validate config: {str(exc)}") from exc | ||||||
|         return config |         return config | ||||||
| @ -99,6 +105,7 @@ class OutpostSerializer(ModelSerializer): | |||||||
|             "providers_obj", |             "providers_obj", | ||||||
|             "service_connection", |             "service_connection", | ||||||
|             "service_connection_obj", |             "service_connection_obj", | ||||||
|  |             "refresh_interval_s", | ||||||
|             "token_identifier", |             "token_identifier", | ||||||
|             "config", |             "config", | ||||||
|             "managed", |             "managed", | ||||||
|  | |||||||
| @ -26,6 +26,7 @@ from authentik.outposts.models import ( | |||||||
|     KubernetesServiceConnection, |     KubernetesServiceConnection, | ||||||
|     OutpostServiceConnection, |     OutpostServiceConnection, | ||||||
| ) | ) | ||||||
|  | from authentik.rbac.filters import ObjectFilter | ||||||
|  |  | ||||||
|  |  | ||||||
| class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): | class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer): | ||||||
| @ -75,7 +76,7 @@ class ServiceConnectionViewSet( | |||||||
|     filterset_fields = ["name"] |     filterset_fields = ["name"] | ||||||
|  |  | ||||||
|     @extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) |     @extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)}) | ||||||
|     @action(detail=True, pagination_class=None, filter_backends=[]) |     @action(detail=True, pagination_class=None, filter_backends=[ObjectFilter]) | ||||||
|     def state(self, request: Request, pk: str) -> Response: |     def state(self, request: Request, pk: str) -> Response: | ||||||
|         """Get the service connection's state""" |         """Get the service connection's state""" | ||||||
|         connection = self.get_object() |         connection = self.get_object() | ||||||
|  | |||||||
| @ -13,16 +13,17 @@ import authentik.outposts.models | |||||||
|  |  | ||||||
|  |  | ||||||
| def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|     User = apps.get_model("authentik_core", "User") |     User = apps.get_model("authentik_core", "User") | ||||||
|     Token = apps.get_model("authentik_core", "Token") |     Token = apps.get_model("authentik_core", "Token") | ||||||
|     from authentik.outposts.models import Outpost |     from authentik.outposts.models import Outpost | ||||||
|  |  | ||||||
|     for outpost in Outpost.objects.using(schema_editor.connection.alias).all().only("pk"): |     for outpost in Outpost.objects.using(db_alias).all().only("pk"): | ||||||
|         user_identifier = outpost.user_identifier |         user_identifier = outpost.user_identifier | ||||||
|         users = User.objects.filter(username=user_identifier) |         users = User.objects.using(db_alias).filter(username=user_identifier) | ||||||
|         if not users.exists(): |         if not users.exists(): | ||||||
|             continue |             continue | ||||||
|         tokens = Token.objects.filter(user=users.first()) |         tokens = Token.objects.using(db_alias).filter(user=users.first()) | ||||||
|         for token in tokens: |         for token in tokens: | ||||||
|             if token.identifier != outpost.token_identifier: |             if token.identifier != outpost.token_identifier: | ||||||
|                 token.identifier = outpost.token_identifier |                 token.identifier = outpost.token_identifier | ||||||
| @ -37,8 +38,8 @@ def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaE | |||||||
|         "authentik_outposts", "KubernetesServiceConnection" |         "authentik_outposts", "KubernetesServiceConnection" | ||||||
|     ) |     ) | ||||||
|  |  | ||||||
|     docker = DockerServiceConnection.objects.filter(local=True).first() |     docker = DockerServiceConnection.objects.using(db_alias).filter(local=True).first() | ||||||
|     k8s = KubernetesServiceConnection.objects.filter(local=True).first() |     k8s = KubernetesServiceConnection.objects.using(db_alias).filter(local=True).first() | ||||||
|  |  | ||||||
|     try: |     try: | ||||||
|         for outpost in Outpost.objects.using(db_alias).all().exclude(deployment_type="custom"): |         for outpost in Outpost.objects.using(db_alias).all().exclude(deployment_type="custom"): | ||||||
| @ -54,21 +55,21 @@ def migrate_to_service_connection(apps: Apps, schema_editor: BaseDatabaseSchemaE | |||||||
|  |  | ||||||
|  |  | ||||||
| def remove_pb_prefix_users(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def remove_pb_prefix_users(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|     User = apps.get_model("authentik_core", "User") |     User = apps.get_model("authentik_core", "User") | ||||||
|     Outpost = apps.get_model("authentik_outposts", "Outpost") |     Outpost = apps.get_model("authentik_outposts", "Outpost") | ||||||
|  |  | ||||||
|     for outpost in Outpost.objects.using(alias).all(): |     for outpost in Outpost.objects.using(db_alias).all(): | ||||||
|         matching = User.objects.using(alias).filter(username=f"pb-outpost-{outpost.uuid.hex}") |         matching = User.objects.using(db_alias).filter(username=f"pb-outpost-{outpost.uuid.hex}") | ||||||
|         if matching.exists(): |         if matching.exists(): | ||||||
|             matching.delete() |             matching.delete() | ||||||
|  |  | ||||||
|  |  | ||||||
| def update_config_prefix(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def update_config_prefix(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|     alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|     Outpost = apps.get_model("authentik_outposts", "Outpost") |     Outpost = apps.get_model("authentik_outposts", "Outpost") | ||||||
|  |  | ||||||
|     for outpost in Outpost.objects.using(alias).all(): |     for outpost in Outpost.objects.using(db_alias).all(): | ||||||
|         config = outpost._config |         config = outpost._config | ||||||
|         for key in list(config): |         for key in list(config): | ||||||
|             if "passbook" in key: |             if "passbook" in key: | ||||||
|  | |||||||
| @ -61,6 +61,7 @@ class OutpostConfig: | |||||||
|  |  | ||||||
|     log_level: str = CONFIG.get("log_level") |     log_level: str = CONFIG.get("log_level") | ||||||
|     object_naming_template: str = field(default="ak-outpost-%(name)s") |     object_naming_template: str = field(default="ak-outpost-%(name)s") | ||||||
|  |     refresh_interval: str = "minutes=5" | ||||||
|  |  | ||||||
|     container_image: str | None = field(default=None) |     container_image: str | None = field(default=None) | ||||||
|  |  | ||||||
|  | |||||||
| @ -2,7 +2,6 @@ | |||||||
|  |  | ||||||
| from dataclasses import asdict | from dataclasses import asdict | ||||||
|  |  | ||||||
| from channels.exceptions import DenyConnection |  | ||||||
| from channels.routing import URLRouter | from channels.routing import URLRouter | ||||||
| from channels.testing import WebsocketCommunicator | from channels.testing import WebsocketCommunicator | ||||||
| from django.test import TransactionTestCase | from django.test import TransactionTestCase | ||||||
| @ -37,9 +36,8 @@ class TestOutpostWS(TransactionTestCase): | |||||||
|         communicator = WebsocketCommunicator( |         communicator = WebsocketCommunicator( | ||||||
|             URLRouter(websocket.websocket_urlpatterns), f"/ws/outpost/{self.outpost.pk}/" |             URLRouter(websocket.websocket_urlpatterns), f"/ws/outpost/{self.outpost.pk}/" | ||||||
|         ) |         ) | ||||||
|         with self.assertRaises(DenyConnection): |         connected, _ = await communicator.connect() | ||||||
|             connected, _ = await communicator.connect() |         self.assertFalse(connected) | ||||||
|             self.assertFalse(connected) |  | ||||||
|  |  | ||||||
|     async def test_auth_valid(self): |     async def test_auth_valid(self): | ||||||
|         """Test auth with token""" |         """Test auth with token""" | ||||||
|  | |||||||
| @ -2,13 +2,13 @@ | |||||||
|  |  | ||||||
| from django.urls import path | from django.urls import path | ||||||
|  |  | ||||||
| from authentik.core.channels import TokenOutpostMiddleware |  | ||||||
| from authentik.outposts.api.outposts import OutpostViewSet | from authentik.outposts.api.outposts import OutpostViewSet | ||||||
| from authentik.outposts.api.service_connections import ( | from authentik.outposts.api.service_connections import ( | ||||||
|     DockerServiceConnectionViewSet, |     DockerServiceConnectionViewSet, | ||||||
|     KubernetesServiceConnectionViewSet, |     KubernetesServiceConnectionViewSet, | ||||||
|     ServiceConnectionViewSet, |     ServiceConnectionViewSet, | ||||||
| ) | ) | ||||||
|  | from authentik.outposts.channels import TokenOutpostMiddleware | ||||||
| from authentik.outposts.consumer import OutpostConsumer | from authentik.outposts.consumer import OutpostConsumer | ||||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | from authentik.root.middleware import ChannelsLoggingMiddleware | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,6 +1,8 @@ | |||||||
| """Reputation policy API Views""" | """Reputation policy API Views""" | ||||||
|  |  | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
|  | from django_filters.filters import BaseInFilter, CharFilter | ||||||
|  | from django_filters.filterset import FilterSet | ||||||
| from rest_framework import mixins | from rest_framework import mixins | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
| from rest_framework.viewsets import GenericViewSet, ModelViewSet | from rest_framework.viewsets import GenericViewSet, ModelViewSet | ||||||
| @ -11,6 +13,10 @@ from authentik.policies.api.policies import PolicySerializer | |||||||
| from authentik.policies.reputation.models import Reputation, ReputationPolicy | from authentik.policies.reputation.models import Reputation, ReputationPolicy | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class CharInFilter(BaseInFilter, CharFilter): | ||||||
|  |     pass | ||||||
|  |  | ||||||
|  |  | ||||||
| class ReputationPolicySerializer(PolicySerializer): | class ReputationPolicySerializer(PolicySerializer): | ||||||
|     """Reputation Policy Serializer""" |     """Reputation Policy Serializer""" | ||||||
|  |  | ||||||
| @ -38,6 +44,16 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet): | |||||||
|     ordering = ["name"] |     ordering = ["name"] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ReputationFilter(FilterSet): | ||||||
|  |     """Filter for reputation""" | ||||||
|  |  | ||||||
|  |     identifier_in = CharInFilter(field_name="identifier", lookup_expr="in") | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = Reputation | ||||||
|  |         fields = ["identifier", "ip", "score"] | ||||||
|  |  | ||||||
|  |  | ||||||
| class ReputationSerializer(ModelSerializer): | class ReputationSerializer(ModelSerializer): | ||||||
|     """Reputation Serializer""" |     """Reputation Serializer""" | ||||||
|  |  | ||||||
| @ -66,5 +82,5 @@ class ReputationViewSet( | |||||||
|     queryset = Reputation.objects.all() |     queryset = Reputation.objects.all() | ||||||
|     serializer_class = ReputationSerializer |     serializer_class = ReputationSerializer | ||||||
|     search_fields = ["identifier", "ip", "score"] |     search_fields = ["identifier", "ip", "score"] | ||||||
|     filterset_fields = ["identifier", "ip", "score"] |     filterset_class = ReputationFilter | ||||||
|     ordering = ["ip"] |     ordering = ["ip"] | ||||||
|  | |||||||
| @ -29,7 +29,6 @@ class TesOAuth2Introspection(OAuthTestCase): | |||||||
|         self.app = Application.objects.create( |         self.app = Application.objects.create( | ||||||
|             name=generate_id(), slug=generate_id(), provider=self.provider |             name=generate_id(), slug=generate_id(), provider=self.provider | ||||||
|         ) |         ) | ||||||
|         self.app.save() |  | ||||||
|         self.user = create_test_admin_user() |         self.user = create_test_admin_user() | ||||||
|         self.auth = b64encode( |         self.auth = b64encode( | ||||||
|             f"{self.provider.client_id}:{self.provider.client_secret}".encode() |             f"{self.provider.client_id}:{self.provider.client_secret}".encode() | ||||||
| @ -114,6 +113,41 @@ class TesOAuth2Introspection(OAuthTestCase): | |||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     def test_introspect_invalid_provider(self): | ||||||
|  |         """Test introspection (mismatched provider and token)""" | ||||||
|  |         provider: OAuth2Provider = OAuth2Provider.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             authorization_flow=create_test_flow(), | ||||||
|  |             redirect_uris="", | ||||||
|  |             signing_key=create_test_cert(), | ||||||
|  |         ) | ||||||
|  |         auth = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() | ||||||
|  |  | ||||||
|  |         token: AccessToken = AccessToken.objects.create( | ||||||
|  |             provider=self.provider, | ||||||
|  |             user=self.user, | ||||||
|  |             token=generate_id(), | ||||||
|  |             auth_time=timezone.now(), | ||||||
|  |             _scope="openid user profile", | ||||||
|  |             _id_token=json.dumps( | ||||||
|  |                 asdict( | ||||||
|  |                     IDToken("foo", "bar"), | ||||||
|  |                 ) | ||||||
|  |             ), | ||||||
|  |         ) | ||||||
|  |         res = self.client.post( | ||||||
|  |             reverse("authentik_providers_oauth2:token-introspection"), | ||||||
|  |             HTTP_AUTHORIZATION=f"Basic {auth}", | ||||||
|  |             data={"token": token.token}, | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(res.status_code, 200) | ||||||
|  |         self.assertJSONEqual( | ||||||
|  |             res.content.decode(), | ||||||
|  |             { | ||||||
|  |                 "active": False, | ||||||
|  |             }, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def test_introspect_invalid_auth(self): |     def test_introspect_invalid_auth(self): | ||||||
|         """Test introspect (invalid auth)""" |         """Test introspect (invalid auth)""" | ||||||
|         res = self.client.post( |         res = self.client.post( | ||||||
|  | |||||||
| @ -46,10 +46,10 @@ class TokenIntrospectionParams: | |||||||
|         if not provider: |         if not provider: | ||||||
|             raise TokenIntrospectionError |             raise TokenIntrospectionError | ||||||
|  |  | ||||||
|         access_token = AccessToken.objects.filter(token=raw_token).first() |         access_token = AccessToken.objects.filter(token=raw_token, provider=provider).first() | ||||||
|         if access_token: |         if access_token: | ||||||
|             return TokenIntrospectionParams(access_token, provider) |             return TokenIntrospectionParams(access_token, provider) | ||||||
|         refresh_token = RefreshToken.objects.filter(token=raw_token).first() |         refresh_token = RefreshToken.objects.filter(token=raw_token, provider=provider).first() | ||||||
|         if refresh_token: |         if refresh_token: | ||||||
|             return TokenIntrospectionParams(refresh_token, provider) |             return TokenIntrospectionParams(refresh_token, provider) | ||||||
|         LOGGER.debug("Token does not exist", token=raw_token) |         LOGGER.debug("Token does not exist", token=raw_token) | ||||||
|  | |||||||
| @ -268,7 +268,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet): | |||||||
|         except ValueError as exc:  # pragma: no cover |         except ValueError as exc:  # pragma: no cover | ||||||
|             LOGGER.warning(str(exc)) |             LOGGER.warning(str(exc)) | ||||||
|             raise ValidationError( |             raise ValidationError( | ||||||
|                 _("Failed to import Metadata: {messages}".format_map({"message": str(exc)})), |                 _("Failed to import Metadata: {messages}".format_map({"messages": str(exc)})), | ||||||
|             ) from None |             ) from None | ||||||
|         return Response(status=204) |         return Response(status=204) | ||||||
|  |  | ||||||
|  | |||||||
| @ -89,6 +89,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( | |||||||
|             return ServiceProviderConfiguration.model_validate( |             return ServiceProviderConfiguration.model_validate( | ||||||
|                 self._request("GET", "/ServiceProviderConfig") |                 self._request("GET", "/ServiceProviderConfig") | ||||||
|             ) |             ) | ||||||
|         except (ValidationError, SCIMRequestException) as exc: |         except (ValidationError, SCIMRequestException, NotFoundSyncException) as exc: | ||||||
|             self.logger.warning("failed to get ServiceProviderConfig", exc=exc) |             self.logger.warning("failed to get ServiceProviderConfig", exc=exc) | ||||||
|             return default_config |             return default_config | ||||||
|  | |||||||
| @ -2,6 +2,7 @@ | |||||||
|  |  | ||||||
| from collections.abc import Callable | from collections.abc import Callable | ||||||
| from hashlib import sha512 | from hashlib import sha512 | ||||||
|  | from ipaddress import ip_address | ||||||
| from time import perf_counter, time | from time import perf_counter, time | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| @ -174,6 +175,7 @@ class ClientIPMiddleware: | |||||||
|  |  | ||||||
|     def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): |     def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]): | ||||||
|         self.get_response = get_response |         self.get_response = get_response | ||||||
|  |         self.logger = get_logger().bind() | ||||||
|  |  | ||||||
|     def _get_client_ip_from_meta(self, meta: dict[str, Any]) -> str: |     def _get_client_ip_from_meta(self, meta: dict[str, Any]) -> str: | ||||||
|         """Attempt to get the client's IP by checking common HTTP Headers. |         """Attempt to get the client's IP by checking common HTTP Headers. | ||||||
| @ -185,11 +187,16 @@ class ClientIPMiddleware: | |||||||
|             "HTTP_X_FORWARDED_FOR", |             "HTTP_X_FORWARDED_FOR", | ||||||
|             "REMOTE_ADDR", |             "REMOTE_ADDR", | ||||||
|         ) |         ) | ||||||
|         for _header in headers: |         try: | ||||||
|             if _header in meta: |             for _header in headers: | ||||||
|                 ips: list[str] = meta.get(_header).split(",") |                 if _header in meta: | ||||||
|                 return ips[0].strip() |                     ips: list[str] = meta.get(_header).split(",") | ||||||
|         return self.default_ip |                     # Ensure the IP parses as a valid IP | ||||||
|  |                     return str(ip_address(ips[0].strip())) | ||||||
|  |             return self.default_ip | ||||||
|  |         except ValueError as exc: | ||||||
|  |             self.logger.debug("Invalid remote IP", exc=exc) | ||||||
|  |             return self.default_ip | ||||||
|  |  | ||||||
|     # FIXME: this should probably not be in `root` but rather in a middleware in `outposts` |     # FIXME: this should probably not be in `root` but rather in a middleware in `outposts` | ||||||
|     # but for now it's fine |     # but for now it's fine | ||||||
| @ -228,7 +235,11 @@ class ClientIPMiddleware: | |||||||
|         Hub.current.scope.set_user(user) |         Hub.current.scope.set_user(user) | ||||||
|         # Set the outpost service account on the request |         # Set the outpost service account on the request | ||||||
|         setattr(request, self.request_attr_outpost_user, user) |         setattr(request, self.request_attr_outpost_user, user) | ||||||
|         return delegated_ip |         try: | ||||||
|  |             return str(ip_address(delegated_ip)) | ||||||
|  |         except ValueError as exc: | ||||||
|  |             self.logger.debug("Invalid remote IP from Outpost", exc=exc) | ||||||
|  |             return None | ||||||
|  |  | ||||||
|     def _get_client_ip(self, request: HttpRequest | None) -> str: |     def _get_client_ip(self, request: HttpRequest | None) -> str: | ||||||
|         """Attempt to get the client's IP by checking common HTTP Headers. |         """Attempt to get the client's IP by checking common HTTP Headers. | ||||||
| @ -274,9 +285,13 @@ class ChannelsLoggingMiddleware: | |||||||
|         self.log(scope) |         self.log(scope) | ||||||
|         try: |         try: | ||||||
|             return await self.inner(scope, receive, send) |             return await self.inner(scope, receive, send) | ||||||
|  |         except DenyConnection: | ||||||
|  |             return await send({"type": "websocket.close"}) | ||||||
|         except Exception as exc: |         except Exception as exc: | ||||||
|  |             if settings.DEBUG: | ||||||
|  |                 raise exc | ||||||
|             LOGGER.warning("Exception in ASGI application", exc=exc) |             LOGGER.warning("Exception in ASGI application", exc=exc) | ||||||
|             raise DenyConnection() from None |             return await send({"type": "websocket.close"}) | ||||||
|  |  | ||||||
|     def log(self, scope: dict, **kwargs): |     def log(self, scope: dict, **kwargs): | ||||||
|         """Log request""" |         """Log request""" | ||||||
|  | |||||||
| @ -31,9 +31,9 @@ def set_default_group_mappings(apps: Apps, schema_editor): | |||||||
|     db_alias = schema_editor.connection.alias |     db_alias = schema_editor.connection.alias | ||||||
|  |  | ||||||
|     for source in LDAPSource.objects.using(db_alias).all(): |     for source in LDAPSource.objects.using(db_alias).all(): | ||||||
|         if source.property_mappings_group.exists(): |         if source.property_mappings_group.using(db_alias).exists(): | ||||||
|             continue |             continue | ||||||
|         source.property_mappings_group.set( |         source.property_mappings_group.using(db_alias).set( | ||||||
|             LDAPPropertyMapping.objects.using(db_alias).filter( |             LDAPPropertyMapping.objects.using(db_alias).filter( | ||||||
|                 managed="goauthentik.io/sources/ldap/default-name" |                 managed="goauthentik.io/sources/ldap/default-name" | ||||||
|             ) |             ) | ||||||
|  | |||||||
| @ -39,7 +39,7 @@ def sync_ldap_source_on_save(sender, instance: LDAPSource, **_): | |||||||
| @receiver(password_validate) | @receiver(password_validate) | ||||||
| def ldap_password_validate(sender, password: str, plan_context: dict[str, Any], **__): | def ldap_password_validate(sender, password: str, plan_context: dict[str, Any], **__): | ||||||
|     """if there's an LDAP Source with enabled password sync, check the password""" |     """if there's an LDAP Source with enabled password sync, check the password""" | ||||||
|     sources = LDAPSource.objects.filter(sync_users_password=True) |     sources = LDAPSource.objects.filter(sync_users_password=True, enabled=True) | ||||||
|     if not sources.exists(): |     if not sources.exists(): | ||||||
|         return |         return | ||||||
|     source = sources.first() |     source = sources.first() | ||||||
| @ -56,7 +56,7 @@ def ldap_password_validate(sender, password: str, plan_context: dict[str, Any], | |||||||
| @receiver(password_changed) | @receiver(password_changed) | ||||||
| def ldap_sync_password(sender, user: User, password: str, **_): | def ldap_sync_password(sender, user: User, password: str, **_): | ||||||
|     """Connect to ldap and update password.""" |     """Connect to ldap and update password.""" | ||||||
|     sources = LDAPSource.objects.filter(sync_users_password=True) |     sources = LDAPSource.objects.filter(sync_users_password=True, enabled=True) | ||||||
|     if not sources.exists(): |     if not sources.exists(): | ||||||
|         return |         return | ||||||
|     source = sources.first() |     source = sources.first() | ||||||
|  | |||||||
| @ -2,9 +2,6 @@ | |||||||
|  |  | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| from facebook import GraphAPI |  | ||||||
|  |  | ||||||
| from authentik.sources.oauth.clients.oauth2 import OAuth2Client |  | ||||||
| from authentik.sources.oauth.types.registry import SourceType, registry | from authentik.sources.oauth.types.registry import SourceType, registry | ||||||
| from authentik.sources.oauth.views.callback import OAuthCallback | from authentik.sources.oauth.views.callback import OAuthCallback | ||||||
| from authentik.sources.oauth.views.redirect import OAuthRedirect | from authentik.sources.oauth.views.redirect import OAuthRedirect | ||||||
| @ -19,19 +16,9 @@ class FacebookOAuthRedirect(OAuthRedirect): | |||||||
|         } |         } | ||||||
|  |  | ||||||
|  |  | ||||||
| class FacebookOAuth2Client(OAuth2Client): |  | ||||||
|     """Facebook OAuth2 Client""" |  | ||||||
|  |  | ||||||
|     def get_profile_info(self, token: dict[str, str]) -> dict[str, Any] | None: |  | ||||||
|         api = GraphAPI(access_token=token["access_token"]) |  | ||||||
|         return api.get_object("me", fields="id,name,email") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class FacebookOAuth2Callback(OAuthCallback): | class FacebookOAuth2Callback(OAuthCallback): | ||||||
|     """Facebook OAuth2 Callback""" |     """Facebook OAuth2 Callback""" | ||||||
|  |  | ||||||
|     client_class = FacebookOAuth2Client |  | ||||||
|  |  | ||||||
|     def get_user_enroll_context( |     def get_user_enroll_context( | ||||||
|         self, |         self, | ||||||
|         info: dict[str, Any], |         info: dict[str, Any], | ||||||
|  | |||||||
| @ -10,6 +10,8 @@ from authentik.sources.saml.processors import constants | |||||||
|  |  | ||||||
|  |  | ||||||
| def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||||
|  |     db_alias = schema_editor.connection.alias | ||||||
|  |  | ||||||
|     SAMLSource = apps.get_model("authentik_sources_saml", "SAMLSource") |     SAMLSource = apps.get_model("authentik_sources_saml", "SAMLSource") | ||||||
|     signature_translation_map = { |     signature_translation_map = { | ||||||
|         "rsa-sha1": constants.RSA_SHA1, |         "rsa-sha1": constants.RSA_SHA1, | ||||||
| @ -22,7 +24,7 @@ def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|         "sha256": constants.SHA256, |         "sha256": constants.SHA256, | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     for source in SAMLSource.objects.all(): |     for source in SAMLSource.objects.using(db_alias).all(): | ||||||
|         source.signature_algorithm = signature_translation_map.get( |         source.signature_algorithm = signature_translation_map.get( | ||||||
|             source.signature_algorithm, constants.RSA_SHA256 |             source.signature_algorithm, constants.RSA_SHA256 | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -10,6 +10,7 @@ from django.core.cache import cache | |||||||
| from django.core.exceptions import SuspiciousOperation | from django.core.exceptions import SuspiciousOperation | ||||||
| from django.http import HttpRequest | from django.http import HttpRequest | ||||||
| from django.utils.timezone import now | from django.utils.timezone import now | ||||||
|  | from lxml import etree  # nosec | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import ( | from authentik.core.models import ( | ||||||
| @ -240,7 +241,7 @@ class ResponseProcessor: | |||||||
|             name_id.text, |             name_id.text, | ||||||
|             delete_none_values(self.get_attributes()), |             delete_none_values(self.get_attributes()), | ||||||
|         ) |         ) | ||||||
|         flow_manager.policy_context["saml_response"] = self._root |         flow_manager.policy_context["saml_response"] = etree.tostring(self._root) | ||||||
|         return flow_manager |         return flow_manager | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -1,7 +1,5 @@ | |||||||
| """SCIM Source""" | """SCIM Source""" | ||||||
|  |  | ||||||
| from uuid import uuid4 |  | ||||||
|  |  | ||||||
| from django.db import models | from django.db import models | ||||||
| from django.templatetags.static import static | from django.templatetags.static import static | ||||||
| from django.utils.translation import gettext_lazy as _ | from django.utils.translation import gettext_lazy as _ | ||||||
| @ -19,8 +17,6 @@ class SCIMSource(Source): | |||||||
|  |  | ||||||
|     @property |     @property | ||||||
|     def service_account_identifier(self) -> str: |     def service_account_identifier(self) -> str: | ||||||
|         if not self.pk: |  | ||||||
|             self.pk = uuid4() |  | ||||||
|         return f"ak-source-scim-{self.pk}" |         return f"ak-source-scim-{self.pk}" | ||||||
|  |  | ||||||
|     @property |     @property | ||||||
|  | |||||||
| @ -1,41 +1,44 @@ | |||||||
| from django.db.models import Model | from django.db.models import Model | ||||||
| from django.db.models.signals import pre_delete, pre_save | from django.db.models.signals import post_delete, post_save | ||||||
| from django.dispatch import receiver | from django.dispatch import receiver | ||||||
|  |  | ||||||
| from authentik.core.models import USER_PATH_SYSTEM_PREFIX, Token, TokenIntents, User, UserTypes | from authentik.core.models import USER_PATH_SYSTEM_PREFIX, Token, TokenIntents, User, UserTypes | ||||||
|  | from authentik.events.middleware import audit_ignore | ||||||
| from authentik.sources.scim.models import SCIMSource | from authentik.sources.scim.models import SCIMSource | ||||||
|  |  | ||||||
| USER_PATH_SOURCE_SCIM = USER_PATH_SYSTEM_PREFIX + "/sources/scim" | USER_PATH_SOURCE_SCIM = USER_PATH_SYSTEM_PREFIX + "/sources/scim" | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_save, sender=SCIMSource) | @receiver(post_save, sender=SCIMSource) | ||||||
| def scim_source_pre_save(sender: type[Model], instance: SCIMSource, **_): | def scim_source_post_save(sender: type[Model], instance: SCIMSource, created: bool, **_): | ||||||
|     """Create service account before source is saved""" |     """Create service account before source is saved""" | ||||||
|     # .service_account_identifier will auto-assign a primary key uuid to the source |  | ||||||
|     # if none is set yet, just so we can get the identifier before we save |  | ||||||
|     identifier = instance.service_account_identifier |     identifier = instance.service_account_identifier | ||||||
|     user = User.objects.create( |     user, _ = User.objects.update_or_create( | ||||||
|         username=identifier, |         username=identifier, | ||||||
|         name=f"SCIM Source {instance.name} Service-Account", |         defaults={ | ||||||
|         type=UserTypes.INTERNAL_SERVICE_ACCOUNT, |             "name": f"SCIM Source {instance.name} Service-Account", | ||||||
|         path=USER_PATH_SOURCE_SCIM, |             "type": UserTypes.INTERNAL_SERVICE_ACCOUNT, | ||||||
|  |             "path": USER_PATH_SOURCE_SCIM, | ||||||
|  |         }, | ||||||
|     ) |     ) | ||||||
|     token = Token.objects.create( |     token, token_created = Token.objects.update_or_create( | ||||||
|         user=user, |  | ||||||
|         identifier=identifier, |         identifier=identifier, | ||||||
|         intent=TokenIntents.INTENT_API, |         defaults={ | ||||||
|         expiring=False, |             "user": user, | ||||||
|         managed=f"goauthentik.io/sources/scim/{instance.pk}", |             "intent": TokenIntents.INTENT_API, | ||||||
|  |             "expiring": False, | ||||||
|  |             "managed": f"goauthentik.io/sources/scim/{instance.pk}", | ||||||
|  |         }, | ||||||
|     ) |     ) | ||||||
|     instance.token = token |     if created or token_created: | ||||||
|  |         with audit_ignore(): | ||||||
|  |             instance.token = token | ||||||
|  |             instance.save() | ||||||
|  |  | ||||||
|  |  | ||||||
| @receiver(pre_delete, sender=SCIMSource) | @receiver(post_delete, sender=SCIMSource) | ||||||
| def scim_source_pre_delete(sender: type[Model], instance: SCIMSource, **_): | def scim_source_post_delete(sender: type[Model], instance: SCIMSource, **_): | ||||||
|     """Delete SCIM Source service account before deleting source""" |     """Delete SCIM Source service account after deleting source""" | ||||||
|     Token.objects.filter( |  | ||||||
|         identifier=instance.service_account_identifier, intent=TokenIntents.INTENT_API |  | ||||||
|     ).delete() |  | ||||||
|     User.objects.filter( |     User.objects.filter( | ||||||
|         username=instance.service_account_identifier, type=UserTypes.INTERNAL_SERVICE_ACCOUNT |         username=instance.service_account_identifier, type=UserTypes.INTERNAL_SERVICE_ACCOUNT | ||||||
|     ).delete() |     ).delete() | ||||||
|  | |||||||
| @ -13,7 +13,7 @@ def migrate_configuration_stage(apps: Apps, schema_editor: BaseDatabaseSchemaEdi | |||||||
|  |  | ||||||
|     for stage in AuthenticatorValidateStage.objects.using(db_alias).all(): |     for stage in AuthenticatorValidateStage.objects.using(db_alias).all(): | ||||||
|         if stage.configuration_stage: |         if stage.configuration_stage: | ||||||
|             stage.configuration_stages.set([stage.configuration_stage]) |             stage.configuration_stages.using(db_alias).set([stage.configuration_stage]) | ||||||
|             stage.save() |             stage.save() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | |||||||
| @ -325,7 +325,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | |||||||
|             serializer = SelectableStageSerializer( |             serializer = SelectableStageSerializer( | ||||||
|                 data={ |                 data={ | ||||||
|                     "pk": stage.pk, |                     "pk": stage.pk, | ||||||
|                     "name": stage.friendly_name or stage.name, |                     "name": getattr(stage, "friendly_name", stage.name), | ||||||
|                     "verbose_name": str(stage._meta.verbose_name) |                     "verbose_name": str(stage._meta.verbose_name) | ||||||
|                     .replace("Setup Stage", "") |                     .replace("Setup Stage", "") | ||||||
|                     .strip(), |                     .strip(), | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ from django.urls import reverse | |||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
|  |  | ||||||
| from authentik.brands.utils import get_brand_for_request | from authentik.brands.utils import get_brand_for_request | ||||||
|  | from authentik.core.middleware import RESPONSE_HEADER_ID | ||||||
| from authentik.core.tests.utils import create_test_admin_user, create_test_flow | from authentik.core.tests.utils import create_test_admin_user, create_test_flow | ||||||
| from authentik.events.models import Event, EventAction | from authentik.events.models import Event, EventAction | ||||||
| from authentik.flows.models import FlowDesignation, FlowStageBinding | from authentik.flows.models import FlowDesignation, FlowStageBinding | ||||||
| @ -186,6 +187,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase): | |||||||
|                     "method": "GET", |                     "method": "GET", | ||||||
|                     "path": f"/api/v3/flows/executor/{flow.slug}/", |                     "path": f"/api/v3/flows/executor/{flow.slug}/", | ||||||
|                     "user_agent": "", |                     "user_agent": "", | ||||||
|  |                     "request_id": response[RESPONSE_HEADER_ID], | ||||||
|                 }, |                 }, | ||||||
|             }, |             }, | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -120,7 +120,7 @@ | |||||||
|             </tr> |             </tr> | ||||||
|             <tr> |             <tr> | ||||||
|               <td align="center"> |               <td align="center"> | ||||||
|                 Powered by <a href="https://goauthentik.io?utm_source=authentik&utm_medium=email">authentik</a>. |                 Powered by <a rel="noopener noreferrer" target="_blank" href="https://goauthentik.io?utm_source=authentik&utm_medium=email">authentik</a>. | ||||||
|               </td> |               </td> | ||||||
|             </tr> |             </tr> | ||||||
|           </table> |           </table> | ||||||
|  | |||||||
| @ -13,9 +13,9 @@ def assign_sources(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|     IdentificationStage = apps.get_model("authentik_stages_identification", "identificationstage") |     IdentificationStage = apps.get_model("authentik_stages_identification", "identificationstage") | ||||||
|     Source = apps.get_model("authentik_core", "source") |     Source = apps.get_model("authentik_core", "source") | ||||||
|  |  | ||||||
|     sources = Source.objects.all() |     sources = Source.objects.using(db_alias).all() | ||||||
|     for stage in IdentificationStage.objects.all().using(db_alias): |     for stage in IdentificationStage.objects.using(db_alias).all(): | ||||||
|         stage.sources.set(sources) |         stage.sources.using(db_alias).set(sources) | ||||||
|         stage.save() |         stage.save() | ||||||
|  |  | ||||||
|  |  | ||||||
| @ -144,7 +144,7 @@ class Migration(migrations.Migration): | |||||||
|                 default=None, |                 default=None, | ||||||
|                 help_text=( |                 help_text=( | ||||||
|                     "When set, shows a password field, instead of showing the password field as" |                     "When set, shows a password field, instead of showing the password field as" | ||||||
|                     " seaprate step." |                     " separate step." | ||||||
|                 ), |                 ), | ||||||
|                 null=True, |                 null=True, | ||||||
|                 on_delete=django.db.models.deletion.SET_NULL, |                 on_delete=django.db.models.deletion.SET_NULL, | ||||||
|  | |||||||
| @ -108,7 +108,7 @@ class PromptViewSet(UsedByMixin, ModelViewSet): | |||||||
|             return Response( |             return Response( | ||||||
|                 { |                 { | ||||||
|                     "non_field_errors": [ |                     "non_field_errors": [ | ||||||
|                         exception_to_string(exc), |                         exception_to_string(exc.exc), | ||||||
|                     ] |                     ] | ||||||
|                 }, |                 }, | ||||||
|                 status=400, |                 status=400, | ||||||
|  | |||||||
| @ -12,7 +12,7 @@ def set_generated_name(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | |||||||
|  |  | ||||||
|     for prompt in Prompt.objects.using(db_alias).all(): |     for prompt in Prompt.objects.using(db_alias).all(): | ||||||
|         name = prompt.field_key |         name = prompt.field_key | ||||||
|         stage = prompt.promptstage_set.order_by("name").first() |         stage = prompt.promptstage_set.using(db_alias).order_by("name").first() | ||||||
|         if stage: |         if stage: | ||||||
|             name += "_" + stage.name |             name += "_" + stage.name | ||||||
|         else: |         else: | ||||||
|  | |||||||
| @ -170,7 +170,7 @@ class Prompt(SerializerModel): | |||||||
|             try: |             try: | ||||||
|                 raw_choices = evaluator.evaluate(self.placeholder) |                 raw_choices = evaluator.evaluate(self.placeholder) | ||||||
|             except Exception as exc:  # pylint:disable=broad-except |             except Exception as exc:  # pylint:disable=broad-except | ||||||
|                 wrapped = PropertyMappingExpressionException(str(exc)) |                 wrapped = PropertyMappingExpressionException(exc, None) | ||||||
|                 LOGGER.warning( |                 LOGGER.warning( | ||||||
|                     "failed to evaluate prompt choices", |                     "failed to evaluate prompt choices", | ||||||
|                     exc=wrapped, |                     exc=wrapped, | ||||||
| @ -208,7 +208,7 @@ class Prompt(SerializerModel): | |||||||
|             try: |             try: | ||||||
|                 return evaluator.evaluate(self.placeholder) |                 return evaluator.evaluate(self.placeholder) | ||||||
|             except Exception as exc:  # pylint:disable=broad-except |             except Exception as exc:  # pylint:disable=broad-except | ||||||
|                 wrapped = PropertyMappingExpressionException(str(exc), None) |                 wrapped = PropertyMappingExpressionException(exc, None) | ||||||
|                 LOGGER.warning( |                 LOGGER.warning( | ||||||
|                     "failed to evaluate prompt placeholder", |                     "failed to evaluate prompt placeholder", | ||||||
|                     exc=wrapped, |                     exc=wrapped, | ||||||
| @ -237,7 +237,7 @@ class Prompt(SerializerModel): | |||||||
|             try: |             try: | ||||||
|                 value = evaluator.evaluate(self.initial_value) |                 value = evaluator.evaluate(self.initial_value) | ||||||
|             except Exception as exc:  # pylint:disable=broad-except |             except Exception as exc:  # pylint:disable=broad-except | ||||||
|                 wrapped = PropertyMappingExpressionException(str(exc)) |                 wrapped = PropertyMappingExpressionException(exc, None) | ||||||
|                 LOGGER.warning( |                 LOGGER.warning( | ||||||
|                     "failed to evaluate prompt initial value", |                     "failed to evaluate prompt initial value", | ||||||
|                     exc=wrapped, |                     exc=wrapped, | ||||||
|  | |||||||
| @ -1,10 +1,9 @@ | |||||||
| """Sessions bound to ASN/Network and GeoIP/Continent/etc""" | """Sessions bound to ASN/Network and GeoIP/Continent/etc""" | ||||||
|  |  | ||||||
| from django.conf import settings |  | ||||||
| from django.contrib.auth.middleware import AuthenticationMiddleware | from django.contrib.auth.middleware import AuthenticationMiddleware | ||||||
| from django.contrib.auth.signals import user_logged_out | from django.contrib.auth.signals import user_logged_out | ||||||
|  | from django.contrib.auth.views import redirect_to_login | ||||||
| from django.http.request import HttpRequest | from django.http.request import HttpRequest | ||||||
| from django.shortcuts import redirect |  | ||||||
| from structlog.stdlib import get_logger | from structlog.stdlib import get_logger | ||||||
|  |  | ||||||
| from authentik.core.models import AuthenticatedSession | from authentik.core.models import AuthenticatedSession | ||||||
| @ -87,7 +86,7 @@ class BoundSessionMiddleware(SessionMiddleware): | |||||||
|             AuthenticationMiddleware(lambda request: request).process_request(request) |             AuthenticationMiddleware(lambda request: request).process_request(request) | ||||||
|             logout_extra(request, exc) |             logout_extra(request, exc) | ||||||
|             request.session.clear() |             request.session.clear() | ||||||
|             return redirect(settings.LOGIN_URL) |             return redirect_to_login(request.get_full_path()) | ||||||
|         return None |         return None | ||||||
|  |  | ||||||
|     def recheck_session(self, request: HttpRequest): |     def recheck_session(self, request: HttpRequest): | ||||||
|  | |||||||
| @ -6,6 +6,7 @@ from django.contrib.auth import update_session_auth_hash | |||||||
| from django.db import transaction | from django.db import transaction | ||||||
| from django.db.utils import IntegrityError, InternalError | from django.db.utils import IntegrityError, InternalError | ||||||
| from django.http import HttpRequest, HttpResponse | from django.http import HttpRequest, HttpResponse | ||||||
|  | from django.utils.functional import SimpleLazyObject | ||||||
| from django.utils.translation import gettext as _ | from django.utils.translation import gettext as _ | ||||||
| from rest_framework.exceptions import ValidationError | from rest_framework.exceptions import ValidationError | ||||||
|  |  | ||||||
| @ -118,6 +119,14 @@ class UserWriteStageView(StageView): | |||||||
|                 UserWriteStageView.write_attribute(user, key, value) |                 UserWriteStageView.write_attribute(user, key, value) | ||||||
|             # User has this key already |             # User has this key already | ||||||
|             elif hasattr(user, key): |             elif hasattr(user, key): | ||||||
|  |                 if isinstance(user, SimpleLazyObject): | ||||||
|  |                     user._setup() | ||||||
|  |                     user = user._wrapped | ||||||
|  |                 attr = getattr(type(user), key) | ||||||
|  |                 if isinstance(attr, property): | ||||||
|  |                     if not attr.fset: | ||||||
|  |                         self.logger.info("discarding key", key=key) | ||||||
|  |                         continue | ||||||
|                 setattr(user, key, value) |                 setattr(user, key, value) | ||||||
|             # If none of the cases above matched, we have an attribute that the user doesn't have, |             # If none of the cases above matched, we have an attribute that the user doesn't have, | ||||||
|             # has no setter for, is not a nested attributes value and as such is invalid |             # has no setter for, is not a nested attributes value and as such is invalid | ||||||
|  | |||||||
| @ -82,3 +82,5 @@ entries: | |||||||
|     order: 10 |     order: 10 | ||||||
|     target: !KeyOf default-authentication-flow-password-binding |     target: !KeyOf default-authentication-flow-password-binding | ||||||
|     policy: !KeyOf default-authentication-flow-password-optional |     policy: !KeyOf default-authentication-flow-password-optional | ||||||
|  |   attrs: | ||||||
|  |     failure_result: true | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|     "$schema": "http://json-schema.org/draft-07/schema", |     "$schema": "http://json-schema.org/draft-07/schema", | ||||||
|     "$id": "https://goauthentik.io/blueprints/schema.json", |     "$id": "https://goauthentik.io/blueprints/schema.json", | ||||||
|     "type": "object", |     "type": "object", | ||||||
|     "title": "authentik 2024.6.0 Blueprint schema", |     "title": "authentik 2024.6.5 Blueprint schema", | ||||||
|     "required": [ |     "required": [ | ||||||
|         "version", |         "version", | ||||||
|         "entries" |         "entries" | ||||||
|  | |||||||
| @ -31,7 +31,7 @@ services: | |||||||
|     volumes: |     volumes: | ||||||
|       - redis:/data |       - redis:/data | ||||||
|   server: |   server: | ||||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.0} |     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.5} | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     command: server |     command: server | ||||||
|     environment: |     environment: | ||||||
| @ -52,7 +52,7 @@ services: | |||||||
|       - postgresql |       - postgresql | ||||||
|       - redis |       - redis | ||||||
|   worker: |   worker: | ||||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.0} |     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.5} | ||||||
|     restart: unless-stopped |     restart: unless-stopped | ||||||
|     command: worker |     command: worker | ||||||
|     environment: |     environment: | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.mod
									
									
									
									
									
								
							| @ -28,7 +28,7 @@ require ( | |||||||
| 	github.com/spf13/cobra v1.8.0 | 	github.com/spf13/cobra v1.8.0 | ||||||
| 	github.com/stretchr/testify v1.9.0 | 	github.com/stretchr/testify v1.9.0 | ||||||
| 	github.com/wwt/guac v1.3.2 | 	github.com/wwt/guac v1.3.2 | ||||||
| 	goauthentik.io/api/v3 v3.2024042.11 | 	goauthentik.io/api/v3 v3.2024060.5 | ||||||
| 	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab | 	golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab | ||||||
| 	golang.org/x/oauth2 v0.21.0 | 	golang.org/x/oauth2 v0.21.0 | ||||||
| 	golang.org/x/sync v0.7.0 | 	golang.org/x/sync v0.7.0 | ||||||
|  | |||||||
							
								
								
									
										4
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								go.sum
									
									
									
									
									
								
							| @ -294,8 +294,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.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= | ||||||
| go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= | go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= | ||||||
| go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= | go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= | ||||||
| goauthentik.io/api/v3 v3.2024042.11 h1:cGgUz1E8rlMphGvv04VI7i+MgT8eidZbxTpza5zd96I= | goauthentik.io/api/v3 v3.2024060.5 h1:AjvPUZoObk7a86ZZaz2tmruteY+1vAEfVzIOzQpWSXM= | ||||||
| goauthentik.io/api/v3 v3.2024042.11/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | goauthentik.io/api/v3 v3.2024060.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-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-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||||
| golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= | ||||||
|  | |||||||
| @ -29,4 +29,4 @@ func UserAgent() string { | |||||||
| 	return fmt.Sprintf("authentik@%s", FullVersion()) | 	return fmt.Sprintf("authentik@%s", FullVersion()) | ||||||
| } | } | ||||||
|  |  | ||||||
| const VERSION = "2024.6.0" | const VERSION = "2024.6.5" | ||||||
|  | |||||||
| @ -183,7 +183,19 @@ func (ac *APIController) startWSHealth() { | |||||||
|  |  | ||||||
| func (ac *APIController) startIntervalUpdater() { | func (ac *APIController) startIntervalUpdater() { | ||||||
| 	logger := ac.logger.WithField("loop", "interval-updater") | 	logger := ac.logger.WithField("loop", "interval-updater") | ||||||
| 	ticker := time.NewTicker(5 * time.Minute) | 	getInterval := func() time.Duration { | ||||||
|  | 		// Ensure timer interval is not negative or 0 | ||||||
|  | 		// for 0 we assume migration or unconfigured, so default to 5 minutes | ||||||
|  | 		if ac.Outpost.RefreshIntervalS <= 0 { | ||||||
|  | 			return 5 * time.Minute | ||||||
|  | 		} | ||||||
|  | 		// Clamp interval to be at least 30 seconds | ||||||
|  | 		if ac.Outpost.RefreshIntervalS < 30 { | ||||||
|  | 			return 30 * time.Second | ||||||
|  | 		} | ||||||
|  | 		return time.Duration(ac.Outpost.RefreshIntervalS) * time.Second | ||||||
|  | 	} | ||||||
|  | 	ticker := time.NewTicker(getInterval()) | ||||||
| 	for ; true; <-ticker.C { | 	for ; true; <-ticker.C { | ||||||
| 		logger.Debug("Running interval update") | 		logger.Debug("Running interval update") | ||||||
| 		err := ac.OnRefresh() | 		err := ac.OnRefresh() | ||||||
| @ -198,6 +210,7 @@ func (ac *APIController) startIntervalUpdater() { | |||||||
| 				"build":        constants.BUILD("tagged"), | 				"build":        constants.BUILD("tagged"), | ||||||
| 			}).SetToCurrentTime() | 			}).SetToCurrentTime() | ||||||
| 		} | 		} | ||||||
|  | 		ticker.Reset(getInterval()) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | |||||||
| @ -48,9 +48,9 @@ | |||||||
|                 <footer class="pf-c-login__footer"> |                 <footer class="pf-c-login__footer"> | ||||||
|                     <ul class="pf-c-list pf-m-inline"> |                     <ul class="pf-c-list pf-m-inline"> | ||||||
|                         <li> |                         <li> | ||||||
|                             <a href="https://goauthentik.io?utm_source=authentik_outpost&utm_campaign=proxy_error"> |                             <span> | ||||||
|                                 Powered by authentik |                                 Powered by authentik | ||||||
|                             </a> |                             </span> | ||||||
|                         </li> |                         </li> | ||||||
|                     </ul> |                     </ul> | ||||||
|                 </footer> |                 </footer> | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| # flake8: noqa | # flake8: noqa | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
|  |  | ||||||
|  | from authentik.lib.config import CONFIG | ||||||
| from lifecycle.migrate import BaseMigration | from lifecycle.migrate import BaseMigration | ||||||
|  |  | ||||||
| MEDIA_ROOT = Path(__file__).parent.parent.parent / "media" | MEDIA_ROOT = Path(__file__).parent.parent.parent / "media" | ||||||
| @ -9,7 +10,9 @@ TENANT_MEDIA_ROOT = MEDIA_ROOT / "public" | |||||||
|  |  | ||||||
| class Migration(BaseMigration): | class Migration(BaseMigration): | ||||||
|     def needs_migration(self) -> bool: |     def needs_migration(self) -> bool: | ||||||
|         return not TENANT_MEDIA_ROOT.exists() |         return ( | ||||||
|  |             not TENANT_MEDIA_ROOT.exists() and CONFIG.get("storage.media.backend", "file") != "s3" | ||||||
|  |         ) | ||||||
|  |  | ||||||
|     def run(self): |     def run(self): | ||||||
|         TENANT_MEDIA_ROOT.mkdir(parents=True) |         TENANT_MEDIA_ROOT.mkdir(parents=True) | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
|     "name": "@goauthentik/authentik", |     "name": "@goauthentik/authentik", | ||||||
|     "version": "2024.6.0", |     "version": "2024.6.5", | ||||||
|     "private": true |     "private": true | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										29
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										29
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							| @ -1,4 +1,4 @@ | |||||||
| # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. | # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "aiohttp" | name = "aiohttp" | ||||||
| @ -1513,20 +1513,6 @@ files = [ | |||||||
| dnspython = ">=2.0.0" | dnspython = ">=2.0.0" | ||||||
| idna = ">=2.0.0" | idna = ">=2.0.0" | ||||||
|  |  | ||||||
| [[package]] |  | ||||||
| name = "facebook-sdk" |  | ||||||
| version = "3.1.0" |  | ||||||
| description = "This client library is designed to support the Facebook Graph API and the official Facebook JavaScript SDK, which is the canonical way to implement Facebook authentication." |  | ||||||
| optional = false |  | ||||||
| python-versions = "*" |  | ||||||
| files = [ |  | ||||||
|     {file = "facebook-sdk-3.1.0.tar.gz", hash = "sha256:cabcd2e69ea3d9f042919c99b353df7aa1e2be86d040121f6e9f5e63c1cf0f8d"}, |  | ||||||
|     {file = "facebook_sdk-3.1.0-py2.py3-none-any.whl", hash = "sha256:2e987b3e0f466a6f4ee77b935eb023dba1384134f004a2af21f1cfff7fe0806e"}, |  | ||||||
| ] |  | ||||||
|  |  | ||||||
| [package.dependencies] |  | ||||||
| requests = "*" |  | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "fido2" | name = "fido2" | ||||||
| version = "1.1.3" | version = "1.1.3" | ||||||
| @ -2954,9 +2940,14 @@ version = "0.0.14" | |||||||
| description = "Python module for oci specifications" | description = "Python module for oci specifications" | ||||||
| optional = false | optional = false | ||||||
| python-versions = "*" | python-versions = "*" | ||||||
| files = [ | files = [] | ||||||
|     {file = "opencontainers-0.0.14.tar.gz", hash = "sha256:fde3b8099b56b5c956415df8933e2227e1914e805a277b844f2f9e52341738f2"}, | develop = false | ||||||
| ] |  | ||||||
|  | [package.source] | ||||||
|  | type = "git" | ||||||
|  | url = "https://github.com/vsoch/oci-python" | ||||||
|  | reference = "20d69d9cc50a0fef31605b46f06da0c94f1ec3cf" | ||||||
|  | resolved_reference = "20d69d9cc50a0fef31605b46f06da0c94f1ec3cf" | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "opentelemetry-api" | name = "opentelemetry-api" | ||||||
| @ -5350,4 +5341,4 @@ files = [ | |||||||
| [metadata] | [metadata] | ||||||
| lock-version = "2.0" | lock-version = "2.0" | ||||||
| python-versions = "~3.12" | python-versions = "~3.12" | ||||||
| content-hash = "f960013b56683ab42d82f8b49b2822dffc76046e3d22695ebb737b405a98dbaf" | content-hash = "055376879ff784080ab95c02eaa012fb1dad1213b1faa0dd1d61b0b812859b6d" | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| [tool.poetry] | [tool.poetry] | ||||||
| name = "authentik" | name = "authentik" | ||||||
| version = "2024.6.0" | version = "2024.6.5" | ||||||
| description = "" | description = "" | ||||||
| authors = ["authentik Team <hello@goauthentik.io>"] | authors = ["authentik Team <hello@goauthentik.io>"] | ||||||
|  |  | ||||||
| @ -110,7 +110,6 @@ docker = "*" | |||||||
| drf-spectacular = "*" | drf-spectacular = "*" | ||||||
| dumb-init = "*" | dumb-init = "*" | ||||||
| duo-client = "*" | duo-client = "*" | ||||||
| facebook-sdk = "*" |  | ||||||
| fido2 = "*" | fido2 = "*" | ||||||
| flower = "*" | flower = "*" | ||||||
| geoip2 = "*" | geoip2 = "*" | ||||||
| @ -121,7 +120,7 @@ kubernetes = "*" | |||||||
| ldap3 = "*" | ldap3 = "*" | ||||||
| lxml = "*" | lxml = "*" | ||||||
| msgraph-sdk = "*" | msgraph-sdk = "*" | ||||||
| opencontainers = { extras = ["reggie"], version = "*" } | opencontainers = { git = "https://github.com/vsoch/oci-python", rev = "20d69d9cc50a0fef31605b46f06da0c94f1ec3cf", extras = ["reggie"] } | ||||||
| packaging = "*" | packaging = "*" | ||||||
| paramiko = "*" | paramiko = "*" | ||||||
| psycopg = { extras = ["c"], version = "*" } | psycopg = { extras = ["c"], version = "*" } | ||||||
|  | |||||||
							
								
								
									
										16
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								schema.yml
									
									
									
									
									
								
							| @ -1,7 +1,7 @@ | |||||||
| openapi: 3.0.3 | openapi: 3.0.3 | ||||||
| info: | info: | ||||||
|   title: authentik |   title: authentik | ||||||
|   version: 2024.6.0 |   version: 2024.6.5 | ||||||
|   description: Making authentication simple. |   description: Making authentication simple. | ||||||
|   contact: |   contact: | ||||||
|     email: hello@goauthentik.io |     email: hello@goauthentik.io | ||||||
| @ -13080,6 +13080,15 @@ paths: | |||||||
|         name: identifier |         name: identifier | ||||||
|         schema: |         schema: | ||||||
|           type: string |           type: string | ||||||
|  |       - in: query | ||||||
|  |         name: identifier_in | ||||||
|  |         schema: | ||||||
|  |           type: array | ||||||
|  |           items: | ||||||
|  |             type: string | ||||||
|  |         description: Multiple values may be separated by commas. | ||||||
|  |         explode: false | ||||||
|  |         style: form | ||||||
|       - in: query |       - in: query | ||||||
|         name: ip |         name: ip | ||||||
|         schema: |         schema: | ||||||
| @ -36625,6 +36634,7 @@ components: | |||||||
|         href: |         href: | ||||||
|           type: string |           type: string | ||||||
|           readOnly: true |           readOnly: true | ||||||
|  |           nullable: true | ||||||
|         name: |         name: | ||||||
|           type: string |           type: string | ||||||
|           readOnly: true |           readOnly: true | ||||||
| @ -39488,6 +39498,9 @@ components: | |||||||
|           allOf: |           allOf: | ||||||
|           - $ref: '#/components/schemas/ServiceConnection' |           - $ref: '#/components/schemas/ServiceConnection' | ||||||
|           readOnly: true |           readOnly: true | ||||||
|  |         refresh_interval_s: | ||||||
|  |           type: integer | ||||||
|  |           readOnly: true | ||||||
|         token_identifier: |         token_identifier: | ||||||
|           type: string |           type: string | ||||||
|           description: Get Token identifier |           description: Get Token identifier | ||||||
| @ -39509,6 +39522,7 @@ components: | |||||||
|       - pk |       - pk | ||||||
|       - providers |       - providers | ||||||
|       - providers_obj |       - providers_obj | ||||||
|  |       - refresh_interval_s | ||||||
|       - service_connection_obj |       - service_connection_obj | ||||||
|       - token_identifier |       - token_identifier | ||||||
|       - type |       - type | ||||||
|  | |||||||
							
								
								
									
										23
									
								
								tests/e2e/test-saml-idp/saml20-sp-remote.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								tests/e2e/test-saml-idp/saml20-sp-remote.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | |||||||
|  | <?php | ||||||
|  | /** | ||||||
|  |  * SAML 2.0 remote SP metadata for SimpleSAMLphp. | ||||||
|  |  * | ||||||
|  |  * See: https://simplesamlphp.org/docs/stable/simplesamlphp-reference-sp-remote | ||||||
|  |  */ | ||||||
|  |  | ||||||
|  | $metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array( | ||||||
|  |     'AssertionConsumerService' => getenv('SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE'), | ||||||
|  |     'SingleLogoutService' => getenv('SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE'), | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | if (null != getenv('SIMPLESAMLPHP_SP_NAME_ID_FORMAT')) { | ||||||
|  |     $metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array_merge($metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')], array('NameIDFormat' => getenv('SIMPLESAMLPHP_SP_NAME_ID_FORMAT'))); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | if (null != getenv('SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE')) { | ||||||
|  |     $metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array_merge($metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')], array('simplesaml.nameidattribute' => getenv('SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE'))); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | if (null != getenv('SIMPLESAMLPHP_SP_SIGN_ASSERTION')) { | ||||||
|  |     $metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')] = array_merge($metadata[getenv('SIMPLESAMLPHP_SP_ENTITY_ID')], array('saml20.sign.assertion' => ('true' == getenv('SIMPLESAMLPHP_SP_SIGN_ASSERTION')))); | ||||||
|  | } | ||||||
| @ -5,7 +5,6 @@ from time import sleep | |||||||
|  |  | ||||||
| from docker.client import DockerClient, from_env | from docker.client import DockerClient, from_env | ||||||
| from docker.models.containers import Container | from docker.models.containers import Container | ||||||
| from guardian.shortcuts import get_anonymous_user |  | ||||||
| from ldap3 import ALL, ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE, Connection, Server | from ldap3 import ALL, ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES, SUBTREE, Connection, Server | ||||||
| from ldap3.core.exceptions import LDAPInvalidCredentialsResult | from ldap3.core.exceptions import LDAPInvalidCredentialsResult | ||||||
|  |  | ||||||
| @ -180,15 +179,13 @@ class TestProviderLDAP(SeleniumTestCase): | |||||||
|         ) |         ) | ||||||
|         with self.assertRaises(LDAPInvalidCredentialsResult): |         with self.assertRaises(LDAPInvalidCredentialsResult): | ||||||
|             _connection.bind() |             _connection.bind() | ||||||
|         anon = get_anonymous_user() |  | ||||||
|         self.assertTrue( |         self.assertTrue( | ||||||
|             Event.objects.filter( |             Event.objects.filter( | ||||||
|                 action=EventAction.LOGIN_FAILED, |                 action=EventAction.LOGIN_FAILED, | ||||||
|                 user={ |                 user={ | ||||||
|                     "pk": anon.pk, |                     "pk": self.user.pk, | ||||||
|                     "email": anon.email, |                     "email": self.user.email, | ||||||
|                     "username": anon.username, |                     "username": self.user.username, | ||||||
|                     "is_anonymous": True, |  | ||||||
|                 }, |                 }, | ||||||
|             ).exists(), |             ).exists(), | ||||||
|         ) |         ) | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| """test OAuth Source""" | """test OAuth Source""" | ||||||
|  |  | ||||||
|  | from json import loads | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| from time import sleep | from time import sleep | ||||||
| from typing import Any | from typing import Any | ||||||
| @ -194,3 +195,41 @@ class TestSourceOAuth2(SeleniumTestCase): | |||||||
|         self.driver.get(self.if_user_url("/settings")) |         self.driver.get(self.if_user_url("/settings")) | ||||||
|  |  | ||||||
|         self.assert_user(User(username="foo", name="admin", email="admin@example.com")) |         self.assert_user(User(username="foo", name="admin", email="admin@example.com")) | ||||||
|  |  | ||||||
|  |     @retry() | ||||||
|  |     @apply_blueprint( | ||||||
|  |         "default/flow-default-authentication-flow.yaml", | ||||||
|  |         "default/flow-default-invalidation-flow.yaml", | ||||||
|  |     ) | ||||||
|  |     @apply_blueprint( | ||||||
|  |         "default/flow-default-source-authentication.yaml", | ||||||
|  |         "default/flow-default-source-enrollment.yaml", | ||||||
|  |         "default/flow-default-source-pre-authentication.yaml", | ||||||
|  |     ) | ||||||
|  |     def test_oauth_link(self): | ||||||
|  |         """test OAuth Source link OIDC""" | ||||||
|  |         self.create_objects() | ||||||
|  |         self.driver.get(self.live_server_url) | ||||||
|  |         self.login() | ||||||
|  |  | ||||||
|  |         self.driver.get( | ||||||
|  |             self.url("authentik_sources_oauth:oauth-client-login", source_slug=self.slug) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Now we should be at the IDP, wait for the login field | ||||||
|  |         self.wait.until(ec.presence_of_element_located((By.ID, "login"))) | ||||||
|  |         self.driver.find_element(By.ID, "login").send_keys("admin@example.com") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys("password") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) | ||||||
|  |  | ||||||
|  |         # Wait until we're logged in | ||||||
|  |         self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "button[type=submit]"))) | ||||||
|  |         self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() | ||||||
|  |  | ||||||
|  |         self.driver.get(self.url("authentik_api:usersourceconnection-list") + "?format=json") | ||||||
|  |         body_json = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) | ||||||
|  |         results = body_json["results"] | ||||||
|  |         self.assertEqual(len(results), 1) | ||||||
|  |         connection = results[0] | ||||||
|  |         self.assertEqual(connection["source"]["slug"], self.slug) | ||||||
|  |         self.assertEqual(connection["user"], self.user.pk) | ||||||
|  | |||||||
| @ -1,5 +1,6 @@ | |||||||
| """test SAML Source""" | """test SAML Source""" | ||||||
|  |  | ||||||
|  | from pathlib import Path | ||||||
| from time import sleep | from time import sleep | ||||||
| from typing import Any | from typing import Any | ||||||
|  |  | ||||||
| @ -88,8 +89,20 @@ class TestSourceSAML(SeleniumTestCase): | |||||||
|                 interval=5 * 1_000 * 1_000_000, |                 interval=5 * 1_000 * 1_000_000, | ||||||
|                 start_period=1 * 1_000 * 1_000_000, |                 start_period=1 * 1_000 * 1_000_000, | ||||||
|             ), |             ), | ||||||
|  |             "volumes": { | ||||||
|  |                 str( | ||||||
|  |                     (Path(__file__).parent / Path("test-saml-idp/saml20-sp-remote.php")).absolute() | ||||||
|  |                 ): { | ||||||
|  |                     "bind": "/var/www/simplesamlphp/metadata/saml20-sp-remote.php", | ||||||
|  |                     "mode": "ro", | ||||||
|  |                 } | ||||||
|  |             }, | ||||||
|             "environment": { |             "environment": { | ||||||
|                 "SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id", |                 "SIMPLESAMLPHP_SP_ENTITY_ID": "entity-id", | ||||||
|  |                 "SIMPLESAMLPHP_SP_NAME_ID_FORMAT": ( | ||||||
|  |                     "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" | ||||||
|  |                 ), | ||||||
|  |                 "SIMPLESAMLPHP_SP_NAME_ID_ATTRIBUTE": "email", | ||||||
|                 "SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": ( |                 "SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE": ( | ||||||
|                     self.url("authentik_sources_saml:acs", source_slug=self.slug) |                     self.url("authentik_sources_saml:acs", source_slug=self.slug) | ||||||
|                 ), |                 ), | ||||||
| @ -318,3 +331,109 @@ class TestSourceSAML(SeleniumTestCase): | |||||||
|             .exclude(pk=self.user.pk) |             .exclude(pk=self.user.pk) | ||||||
|             .first() |             .first() | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |     @retry() | ||||||
|  |     @apply_blueprint( | ||||||
|  |         "default/flow-default-authentication-flow.yaml", | ||||||
|  |         "default/flow-default-invalidation-flow.yaml", | ||||||
|  |     ) | ||||||
|  |     @apply_blueprint( | ||||||
|  |         "default/flow-default-source-authentication.yaml", | ||||||
|  |         "default/flow-default-source-enrollment.yaml", | ||||||
|  |         "default/flow-default-source-pre-authentication.yaml", | ||||||
|  |     ) | ||||||
|  |     def test_idp_post_auto_enroll_auth(self): | ||||||
|  |         """test SAML Source With post binding (auto redirect)""" | ||||||
|  |         # Bootstrap all needed objects | ||||||
|  |         authentication_flow = Flow.objects.get(slug="default-source-authentication") | ||||||
|  |         enrollment_flow = Flow.objects.get(slug="default-source-enrollment") | ||||||
|  |         pre_authentication_flow = Flow.objects.get(slug="default-source-pre-authentication") | ||||||
|  |         keypair = CertificateKeyPair.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             certificate_data=IDP_CERT, | ||||||
|  |             key_data=IDP_KEY, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         source = SAMLSource.objects.create( | ||||||
|  |             name=generate_id(), | ||||||
|  |             slug=self.slug, | ||||||
|  |             authentication_flow=authentication_flow, | ||||||
|  |             enrollment_flow=enrollment_flow, | ||||||
|  |             pre_authentication_flow=pre_authentication_flow, | ||||||
|  |             issuer="entity-id", | ||||||
|  |             sso_url=f"http://{self.host}:8080/simplesaml/saml2/idp/SSOService.php", | ||||||
|  |             binding_type=SAMLBindingTypes.POST_AUTO, | ||||||
|  |             signing_kp=keypair, | ||||||
|  |         ) | ||||||
|  |         ident_stage = IdentificationStage.objects.first() | ||||||
|  |         ident_stage.sources.set([source]) | ||||||
|  |         ident_stage.save() | ||||||
|  |  | ||||||
|  |         self.driver.get(self.live_server_url) | ||||||
|  |  | ||||||
|  |         flow_executor = self.get_shadow_root("ak-flow-executor") | ||||||
|  |         identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor) | ||||||
|  |         wait = WebDriverWait(identification_stage, self.wait_timeout) | ||||||
|  |  | ||||||
|  |         wait.until( | ||||||
|  |             ec.presence_of_element_located( | ||||||
|  |                 (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         identification_stage.find_element( | ||||||
|  |             By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" | ||||||
|  |         ).click() | ||||||
|  |  | ||||||
|  |         # Now we should be at the IDP, wait for the username field | ||||||
|  |         self.wait.until(ec.presence_of_element_located((By.ID, "username"))) | ||||||
|  |         self.driver.find_element(By.ID, "username").send_keys("user1") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys("user1pass") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) | ||||||
|  |  | ||||||
|  |         # Wait until we're logged in | ||||||
|  |         self.wait_for_url(self.if_user_url("/library")) | ||||||
|  |         self.driver.get(self.if_user_url("/settings")) | ||||||
|  |  | ||||||
|  |         self.assert_user( | ||||||
|  |             User.objects.exclude(username="akadmin") | ||||||
|  |             .exclude(username__startswith="ak-outpost") | ||||||
|  |             .exclude_anonymous() | ||||||
|  |             .exclude(pk=self.user.pk) | ||||||
|  |             .first() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         # Clear all cookies and log in again | ||||||
|  |         self.driver.delete_all_cookies() | ||||||
|  |         self.driver.get(self.live_server_url) | ||||||
|  |  | ||||||
|  |         flow_executor = self.get_shadow_root("ak-flow-executor") | ||||||
|  |         identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor) | ||||||
|  |         wait = WebDriverWait(identification_stage, self.wait_timeout) | ||||||
|  |  | ||||||
|  |         wait.until( | ||||||
|  |             ec.presence_of_element_located( | ||||||
|  |                 (By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button") | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         identification_stage.find_element( | ||||||
|  |             By.CSS_SELECTOR, ".pf-c-login__main-footer-links-item > button" | ||||||
|  |         ).click() | ||||||
|  |  | ||||||
|  |         # Now we should be at the IDP, wait for the username field | ||||||
|  |         self.wait.until(ec.presence_of_element_located((By.ID, "username"))) | ||||||
|  |         self.driver.find_element(By.ID, "username").send_keys("user1") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys("user1pass") | ||||||
|  |         self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) | ||||||
|  |  | ||||||
|  |         # Wait until we're logged in | ||||||
|  |         self.wait_for_url(self.if_user_url("/library")) | ||||||
|  |         self.driver.get(self.if_user_url("/settings")) | ||||||
|  |  | ||||||
|  |         # sleep(999999) | ||||||
|  |         self.assert_user( | ||||||
|  |             User.objects.exclude(username="akadmin") | ||||||
|  |             .exclude(username__startswith="ak-outpost") | ||||||
|  |             .exclude_anonymous() | ||||||
|  |             .exclude(pk=self.user.pk) | ||||||
|  |             .first() | ||||||
|  |         ) | ||||||
|  | |||||||
							
								
								
									
										7823
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7823
									
								
								web/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -38,7 +38,7 @@ | |||||||
|         "@codemirror/theme-one-dark": "^6.1.2", |         "@codemirror/theme-one-dark": "^6.1.2", | ||||||
|         "@formatjs/intl-listformat": "^7.5.7", |         "@formatjs/intl-listformat": "^7.5.7", | ||||||
|         "@fortawesome/fontawesome-free": "^6.5.2", |         "@fortawesome/fontawesome-free": "^6.5.2", | ||||||
|         "@goauthentik/api": "^2024.4.2-1718378698", |         "@goauthentik/api": "^2024.6.0-1719577139", | ||||||
|         "@lit/context": "^1.1.2", |         "@lit/context": "^1.1.2", | ||||||
|         "@lit/localize": "^0.12.1", |         "@lit/localize": "^0.12.1", | ||||||
|         "@lit/reactive-element": "^2.0.4", |         "@lit/reactive-element": "^2.0.4", | ||||||
|  | |||||||
							
								
								
									
										529
									
								
								web/sfe/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										529
									
								
								web/sfe/index.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,529 @@ | |||||||
|  | import { fromByteArray } from "base64-js"; | ||||||
|  | import "formdata-polyfill"; | ||||||
|  | import $ from "jquery"; | ||||||
|  | import "weakmap-polyfill"; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  |     type AuthenticatorValidationChallenge, | ||||||
|  |     type AutosubmitChallenge, | ||||||
|  |     type ChallengeTypes, | ||||||
|  |     ChallengeTypesFromJSON, | ||||||
|  |     type ContextualFlowInfo, | ||||||
|  |     type DeviceChallenge, | ||||||
|  |     type ErrorDetail, | ||||||
|  |     type IdentificationChallenge, | ||||||
|  |     type PasswordChallenge, | ||||||
|  |     type RedirectChallenge, | ||||||
|  | } from "@goauthentik/api"; | ||||||
|  |  | ||||||
|  | interface GlobalAuthentik { | ||||||
|  |     brand: { | ||||||
|  |         branding_logo: string; | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function ak(): GlobalAuthentik { | ||||||
|  |     return ( | ||||||
|  |         window as unknown as { | ||||||
|  |             authentik: GlobalAuthentik; | ||||||
|  |         } | ||||||
|  |     ).authentik; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class SimpleFlowExecutor { | ||||||
|  |     challenge?: ChallengeTypes; | ||||||
|  |     flowSlug: string; | ||||||
|  |     container: HTMLDivElement; | ||||||
|  |  | ||||||
|  |     constructor(container: HTMLDivElement) { | ||||||
|  |         this.flowSlug = window.location.pathname.split("/")[3]; | ||||||
|  |         this.container = container; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     get apiURL() { | ||||||
|  |         return `/api/v3/flows/executor/${this.flowSlug}/?query=${encodeURIComponent(window.location.search.substring(1))}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     start() { | ||||||
|  |         $.ajax({ | ||||||
|  |             type: "GET", | ||||||
|  |             url: this.apiURL, | ||||||
|  |             success: (data) => { | ||||||
|  |                 this.challenge = ChallengeTypesFromJSON(data); | ||||||
|  |                 this.renderChallenge(); | ||||||
|  |             }, | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     submit(data: { [key: string]: unknown } | FormData) { | ||||||
|  |         $("button[type=submit]").addClass("disabled") | ||||||
|  |             .html(`<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> | ||||||
|  |                 <span role="status">Loading...</span>`); | ||||||
|  |         let finalData: { [key: string]: unknown } = {}; | ||||||
|  |         if (data instanceof FormData) { | ||||||
|  |             finalData = {}; | ||||||
|  |             data.forEach((value, key) => { | ||||||
|  |                 finalData[key] = value; | ||||||
|  |             }); | ||||||
|  |         } else { | ||||||
|  |             finalData = data; | ||||||
|  |         } | ||||||
|  |         $.ajax({ | ||||||
|  |             type: "POST", | ||||||
|  |             url: this.apiURL, | ||||||
|  |             data: JSON.stringify(finalData), | ||||||
|  |             success: (data) => { | ||||||
|  |                 this.challenge = ChallengeTypesFromJSON(data); | ||||||
|  |                 this.renderChallenge(); | ||||||
|  |             }, | ||||||
|  |             contentType: "application/json", | ||||||
|  |             dataType: "json", | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderChallenge() { | ||||||
|  |         switch (this.challenge?.component) { | ||||||
|  |             case "ak-stage-identification": | ||||||
|  |                 new IdentificationStage(this, this.challenge).render(); | ||||||
|  |                 return; | ||||||
|  |             case "ak-stage-password": | ||||||
|  |                 new PasswordStage(this, this.challenge).render(); | ||||||
|  |                 return; | ||||||
|  |             case "xak-flow-redirect": | ||||||
|  |                 new RedirectStage(this, this.challenge).render(); | ||||||
|  |                 return; | ||||||
|  |             case "ak-stage-autosubmit": | ||||||
|  |                 new AutosubmitStage(this, this.challenge).render(); | ||||||
|  |                 return; | ||||||
|  |             case "ak-stage-authenticator-validate": | ||||||
|  |                 new AuthenticatorValidateStage(this, this.challenge).render(); | ||||||
|  |                 return; | ||||||
|  |             default: | ||||||
|  |                 this.container.innerText = "Unsupported stage: " + this.challenge?.component; | ||||||
|  |                 return; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface FlowInfoChallenge { | ||||||
|  |     flowInfo?: ContextualFlowInfo; | ||||||
|  |     responseErrors?: { | ||||||
|  |         [key: string]: Array<ErrorDetail>; | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Stage<T extends FlowInfoChallenge> { | ||||||
|  |     constructor( | ||||||
|  |         public executor: SimpleFlowExecutor, | ||||||
|  |         public challenge: T, | ||||||
|  |     ) {} | ||||||
|  |  | ||||||
|  |     error(fieldName: string) { | ||||||
|  |         if (!this.challenge.responseErrors) { | ||||||
|  |             return []; | ||||||
|  |         } | ||||||
|  |         return this.challenge.responseErrors[fieldName] || []; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderInputError(fieldName: string) { | ||||||
|  |         return `${this.error(fieldName) | ||||||
|  |             .map((error) => { | ||||||
|  |                 return `<div class="invalid-feedback"> | ||||||
|  |                     ${error.string} | ||||||
|  |                 </div>`; | ||||||
|  |             }) | ||||||
|  |             .join("")}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderNonFieldErrors() { | ||||||
|  |         return `${this.error("non_field_errors") | ||||||
|  |             .map((error) => { | ||||||
|  |                 return `<div class="alert alert-danger" role="alert"> | ||||||
|  |                     ${error.string} | ||||||
|  |                 </div>`; | ||||||
|  |             }) | ||||||
|  |             .join("")}`; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     html(html: string) { | ||||||
|  |         this.executor.container.innerHTML = html; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     render() { | ||||||
|  |         throw new Error("Abstract method"); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class IdentificationStage extends Stage<IdentificationChallenge> { | ||||||
|  |     render() { | ||||||
|  |         this.html(` | ||||||
|  |             <form id="ident-form"> | ||||||
|  |                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||||
|  |                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||||
|  |                 ${ | ||||||
|  |                     this.challenge.applicationPre | ||||||
|  |                         ? `<p> | ||||||
|  |                               Login to continue to ${this.challenge.applicationPre}. | ||||||
|  |                           </p>` | ||||||
|  |                         : "" | ||||||
|  |                 } | ||||||
|  |                 <div class="form-label-group my-3 has-validation"> | ||||||
|  |                     <input type="text" autofocus class="form-control" name="uid_field" placeholder="Email / Username"> | ||||||
|  |                 </div> | ||||||
|  |                 ${ | ||||||
|  |                     this.challenge.passwordFields | ||||||
|  |                         ? `<div class="form-label-group my-3 has-validation"> | ||||||
|  |                                 <input type="password" class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password"> | ||||||
|  |                                 ${this.renderInputError("password")} | ||||||
|  |                         </div>` | ||||||
|  |                         : "" | ||||||
|  |                 } | ||||||
|  |                 ${this.renderNonFieldErrors()} | ||||||
|  |                 <button class="btn btn-primary w-100 py-2" type="submit">${this.challenge.primaryAction}</button> | ||||||
|  |             </form>`); | ||||||
|  |         $("#ident-form input[name=uid_field]").trigger("focus"); | ||||||
|  |         $("#ident-form").on("submit", (ev) => { | ||||||
|  |             ev.preventDefault(); | ||||||
|  |             const data = new FormData(ev.target as HTMLFormElement); | ||||||
|  |             this.executor.submit(data); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class PasswordStage extends Stage<PasswordChallenge> { | ||||||
|  |     render() { | ||||||
|  |         this.html(` | ||||||
|  |             <form id="password-form"> | ||||||
|  |                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||||
|  |                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||||
|  |                 <div class="form-label-group my-3 has-validation"> | ||||||
|  |                     <input type="password" autofocus class="form-control ${this.error("password").length > 0 ? "is-invalid" : ""}" name="password" placeholder="Password"> | ||||||
|  |                     ${this.renderInputError("password")} | ||||||
|  |                 </div> | ||||||
|  |                 <button class="btn btn-primary w-100 py-2" type="submit">Continue</button> | ||||||
|  |             </form>`); | ||||||
|  |         $("#password-form input").trigger("focus"); | ||||||
|  |         $("#password-form").on("submit", (ev) => { | ||||||
|  |             ev.preventDefault(); | ||||||
|  |             const data = new FormData(ev.target as HTMLFormElement); | ||||||
|  |             this.executor.submit(data); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class RedirectStage extends Stage<RedirectChallenge> { | ||||||
|  |     render() { | ||||||
|  |         window.location.assign(this.challenge.to); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class AutosubmitStage extends Stage<AutosubmitChallenge> { | ||||||
|  |     render() { | ||||||
|  |         this.html(` | ||||||
|  |             <form id="autosubmit-form" action="${this.challenge.url}" method="POST"> | ||||||
|  |                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||||
|  |                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||||
|  |                 ${Object.entries(this.challenge.attrs).map(([key, value]) => { | ||||||
|  |                     return `<input | ||||||
|  |                             type="hidden" | ||||||
|  |                             name="${key}" | ||||||
|  |                             value="${value}" | ||||||
|  |                         />`; | ||||||
|  |                 })} | ||||||
|  |                 <div class="d-flex justify-content-center"> | ||||||
|  |                     <div class="spinner-border" role="status"> | ||||||
|  |                         <span class="sr-only">Loading...</span> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </form>`); | ||||||
|  |         $("#autosubmit-form").submit(); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface Assertion { | ||||||
|  |     id: string; | ||||||
|  |     rawId: string; | ||||||
|  |     type: string; | ||||||
|  |     registrationClientExtensions: string; | ||||||
|  |     response: { | ||||||
|  |         clientDataJSON: string; | ||||||
|  |         attestationObject: string; | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface AuthAssertion { | ||||||
|  |     id: string; | ||||||
|  |     rawId: string; | ||||||
|  |     type: string; | ||||||
|  |     assertionClientExtensions: string; | ||||||
|  |     response: { | ||||||
|  |         clientDataJSON: string; | ||||||
|  |         authenticatorData: string; | ||||||
|  |         signature: string; | ||||||
|  |         userHandle: string | null; | ||||||
|  |     }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class AuthenticatorValidateStage extends Stage<AuthenticatorValidationChallenge> { | ||||||
|  |     deviceChallenge?: DeviceChallenge; | ||||||
|  |  | ||||||
|  |     b64enc(buf: Uint8Array): string { | ||||||
|  |         return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     b64RawEnc(buf: Uint8Array): string { | ||||||
|  |         return fromByteArray(buf).replace(/\+/g, "-").replace(/\//g, "_"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     u8arr(input: string): Uint8Array { | ||||||
|  |         return Uint8Array.from(atob(input.replace(/_/g, "/").replace(/-/g, "+")), (c) => | ||||||
|  |             c.charCodeAt(0), | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     checkWebAuthnSupport(): boolean { | ||||||
|  |         if ("credentials" in navigator) { | ||||||
|  |             return true; | ||||||
|  |         } | ||||||
|  |         if (window.location.protocol === "http:" && window.location.hostname !== "localhost") { | ||||||
|  |             console.warn("WebAuthn requires this page to be accessed via HTTPS."); | ||||||
|  |             return false; | ||||||
|  |         } | ||||||
|  |         console.warn("WebAuthn not supported by browser."); | ||||||
|  |         return false; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Transforms items in the credentialCreateOptions generated on the server | ||||||
|  |      * into byte arrays expected by the navigator.credentials.create() call | ||||||
|  |      */ | ||||||
|  |     transformCredentialCreateOptions( | ||||||
|  |         credentialCreateOptions: PublicKeyCredentialCreationOptions, | ||||||
|  |         userId: string, | ||||||
|  |     ): PublicKeyCredentialCreationOptions { | ||||||
|  |         const user = credentialCreateOptions.user; | ||||||
|  |         // Because json can't contain raw bytes, the server base64-encodes the User ID | ||||||
|  |         // So to get the base64 encoded byte array, we first need to convert it to a regular | ||||||
|  |         // string, then a byte array, re-encode it and wrap that in an array. | ||||||
|  |         const stringId = decodeURIComponent(window.atob(userId)); | ||||||
|  |         user.id = this.u8arr(this.b64enc(this.u8arr(stringId))); | ||||||
|  |         const challenge = this.u8arr(credentialCreateOptions.challenge.toString()); | ||||||
|  |  | ||||||
|  |         const transformedCredentialCreateOptions = Object.assign({}, credentialCreateOptions, { | ||||||
|  |             challenge, | ||||||
|  |             user, | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         return transformedCredentialCreateOptions; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Transforms the binary data in the credential into base64 strings | ||||||
|  |      * for posting to the server. | ||||||
|  |      * @param {PublicKeyCredential} newAssertion | ||||||
|  |      */ | ||||||
|  |     transformNewAssertionForServer(newAssertion: PublicKeyCredential): Assertion { | ||||||
|  |         const attObj = new Uint8Array( | ||||||
|  |             (newAssertion.response as AuthenticatorAttestationResponse).attestationObject, | ||||||
|  |         ); | ||||||
|  |         const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON); | ||||||
|  |         const rawId = new Uint8Array(newAssertion.rawId); | ||||||
|  |  | ||||||
|  |         const registrationClientExtensions = newAssertion.getClientExtensionResults(); | ||||||
|  |         return { | ||||||
|  |             id: newAssertion.id, | ||||||
|  |             rawId: this.b64enc(rawId), | ||||||
|  |             type: newAssertion.type, | ||||||
|  |             registrationClientExtensions: JSON.stringify(registrationClientExtensions), | ||||||
|  |             response: { | ||||||
|  |                 clientDataJSON: this.b64enc(clientDataJSON), | ||||||
|  |                 attestationObject: this.b64enc(attObj), | ||||||
|  |             }, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     transformCredentialRequestOptions( | ||||||
|  |         credentialRequestOptions: PublicKeyCredentialRequestOptions, | ||||||
|  |     ): PublicKeyCredentialRequestOptions { | ||||||
|  |         const challenge = this.u8arr(credentialRequestOptions.challenge.toString()); | ||||||
|  |  | ||||||
|  |         const allowCredentials = (credentialRequestOptions.allowCredentials || []).map( | ||||||
|  |             (credentialDescriptor) => { | ||||||
|  |                 const id = this.u8arr(credentialDescriptor.id.toString()); | ||||||
|  |                 return Object.assign({}, credentialDescriptor, { id }); | ||||||
|  |             }, | ||||||
|  |         ); | ||||||
|  |  | ||||||
|  |         const transformedCredentialRequestOptions = Object.assign({}, credentialRequestOptions, { | ||||||
|  |             challenge, | ||||||
|  |             allowCredentials, | ||||||
|  |         }); | ||||||
|  |  | ||||||
|  |         return transformedCredentialRequestOptions; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * Encodes the binary data in the assertion into strings for posting to the server. | ||||||
|  |      * @param {PublicKeyCredential} newAssertion | ||||||
|  |      */ | ||||||
|  |     transformAssertionForServer(newAssertion: PublicKeyCredential): AuthAssertion { | ||||||
|  |         const response = newAssertion.response as AuthenticatorAssertionResponse; | ||||||
|  |         const authData = new Uint8Array(response.authenticatorData); | ||||||
|  |         const clientDataJSON = new Uint8Array(response.clientDataJSON); | ||||||
|  |         const rawId = new Uint8Array(newAssertion.rawId); | ||||||
|  |         const sig = new Uint8Array(response.signature); | ||||||
|  |         const assertionClientExtensions = newAssertion.getClientExtensionResults(); | ||||||
|  |  | ||||||
|  |         return { | ||||||
|  |             id: newAssertion.id, | ||||||
|  |             rawId: this.b64enc(rawId), | ||||||
|  |             type: newAssertion.type, | ||||||
|  |             assertionClientExtensions: JSON.stringify(assertionClientExtensions), | ||||||
|  |  | ||||||
|  |             response: { | ||||||
|  |                 clientDataJSON: this.b64RawEnc(clientDataJSON), | ||||||
|  |                 signature: this.b64RawEnc(sig), | ||||||
|  |                 authenticatorData: this.b64RawEnc(authData), | ||||||
|  |                 userHandle: null, | ||||||
|  |             }, | ||||||
|  |         }; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     render() { | ||||||
|  |         if (!this.deviceChallenge) { | ||||||
|  |             return this.renderChallengePicker(); | ||||||
|  |         } | ||||||
|  |         switch (this.deviceChallenge.deviceClass) { | ||||||
|  |             case "static": | ||||||
|  |             case "totp": | ||||||
|  |                 this.renderCodeInput(); | ||||||
|  |                 break; | ||||||
|  |             case "webauthn": | ||||||
|  |                 this.renderWebauthn(); | ||||||
|  |                 break; | ||||||
|  |             default: | ||||||
|  |                 break; | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderChallengePicker() { | ||||||
|  |         const challenges = this.challenge.deviceChallenges.filter((challenge) => { | ||||||
|  |             if (challenge.deviceClass === "webauthn") { | ||||||
|  |                 if (!this.checkWebAuthnSupport()) { | ||||||
|  |                     return undefined; | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             return challenge; | ||||||
|  |         }); | ||||||
|  |         this.html(`<form id="picker-form"> | ||||||
|  |                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||||
|  |                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||||
|  |                 ${ | ||||||
|  |                     challenges.length > 0 | ||||||
|  |                         ? "<p>Select an authentication method.</p>" | ||||||
|  |                         : ` | ||||||
|  |                     <p>No compatible authentication method available</p> | ||||||
|  |                     ` | ||||||
|  |                 } | ||||||
|  |                 ${challenges | ||||||
|  |                     .map((challenge) => { | ||||||
|  |                         let label = undefined; | ||||||
|  |                         switch (challenge.deviceClass) { | ||||||
|  |                             case "static": | ||||||
|  |                                 label = "Recovery keys"; | ||||||
|  |                                 break; | ||||||
|  |                             case "totp": | ||||||
|  |                                 label = "Traditional authenticator"; | ||||||
|  |                                 break; | ||||||
|  |                             case "webauthn": | ||||||
|  |                                 label = "Security key"; | ||||||
|  |                                 break; | ||||||
|  |                         } | ||||||
|  |                         if (!label) { | ||||||
|  |                             return ""; | ||||||
|  |                         } | ||||||
|  |                         return `<div class="form-label-group my-3 has-validation"> | ||||||
|  |                             <button id="${challenge.deviceClass}-${challenge.deviceUid}" class="btn btn-secondary w-100 py-2" type="button"> | ||||||
|  |                                 ${label} | ||||||
|  |                             </button> | ||||||
|  |                         </div>`; | ||||||
|  |                     }) | ||||||
|  |                     .join("")} | ||||||
|  |             </form>`); | ||||||
|  |         this.challenge.deviceChallenges.forEach((challenge) => { | ||||||
|  |             $(`#picker-form button#${challenge.deviceClass}-${challenge.deviceUid}`).on( | ||||||
|  |                 "click", | ||||||
|  |                 () => { | ||||||
|  |                     this.deviceChallenge = challenge; | ||||||
|  |                     this.render(); | ||||||
|  |                 }, | ||||||
|  |             ); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderCodeInput() { | ||||||
|  |         this.html(` | ||||||
|  |             <form id="totp-form"> | ||||||
|  |                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||||
|  |                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||||
|  |                 <div class="form-label-group my-3 has-validation"> | ||||||
|  |                     <input type="text" autofocus class="form-control ${this.error("code").length > 0 ? "is-invalid" : ""}" name="code" placeholder="Please enter your code" autocomplete="one-time-code"> | ||||||
|  |                     ${this.renderInputError("code")} | ||||||
|  |                 </div> | ||||||
|  |                 <button class="btn btn-primary w-100 py-2" type="submit">Continue</button> | ||||||
|  |             </form>`); | ||||||
|  |         $("#totp-form input").trigger("focus"); | ||||||
|  |         $("#totp-form").on("submit", (ev) => { | ||||||
|  |             ev.preventDefault(); | ||||||
|  |             const data = new FormData(ev.target as HTMLFormElement); | ||||||
|  |             this.executor.submit(data); | ||||||
|  |         }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     renderWebauthn() { | ||||||
|  |         this.html(` | ||||||
|  |             <form id="totp-form"> | ||||||
|  |                 <img class="mb-4 brand-icon" src="${ak().brand.branding_logo}" alt=""> | ||||||
|  |                 <h1 class="h3 mb-3 fw-normal text-center">${this.challenge?.flowInfo?.title}</h1> | ||||||
|  |                 <div class="d-flex justify-content-center"> | ||||||
|  |                     <div class="spinner-border" role="status"> | ||||||
|  |                         <span class="sr-only">Loading...</span> | ||||||
|  |                     </div> | ||||||
|  |                 </div> | ||||||
|  |             </form> | ||||||
|  |             `); | ||||||
|  |         navigator.credentials | ||||||
|  |             .get({ | ||||||
|  |                 publicKey: this.transformCredentialRequestOptions( | ||||||
|  |                     this.deviceChallenge?.challenge as PublicKeyCredentialRequestOptions, | ||||||
|  |                 ), | ||||||
|  |             }) | ||||||
|  |             .then((assertion) => { | ||||||
|  |                 if (!assertion) { | ||||||
|  |                     throw new Error("No assertion"); | ||||||
|  |                 } | ||||||
|  |                 try { | ||||||
|  |                     // we now have an authentication assertion! encode the byte arrays contained | ||||||
|  |                     // in the assertion data as strings for posting to the server | ||||||
|  |                     const transformedAssertionForServer = this.transformAssertionForServer( | ||||||
|  |                         assertion as PublicKeyCredential, | ||||||
|  |                     ); | ||||||
|  |  | ||||||
|  |                     // post the assertion to the server for verification. | ||||||
|  |                     this.executor.submit({ | ||||||
|  |                         webauthn: transformedAssertionForServer, | ||||||
|  |                     }); | ||||||
|  |                 } catch (err) { | ||||||
|  |                     throw new Error(`Error when validating assertion on server: ${err}`); | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .catch((error) => { | ||||||
|  |                 console.warn(error); | ||||||
|  |                 this.deviceChallenge = undefined; | ||||||
|  |                 this.render(); | ||||||
|  |             }); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const sfe = new SimpleFlowExecutor($("#flow-sfe-container")[0] as HTMLDivElement); | ||||||
|  | sfe.start(); | ||||||
							
								
								
									
										3057
									
								
								web/sfe/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										3057
									
								
								web/sfe/package-lock.json
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										28
									
								
								web/sfe/package.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								web/sfe/package.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | |||||||
|  | { | ||||||
|  |     "name": "@goauthentik/web-sfe", | ||||||
|  |     "version": "0.0.0", | ||||||
|  |     "private": true, | ||||||
|  |     "license": "MIT", | ||||||
|  |     "dependencies": { | ||||||
|  |         "@goauthentik/api": "^2024.6.0-1719577139", | ||||||
|  |         "base64-js": "^1.5.1", | ||||||
|  |         "bootstrap": "^4.6.1", | ||||||
|  |         "formdata-polyfill": "^4.0.10", | ||||||
|  |         "jquery": "^3.7.1", | ||||||
|  |         "weakmap-polyfill": "^2.0.4" | ||||||
|  |     }, | ||||||
|  |     "scripts": { | ||||||
|  |         "build": "rollup -c rollup.config.js --bundleConfigAsCjs", | ||||||
|  |         "watch": "rollup -w -c rollup.config.js --bundleConfigAsCjs" | ||||||
|  |     }, | ||||||
|  |     "devDependencies": { | ||||||
|  |         "@rollup/plugin-commonjs": "^26.0.1", | ||||||
|  |         "@rollup/plugin-node-resolve": "^15.2.3", | ||||||
|  |         "@rollup/plugin-swc": "^0.3.1", | ||||||
|  |         "@swc/cli": "^0.3.14", | ||||||
|  |         "@swc/core": "^1.6.6", | ||||||
|  |         "@types/jquery": "^3.5.30", | ||||||
|  |         "rollup": "^4.18.0", | ||||||
|  |         "rollup-plugin-copy": "^3.5.0" | ||||||
|  |     } | ||||||
|  | } | ||||||
							
								
								
									
										40
									
								
								web/sfe/rollup.config.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								web/sfe/rollup.config.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,40 @@ | |||||||
|  | import commonjs from "@rollup/plugin-commonjs"; | ||||||
|  | import resolve from "@rollup/plugin-node-resolve"; | ||||||
|  | import swc from "@rollup/plugin-swc"; | ||||||
|  | import copy from "rollup-plugin-copy"; | ||||||
|  |  | ||||||
|  | export default { | ||||||
|  |     input: "index.ts", | ||||||
|  |     output: { | ||||||
|  |         dir: "../dist/sfe", | ||||||
|  |         format: "cjs", | ||||||
|  |     }, | ||||||
|  |     context: "window", | ||||||
|  |     plugins: [ | ||||||
|  |         copy({ | ||||||
|  |             targets: [ | ||||||
|  |                 { src: "node_modules/bootstrap/dist/css/bootstrap.min.css", dest: "../dist/sfe" }, | ||||||
|  |             ], | ||||||
|  |         }), | ||||||
|  |         resolve({ browser: true }), | ||||||
|  |         commonjs(), | ||||||
|  |         swc({ | ||||||
|  |             swc: { | ||||||
|  |                 jsc: { | ||||||
|  |                     loose: false, | ||||||
|  |                     externalHelpers: false, | ||||||
|  |                     // Requires v1.2.50 or upper and requires target to be es2016 or upper. | ||||||
|  |                     keepClassNames: false, | ||||||
|  |                 }, | ||||||
|  |                 minify: false, | ||||||
|  |                 env: { | ||||||
|  |                     targets: { | ||||||
|  |                         edge: "17", | ||||||
|  |                         ie: "11", | ||||||
|  |                     }, | ||||||
|  |                     mode: "entry", | ||||||
|  |                 }, | ||||||
|  |             }, | ||||||
|  |         }), | ||||||
|  |     ], | ||||||
|  | }; | ||||||
							
								
								
									
										7
									
								
								web/sfe/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								web/sfe/tsconfig.json
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | |||||||
|  | { | ||||||
|  |     "compilerOptions": { | ||||||
|  |         "types": ["jquery"], | ||||||
|  |         "esModuleInterop": true, | ||||||
|  |         "lib": ["DOM", "ES2015", "ES2017"] | ||||||
|  |     }, | ||||||
|  | } | ||||||
| @ -208,7 +208,14 @@ export class AdminOverviewPage extends AdminOverviewBase { | |||||||
|  |  | ||||||
|             return html`<li> |             return html`<li> | ||||||
|                 ${ex( |                 ${ex( | ||||||
|                     () => html`<a href="${url}" class="pf-u-mb-xl" target="_blank">${content}</a>`, |                     () => | ||||||
|  |                         html`<a | ||||||
|  |                             href="${url}" | ||||||
|  |                             class="pf-u-mb-xl" | ||||||
|  |                             rel="noopener noreferrer" | ||||||
|  |                             target="_blank" | ||||||
|  |                             >${content}</a | ||||||
|  |                         >`, | ||||||
|                     () => html`<a href="${url}" class="pf-u-mb-xl" )>${content}</a>`, |                     () => html`<a href="${url}" class="pf-u-mb-xl" )>${content}</a>`, | ||||||
|                 )} |                 )} | ||||||
|             </li>`; |             </li>`; | ||||||
|  | |||||||
| @ -56,6 +56,6 @@ export class VersionStatusCard extends AdminStatusCard<Version> { | |||||||
|             text = this.value.buildHash?.substring(0, 7); |             text = this.value.buildHash?.substring(0, 7); | ||||||
|             link = `https://github.com/goauthentik/authentik/commit/${this.value.buildHash}`; |             link = `https://github.com/goauthentik/authentik/commit/${this.value.buildHash}`; | ||||||
|         } |         } | ||||||
|         return html`<a href=${link} target="_blank">${text}</a>`; |         return html`<a rel="noopener noreferrer" href=${link} target="_blank">${text}</a>`; | ||||||
|     } |     } | ||||||
| } | } | ||||||
|  | |||||||
| @ -56,6 +56,7 @@ export class OutpostStatusChart extends AKChart<SummarizedSyncStatus[]> { | |||||||
|             }), |             }), | ||||||
|         ); |         ); | ||||||
|         this.centerText = outposts.pagination.count.toString(); |         this.centerText = outposts.pagination.count.toString(); | ||||||
|  |         outpostStats.sort((a, b) => a.label.localeCompare(b.label)); | ||||||
|         return outpostStats; |         return outpostStats; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | |||||||
| @ -15,7 +15,6 @@ const doGroupBy = (items: Provider[]) => groupBy(items, (item) => item.verboseNa | |||||||
| async function fetch(query?: string) { | async function fetch(query?: string) { | ||||||
|     const args: ProvidersAllListRequest = { |     const args: ProvidersAllListRequest = { | ||||||
|         ordering: "name", |         ordering: "name", | ||||||
|         backchannel: false, |  | ||||||
|     }; |     }; | ||||||
|     if (query !== undefined) { |     if (query !== undefined) { | ||||||
|         args.search = query; |         args.search = query; | ||||||
|  | |||||||
| @ -157,6 +157,7 @@ export class BlueprintForm extends ModelForm<BlueprintInstance, string> { | |||||||
|                                   ${msg("See more about OCI support here:")}  |                                   ${msg("See more about OCI support here:")}  | ||||||
|                                   <a |                                   <a | ||||||
|                                       target="_blank" |                                       target="_blank" | ||||||
|  |                                       rel="noopener noreferrer" | ||||||
|                                       href="${docLink( |                                       href="${docLink( | ||||||
|                                           "/developer-docs/blueprints/?utm_source=authentik#storage---oci", |                                           "/developer-docs/blueprints/?utm_source=authentik#storage---oci", | ||||||
|                                       )}" |                                       )}" | ||||||
|  | |||||||
| @ -23,6 +23,7 @@ export class OutpostDeploymentModal extends ModalButton { | |||||||
|                     <a |                     <a | ||||||
|                         target="_blank" |                         target="_blank" | ||||||
|                         href="${docLink("/docs/outposts?utm_source=authentik#deploy")}" |                         href="${docLink("/docs/outposts?utm_source=authentik#deploy")}" | ||||||
|  |                         rel="noopener noreferrer" | ||||||
|                         >${msg("View deployment documentation")}</a |                         >${msg("View deployment documentation")}</a | ||||||
|                     > |                     > | ||||||
|                 </p> |                 </p> | ||||||
|  | |||||||
| @ -210,9 +210,11 @@ export class OutpostForm extends ModelForm<Outpost, string> { | |||||||
|                     )} |                     )} | ||||||
|                 </p> |                 </p> | ||||||
|                 <p class="pf-c-form__helper-text"> |                 <p class="pf-c-form__helper-text"> | ||||||
|                     See |                     <a | ||||||
|                     <a target="_blank" href="${docLink("/docs/outposts?utm_source=authentik")}" |                         target="_blank" | ||||||
|                         >documentation</a |                         rel="noopener noreferrer" | ||||||
|  |                         href="${docLink("/docs/outposts?utm_source=authentik")}" | ||||||
|  |                         >${msg("See documentation")}</a | ||||||
|                     >. |                     >. | ||||||
|                 </p> |                 </p> | ||||||
|             </ak-form-element-horizontal> |             </ak-form-element-horizontal> | ||||||
| @ -245,6 +247,7 @@ export class OutpostForm extends ModelForm<Outpost, string> { | |||||||
|                             ${msg("See more here:")}  |                             ${msg("See more here:")}  | ||||||
|                             <a |                             <a | ||||||
|                                 target="_blank" |                                 target="_blank" | ||||||
|  |                                 rel="noopener noreferrer" | ||||||
|                                 href="${docLink( |                                 href="${docLink( | ||||||
|                                     "/docs/outposts?utm_source=authentik#configuration", |                                     "/docs/outposts?utm_source=authentik#configuration", | ||||||
|                                 )}" |                                 )}" | ||||||
|  | |||||||
| @ -85,6 +85,7 @@ export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> { | |||||||
|                         <p class="pf-c-form__helper-text"> |                         <p class="pf-c-form__helper-text"> | ||||||
|                             ${msg("Expression using Python.")} |                             ${msg("Expression using Python.")} | ||||||
|                             <a |                             <a | ||||||
|  |                                 rel="noopener noreferrer" | ||||||
|                                 target="_blank" |                                 target="_blank" | ||||||
|                                 href="${docLink("/docs/policies/expression?utm_source=authentik")}" |                                 href="${docLink("/docs/policies/expression?utm_source=authentik")}" | ||||||
|                             > |                             > | ||||||
|  | |||||||
| @ -62,6 +62,7 @@ export class PropertyMappingGoogleWorkspaceForm extends BasePropertyMappingForm< | |||||||
|                     ${msg("Expression using Python.")} |                     ${msg("Expression using Python.")} | ||||||
|                     <a |                     <a | ||||||
|                         target="_blank" |                         target="_blank" | ||||||
|  |                         rel="noopener noreferrer" | ||||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" |                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||||
|                     > |                     > | ||||||
|                         ${msg("See documentation for a list of all variables.")} |                         ${msg("See documentation for a list of all variables.")} | ||||||
|  | |||||||
| @ -71,6 +71,7 @@ export class PropertyMappingLDAPForm extends BasePropertyMappingForm<LDAPPropert | |||||||
|                     ${msg("Expression using Python.")} |                     ${msg("Expression using Python.")} | ||||||
|                     <a |                     <a | ||||||
|                         target="_blank" |                         target="_blank" | ||||||
|  |                         rel="noopener noreferrer" | ||||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" |                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||||
|                     > |                     > | ||||||
|                         ${msg("See documentation for a list of all variables.")} |                         ${msg("See documentation for a list of all variables.")} | ||||||
|  | |||||||
| @ -62,6 +62,7 @@ export class PropertyMappingMicrosoftEntraForm extends BasePropertyMappingForm<M | |||||||
|                     ${msg("Expression using Python.")} |                     ${msg("Expression using Python.")} | ||||||
|                     <a |                     <a | ||||||
|                         target="_blank" |                         target="_blank" | ||||||
|  |                         rel="noopener noreferrer" | ||||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" |                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||||
|                     > |                     > | ||||||
|                         ${msg("See documentation for a list of all variables.")} |                         ${msg("See documentation for a list of all variables.")} | ||||||
|  | |||||||
| @ -62,6 +62,7 @@ export class PropertyMappingNotification extends ModelForm<NotificationWebhookMa | |||||||
|                     ${msg("Expression using Python.")} |                     ${msg("Expression using Python.")} | ||||||
|                     <a |                     <a | ||||||
|                         target="_blank" |                         target="_blank" | ||||||
|  |                         rel="noopener noreferrer" | ||||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" |                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||||
|                     > |                     > | ||||||
|                         ${msg("See documentation for a list of all variables.")} |                         ${msg("See documentation for a list of all variables.")} | ||||||
|  | |||||||
| @ -160,6 +160,7 @@ export class PropertyMappingLDAPForm extends ModelForm<RACPropertyMapping, strin | |||||||
|                             ${msg("Expression using Python.")} |                             ${msg("Expression using Python.")} | ||||||
|                             <a |                             <a | ||||||
|                                 target="_blank" |                                 target="_blank" | ||||||
|  |                                 rel="noopener noreferrer" | ||||||
|                                 href="${docLink( |                                 href="${docLink( | ||||||
|                                     "/docs/property-mappings/expression?utm_source=authentik", |                                     "/docs/property-mappings/expression?utm_source=authentik", | ||||||
|                                 )}" |                                 )}" | ||||||
|  | |||||||
| @ -83,6 +83,7 @@ export class PropertyMappingSAMLForm extends BasePropertyMappingForm<SAMLPropert | |||||||
|                     ${msg("Expression using Python.")} |                     ${msg("Expression using Python.")} | ||||||
|                     <a |                     <a | ||||||
|                         target="_blank" |                         target="_blank" | ||||||
|  |                         rel="noopener noreferrer" | ||||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" |                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||||
|                     > |                     > | ||||||
|                         ${msg("See documentation for a list of all variables.")} |                         ${msg("See documentation for a list of all variables.")} | ||||||
|  | |||||||
| @ -56,6 +56,7 @@ export class PropertyMappingSCIMForm extends BasePropertyMappingForm<SCIMMapping | |||||||
|                     ${msg("Expression using Python.")} |                     ${msg("Expression using Python.")} | ||||||
|                     <a |                     <a | ||||||
|                         target="_blank" |                         target="_blank" | ||||||
|  |                         rel="noopener noreferrer" | ||||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" |                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||||
|                     > |                     > | ||||||
|                         ${msg("See documentation for a list of all variables.")} |                         ${msg("See documentation for a list of all variables.")} | ||||||
|  | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	