Compare commits
	
		
			33 Commits
		
	
	
		
			version/20
			...
			version/20
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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] | ||||
| current_version = 2024.6.0 | ||||
| current_version = 2024.6.2 | ||||
| tag = True | ||||
| commit = True | ||||
| parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? | ||||
|  | ||||
							
								
								
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										38
									
								
								.github/dependabot.yml
									
									
									
									
										vendored
									
									
								
							| @ -21,7 +21,10 @@ updates: | ||||
|     labels: | ||||
|       - dependencies | ||||
|   - package-ecosystem: npm | ||||
|     directory: "/web" | ||||
|     directories: | ||||
|       - "/web" | ||||
|       - "/tests/wdio" | ||||
|       - "/web/sfe" | ||||
|     schedule: | ||||
|       interval: daily | ||||
|       time: "04:00" | ||||
| @ -30,7 +33,6 @@ updates: | ||||
|     open-pull-requests-limit: 10 | ||||
|     commit-message: | ||||
|       prefix: "web:" | ||||
|     # TODO: deduplicate these groups | ||||
|     groups: | ||||
|       sentry: | ||||
|         patterns: | ||||
| @ -56,38 +58,6 @@ updates: | ||||
|         patterns: | ||||
|           - "@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: | ||||
|         patterns: | ||||
|           - "@wdio/*" | ||||
|  | ||||
							
								
								
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.github/workflows/api-ts-publish.yml
									
									
									
									
										vendored
									
									
								
							| @ -31,7 +31,12 @@ jobs: | ||||
|         env: | ||||
|           NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} | ||||
|       - 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: | | ||||
|           export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").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: | ||||
|           - web | ||||
|           - 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: | ||||
|       - uses: actions/checkout@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 \ | ||||
|     --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 \ | ||||
|     npm ci --include=dev && \ | ||||
|     cd sfe && \ | ||||
|     npm ci --include=dev | ||||
|  | ||||
| COPY ./package.json /work | ||||
| @ -38,7 +43,9 @@ COPY ./web /work/web/ | ||||
| COPY ./website /work/website/ | ||||
| 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 | ||||
| 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 | ||||
|  | ||||
| __version__ = "2024.6.0" | ||||
| __version__ = "2024.6.2" | ||||
| ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -24,7 +24,7 @@ from authentik.tenants.utils import get_current_tenant | ||||
| class FooterLinkSerializer(PassiveSerializer): | ||||
|     """Links returned in Config API""" | ||||
|  | ||||
|     href = CharField(read_only=True) | ||||
|     href = CharField(read_only=True, allow_null=True) | ||||
|     name = CharField(read_only=True) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -7,12 +7,13 @@ from django.db.backends.base.schema import 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.scim.models import SCIMProvider | ||||
|  | ||||
|     for model in [LDAPProvider, SCIMProvider]: | ||||
|         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.save() | ||||
|         except (DatabaseError, InternalError, ProgrammingError): | ||||
|  | ||||
| @ -212,7 +212,7 @@ class SourceFlowManager: | ||||
|  | ||||
|     def _prepare_flow( | ||||
|         self, | ||||
|         flow: Flow, | ||||
|         flow: Flow | None, | ||||
|         connection: UserSourceConnection, | ||||
|         stages: list[StageView] | None = None, | ||||
|         **kwargs, | ||||
| @ -309,7 +309,9 @@ class SourceFlowManager: | ||||
|         # When request isn't authenticated we jump straight to auth | ||||
|         if not self.request.user.is_authenticated: | ||||
|             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( | ||||
|             EventAction.SOURCE_LINKED, | ||||
|             message="Linked Source", | ||||
|  | ||||
| @ -10,7 +10,7 @@ | ||||
|         versionSubdomain: "{{ version_subdomain }}", | ||||
|         build: "{{ build }}", | ||||
|     }; | ||||
|     window.addEventListener("DOMContentLoaded", () => { | ||||
|     window.addEventListener("DOMContentLoaded", function () { | ||||
|         {% for message in messages %} | ||||
|         window.dispatchEvent( | ||||
|             new CustomEvent("ak-message", { | ||||
|  | ||||
| @ -4,7 +4,7 @@ | ||||
|  | ||||
| <!DOCTYPE html> | ||||
|  | ||||
| <html lang="en"> | ||||
| <html> | ||||
|     <head> | ||||
|         <meta charset="UTF-8"> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> | ||||
|  | ||||
| @ -71,9 +71,9 @@ | ||||
|                 </li> | ||||
|                 {% endfor %} | ||||
|                 <li> | ||||
|                     <a href="https://goauthentik.io?utm_source=authentik"> | ||||
|                     <span> | ||||
|                         {% trans 'Powered by authentik' %} | ||||
|                     </a> | ||||
|                     </span> | ||||
|                 </li> | ||||
|             </ul> | ||||
|         </footer> | ||||
|  | ||||
| @ -17,11 +17,5 @@ def versioned_script(path: str) -> str: | ||||
|             f'<script src="{static_loader(path.replace("%v", get_full_version()))}' | ||||
|             '" 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 | ||||
|  | ||||
| @ -20,8 +20,9 @@ from authentik.core.api.transactional_applications import TransactionalApplicati | ||||
| from authentik.core.api.users import UserViewSet | ||||
| from authentik.core.views import apps | ||||
| 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.flows.views.interface import FlowInterfaceView | ||||
| from authentik.root.asgi_middleware import SessionMiddleware | ||||
| from authentik.root.messages.consumer import MessageConsumer | ||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | ||||
| @ -53,6 +54,8 @@ urlpatterns = [ | ||||
|     ), | ||||
|     path( | ||||
|         "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()), | ||||
|         name="if-flow", | ||||
|     ), | ||||
|  | ||||
| @ -3,7 +3,6 @@ | ||||
| from json import dumps | ||||
| from typing import Any | ||||
|  | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.views.generic.base import TemplateView | ||||
| 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.api.v3.config import ConfigView | ||||
| from authentik.brands.api import CurrentBrandSerializer | ||||
| from authentik.flows.models import Flow | ||||
|  | ||||
|  | ||||
| class InterfaceView(TemplateView): | ||||
| @ -25,14 +23,3 @@ class InterfaceView(TemplateView): | ||||
|         kwargs["build"] = get_build_hash() | ||||
|         kwargs["url_kwargs"] = self.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) | ||||
|  | ||||
| @ -5,7 +5,6 @@ from channels.sessions import CookieMiddleware | ||||
| from django.urls import path | ||||
| 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.endpoints import EndpointViewSet | ||||
| 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_outpost import RACOutpostConsumer | ||||
| 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.middleware import ChannelsLoggingMiddleware | ||||
|  | ||||
|  | ||||
| @ -35,6 +35,7 @@ IGNORED_MODELS = tuple( | ||||
|  | ||||
| _CTX_OVERWRITE_USER = ContextVar[User | None]("authentik_events_log_overwrite_user", default=None) | ||||
| _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: | ||||
| @ -149,11 +150,13 @@ class AuditMiddleware: | ||||
|         m2m_changed.disconnect(dispatch_uid=request.request_id) | ||||
|  | ||||
|     def __call__(self, request: HttpRequest) -> HttpResponse: | ||||
|         _CTX_REQUEST.set(request) | ||||
|         self.connect(request) | ||||
|  | ||||
|         response = self.get_response(request) | ||||
|  | ||||
|         self.disconnect(request) | ||||
|         _CTX_REQUEST.set(None) | ||||
|         return response | ||||
|  | ||||
|     def process_exception(self, request: HttpRequest, exception: Exception): | ||||
| @ -167,7 +170,7 @@ class AuditMiddleware: | ||||
|             thread = EventNewThread( | ||||
|                 EventAction.SUSPICIOUS_REQUEST, | ||||
|                 request, | ||||
|                 message=str(exception), | ||||
|                 message=exception_to_string(exception), | ||||
|             ) | ||||
|             thread.run() | ||||
|         elif before_send({}, {"exc_info": (None, exception, None)}) is not None: | ||||
| @ -192,6 +195,8 @@ class AuditMiddleware: | ||||
|             return | ||||
|         if _CTX_IGNORE.get(): | ||||
|             return | ||||
|         if request.request_id != _CTX_REQUEST.get().request_id: | ||||
|             return | ||||
|         user = self.get_user(request) | ||||
|  | ||||
|         action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED | ||||
| @ -205,6 +210,8 @@ class AuditMiddleware: | ||||
|             return | ||||
|         if _CTX_IGNORE.get(): | ||||
|             return | ||||
|         if request.request_id != _CTX_REQUEST.get().request_id: | ||||
|             return | ||||
|         user = self.get_user(request) | ||||
|  | ||||
|         EventNewThread( | ||||
| @ -230,6 +237,8 @@ class AuditMiddleware: | ||||
|             return | ||||
|         if _CTX_IGNORE.get(): | ||||
|             return | ||||
|         if request.request_id != _CTX_REQUEST.get().request_id: | ||||
|             return | ||||
|         user = self.get_user(request) | ||||
|  | ||||
|         EventNewThread( | ||||
|  | ||||
| @ -238,6 +238,8 @@ class Event(SerializerModel, ExpiringModel): | ||||
|                 "args": cleanse_dict(QueryDict(request.META.get("QUERY_STRING", ""))), | ||||
|                 "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 | ||||
|             # since they keep the http query within a wrapped query | ||||
|             if QS_QUERY in self.context["http_request"]["args"]: | ||||
|  | ||||
| @ -75,7 +75,10 @@ def on_login_failed( | ||||
|     **kwargs, | ||||
| ): | ||||
|     """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) | ||||
|  | ||||
| @ -21,7 +21,9 @@ def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEd | ||||
|         pass | ||||
|  | ||||
|     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): | ||||
|  | ||||
							
								
								
									
										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.db.models import Model | ||||
| from django.db.models.query import Q | ||||
| from django.db.models.signals import m2m_changed, post_save, pre_delete | ||||
|  | ||||
| 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, **_): | ||||
|         """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 | ||||
|         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, **_): | ||||
|         """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 | ||||
|         task_sync_direct.delay( | ||||
|             class_to_path(instance.__class__), instance.pk, Direction.remove.value | ||||
| @ -58,7 +63,9 @@ def register_signals( | ||||
|         """Sync group membership""" | ||||
|         if action not in ["post_add", "post_remove"]: | ||||
|             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 | ||||
|         # 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 | ||||
|  | ||||
| @ -5,6 +5,7 @@ from celery.exceptions import Retry | ||||
| from celery.result import allow_join_result | ||||
| from django.core.paginator import Paginator | ||||
| from django.db.models import Model, QuerySet | ||||
| from django.db.models.query import Q | ||||
| from django.utils.text import slugify | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from structlog.stdlib import BoundLogger, get_logger | ||||
| @ -37,7 +38,9 @@ class SyncTasks: | ||||
|         self._provider_model = provider_model | ||||
|  | ||||
|     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) | ||||
|  | ||||
|     def trigger_single_task(self, provider: OutgoingSyncProvider, sync_task: Callable[[int], None]): | ||||
| @ -62,7 +65,8 @@ class SyncTasks: | ||||
|             provider_pk=provider_pk, | ||||
|         ) | ||||
|         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() | ||||
|         if not provider: | ||||
|             return | ||||
| @ -204,7 +208,9 @@ class SyncTasks: | ||||
|         if not instance: | ||||
|             return | ||||
|         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__) | ||||
|             # Check if the object is allowed within the provider's restrictions | ||||
|             queryset = provider.get_object_qs(instance.__class__) | ||||
| @ -223,6 +229,8 @@ class SyncTasks: | ||||
|                     client.delete(instance) | ||||
|             except TransientSyncException as exc: | ||||
|                 raise Retry() from exc | ||||
|             except SkipObjectException: | ||||
|                 continue | ||||
|             except StopSync as exc: | ||||
|                 self.logger.warning(exc, provider_pk=provider.pk) | ||||
|  | ||||
| @ -233,7 +241,9 @@ class SyncTasks: | ||||
|         group = Group.objects.filter(pk=group_pk).first() | ||||
|         if not group: | ||||
|             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 | ||||
|             queryset: QuerySet = provider.get_object_qs(Group) | ||||
|             # 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) | ||||
|             except TransientSyncException as exc: | ||||
|                 raise Retry() from exc | ||||
|             except SkipObjectException: | ||||
|                 continue | ||||
|             except StopSync as exc: | ||||
|                 self.logger.warning(exc, provider_pk=provider.pk) | ||||
|  | ||||
| @ -20,6 +20,7 @@ from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSeri | ||||
| from authentik.core.models import Provider | ||||
| from authentik.enterprise.license import LicenseKey | ||||
| 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.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME | ||||
| from authentik.outposts.models import ( | ||||
| @ -49,6 +50,10 @@ class OutpostSerializer(ModelSerializer): | ||||
|     service_connection_obj = ServiceConnectionSerializer( | ||||
|         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: | ||||
|         """Validate name (especially for embedded outpost)""" | ||||
| @ -84,7 +89,8 @@ class OutpostSerializer(ModelSerializer): | ||||
|     def validate_config(self, config) -> dict: | ||||
|         """Check that the config has all required fields""" | ||||
|         try: | ||||
|             from_dict(OutpostConfig, config) | ||||
|             parsed = from_dict(OutpostConfig, config) | ||||
|             timedelta_string_validator(parsed.refresh_interval) | ||||
|         except DaciteError as exc: | ||||
|             raise ValidationError(f"Failed to validate config: {str(exc)}") from exc | ||||
|         return config | ||||
| @ -99,6 +105,7 @@ class OutpostSerializer(ModelSerializer): | ||||
|             "providers_obj", | ||||
|             "service_connection", | ||||
|             "service_connection_obj", | ||||
|             "refresh_interval_s", | ||||
|             "token_identifier", | ||||
|             "config", | ||||
|             "managed", | ||||
|  | ||||
| @ -13,16 +13,17 @@ import authentik.outposts.models | ||||
|  | ||||
|  | ||||
| def fix_missing_token_identifier(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     User = apps.get_model("authentik_core", "User") | ||||
|     Token = apps.get_model("authentik_core", "Token") | ||||
|     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 | ||||
|         users = User.objects.filter(username=user_identifier) | ||||
|         users = User.objects.using(db_alias).filter(username=user_identifier) | ||||
|         if not users.exists(): | ||||
|             continue | ||||
|         tokens = Token.objects.filter(user=users.first()) | ||||
|         tokens = Token.objects.using(db_alias).filter(user=users.first()) | ||||
|         for token in tokens: | ||||
|             if 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" | ||||
|     ) | ||||
|  | ||||
|     docker = DockerServiceConnection.objects.filter(local=True).first() | ||||
|     k8s = KubernetesServiceConnection.objects.filter(local=True).first() | ||||
|     docker = DockerServiceConnection.objects.using(db_alias).filter(local=True).first() | ||||
|     k8s = KubernetesServiceConnection.objects.using(db_alias).filter(local=True).first() | ||||
|  | ||||
|     try: | ||||
|         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): | ||||
|     alias = schema_editor.connection.alias | ||||
|     db_alias = schema_editor.connection.alias | ||||
|     User = apps.get_model("authentik_core", "User") | ||||
|     Outpost = apps.get_model("authentik_outposts", "Outpost") | ||||
|  | ||||
|     for outpost in Outpost.objects.using(alias).all(): | ||||
|         matching = User.objects.using(alias).filter(username=f"pb-outpost-{outpost.uuid.hex}") | ||||
|     for outpost in Outpost.objects.using(db_alias).all(): | ||||
|         matching = User.objects.using(db_alias).filter(username=f"pb-outpost-{outpost.uuid.hex}") | ||||
|         if matching.exists(): | ||||
|             matching.delete() | ||||
|  | ||||
|  | ||||
| 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") | ||||
|  | ||||
|     for outpost in Outpost.objects.using(alias).all(): | ||||
|     for outpost in Outpost.objects.using(db_alias).all(): | ||||
|         config = outpost._config | ||||
|         for key in list(config): | ||||
|             if "passbook" in key: | ||||
|  | ||||
| @ -61,6 +61,7 @@ class OutpostConfig: | ||||
|  | ||||
|     log_level: str = CONFIG.get("log_level") | ||||
|     object_naming_template: str = field(default="ak-outpost-%(name)s") | ||||
|     refresh_interval: str = "minutes=5" | ||||
|  | ||||
|     container_image: str | None = field(default=None) | ||||
|  | ||||
|  | ||||
| @ -2,7 +2,6 @@ | ||||
|  | ||||
| from dataclasses import asdict | ||||
|  | ||||
| from channels.exceptions import DenyConnection | ||||
| from channels.routing import URLRouter | ||||
| from channels.testing import WebsocketCommunicator | ||||
| from django.test import TransactionTestCase | ||||
| @ -37,9 +36,8 @@ class TestOutpostWS(TransactionTestCase): | ||||
|         communicator = WebsocketCommunicator( | ||||
|             URLRouter(websocket.websocket_urlpatterns), f"/ws/outpost/{self.outpost.pk}/" | ||||
|         ) | ||||
|         with self.assertRaises(DenyConnection): | ||||
|             connected, _ = await communicator.connect() | ||||
|             self.assertFalse(connected) | ||||
|         connected, _ = await communicator.connect() | ||||
|         self.assertFalse(connected) | ||||
|  | ||||
|     async def test_auth_valid(self): | ||||
|         """Test auth with token""" | ||||
|  | ||||
| @ -2,13 +2,13 @@ | ||||
|  | ||||
| from django.urls import path | ||||
|  | ||||
| from authentik.core.channels import TokenOutpostMiddleware | ||||
| from authentik.outposts.api.outposts import OutpostViewSet | ||||
| from authentik.outposts.api.service_connections import ( | ||||
|     DockerServiceConnectionViewSet, | ||||
|     KubernetesServiceConnectionViewSet, | ||||
|     ServiceConnectionViewSet, | ||||
| ) | ||||
| from authentik.outposts.channels import TokenOutpostMiddleware | ||||
| from authentik.outposts.consumer import OutpostConsumer | ||||
| from authentik.root.middleware import ChannelsLoggingMiddleware | ||||
|  | ||||
|  | ||||
| @ -1,6 +1,8 @@ | ||||
| """Reputation policy API Views""" | ||||
|  | ||||
| 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.exceptions import ValidationError | ||||
| 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 | ||||
|  | ||||
|  | ||||
| class CharInFilter(BaseInFilter, CharFilter): | ||||
|     pass | ||||
|  | ||||
|  | ||||
| class ReputationPolicySerializer(PolicySerializer): | ||||
|     """Reputation Policy Serializer""" | ||||
|  | ||||
| @ -38,6 +44,16 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet): | ||||
|     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): | ||||
|     """Reputation Serializer""" | ||||
|  | ||||
| @ -66,5 +82,5 @@ class ReputationViewSet( | ||||
|     queryset = Reputation.objects.all() | ||||
|     serializer_class = ReputationSerializer | ||||
|     search_fields = ["identifier", "ip", "score"] | ||||
|     filterset_fields = ["identifier", "ip", "score"] | ||||
|     filterset_class = ReputationFilter | ||||
|     ordering = ["ip"] | ||||
|  | ||||
| @ -268,7 +268,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet): | ||||
|         except ValueError as exc:  # pragma: no cover | ||||
|             LOGGER.warning(str(exc)) | ||||
|             raise ValidationError( | ||||
|                 _("Failed to import Metadata: {messages}".format_map({"message": str(exc)})), | ||||
|                 _("Failed to import Metadata: {messages}".format_map({"messages": str(exc)})), | ||||
|             ) from None | ||||
|         return Response(status=204) | ||||
|  | ||||
|  | ||||
| @ -89,6 +89,6 @@ class SCIMClient[TModel: "Model", TConnection: "Model", TSchema: "BaseModel"]( | ||||
|             return ServiceProviderConfiguration.model_validate( | ||||
|                 self._request("GET", "/ServiceProviderConfig") | ||||
|             ) | ||||
|         except (ValidationError, SCIMRequestException) as exc: | ||||
|         except (ValidationError, SCIMRequestException, NotFoundSyncException) as exc: | ||||
|             self.logger.warning("failed to get ServiceProviderConfig", exc=exc) | ||||
|             return default_config | ||||
|  | ||||
| @ -274,9 +274,13 @@ class ChannelsLoggingMiddleware: | ||||
|         self.log(scope) | ||||
|         try: | ||||
|             return await self.inner(scope, receive, send) | ||||
|         except DenyConnection: | ||||
|             return await send({"type": "websocket.close"}) | ||||
|         except Exception as exc: | ||||
|             if settings.DEBUG: | ||||
|                 raise 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): | ||||
|         """Log request""" | ||||
|  | ||||
| @ -31,9 +31,9 @@ def set_default_group_mappings(apps: Apps, schema_editor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|  | ||||
|     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 | ||||
|         source.property_mappings_group.set( | ||||
|         source.property_mappings_group.using(db_alias).set( | ||||
|             LDAPPropertyMapping.objects.using(db_alias).filter( | ||||
|                 managed="goauthentik.io/sources/ldap/default-name" | ||||
|             ) | ||||
|  | ||||
| @ -10,6 +10,8 @@ from authentik.sources.saml.processors import constants | ||||
|  | ||||
|  | ||||
| def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     db_alias = schema_editor.connection.alias | ||||
|  | ||||
|     SAMLSource = apps.get_model("authentik_sources_saml", "SAMLSource") | ||||
|     signature_translation_map = { | ||||
|         "rsa-sha1": constants.RSA_SHA1, | ||||
| @ -22,7 +24,7 @@ def update_algorithms(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|         "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, constants.RSA_SHA256 | ||||
|         ) | ||||
|  | ||||
| @ -10,6 +10,7 @@ from django.core.cache import cache | ||||
| from django.core.exceptions import SuspiciousOperation | ||||
| from django.http import HttpRequest | ||||
| from django.utils.timezone import now | ||||
| from lxml import etree  # nosec | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import ( | ||||
| @ -240,7 +241,7 @@ class ResponseProcessor: | ||||
|             name_id.text, | ||||
|             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 | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -13,7 +13,7 @@ def migrate_configuration_stage(apps: Apps, schema_editor: BaseDatabaseSchemaEdi | ||||
|  | ||||
|     for stage in AuthenticatorValidateStage.objects.using(db_alias).all(): | ||||
|         if stage.configuration_stage: | ||||
|             stage.configuration_stages.set([stage.configuration_stage]) | ||||
|             stage.configuration_stages.using(db_alias).set([stage.configuration_stage]) | ||||
|             stage.save() | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -325,7 +325,7 @@ class AuthenticatorValidateStageView(ChallengeStageView): | ||||
|             serializer = SelectableStageSerializer( | ||||
|                 data={ | ||||
|                     "pk": stage.pk, | ||||
|                     "name": stage.friendly_name or stage.name, | ||||
|                     "name": getattr(stage, "friendly_name", stage.name), | ||||
|                     "verbose_name": str(stage._meta.verbose_name) | ||||
|                     .replace("Setup Stage", "") | ||||
|                     .strip(), | ||||
|  | ||||
| @ -8,6 +8,7 @@ from django.urls import reverse | ||||
| from rest_framework.exceptions import ValidationError | ||||
|  | ||||
| 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.events.models import Event, EventAction | ||||
| from authentik.flows.models import FlowDesignation, FlowStageBinding | ||||
| @ -186,6 +187,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase): | ||||
|                     "method": "GET", | ||||
|                     "path": f"/api/v3/flows/executor/{flow.slug}/", | ||||
|                     "user_agent": "", | ||||
|                     "request_id": response[RESPONSE_HEADER_ID], | ||||
|                 }, | ||||
|             }, | ||||
|         ) | ||||
|  | ||||
| @ -120,7 +120,7 @@ | ||||
|             </tr> | ||||
|             <tr> | ||||
|               <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> | ||||
|             </tr> | ||||
|           </table> | ||||
|  | ||||
| @ -13,9 +13,9 @@ def assign_sources(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|     IdentificationStage = apps.get_model("authentik_stages_identification", "identificationstage") | ||||
|     Source = apps.get_model("authentik_core", "source") | ||||
|  | ||||
|     sources = Source.objects.all() | ||||
|     for stage in IdentificationStage.objects.all().using(db_alias): | ||||
|         stage.sources.set(sources) | ||||
|     sources = Source.objects.using(db_alias).all() | ||||
|     for stage in IdentificationStage.objects.using(db_alias).all(): | ||||
|         stage.sources.using(db_alias).set(sources) | ||||
|         stage.save() | ||||
|  | ||||
|  | ||||
| @ -144,7 +144,7 @@ class Migration(migrations.Migration): | ||||
|                 default=None, | ||||
|                 help_text=( | ||||
|                     "When set, shows a password field, instead of showing the password field as" | ||||
|                     " seaprate step." | ||||
|                     " separate step." | ||||
|                 ), | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|  | ||||
| @ -108,7 +108,7 @@ class PromptViewSet(UsedByMixin, ModelViewSet): | ||||
|             return Response( | ||||
|                 { | ||||
|                     "non_field_errors": [ | ||||
|                         exception_to_string(exc), | ||||
|                         exception_to_string(exc.exc), | ||||
|                     ] | ||||
|                 }, | ||||
|                 status=400, | ||||
|  | ||||
| @ -12,7 +12,7 @@ def set_generated_name(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): | ||||
|  | ||||
|     for prompt in Prompt.objects.using(db_alias).all(): | ||||
|         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: | ||||
|             name += "_" + stage.name | ||||
|         else: | ||||
|  | ||||
| @ -170,7 +170,7 @@ class Prompt(SerializerModel): | ||||
|             try: | ||||
|                 raw_choices = evaluator.evaluate(self.placeholder) | ||||
|             except Exception as exc:  # pylint:disable=broad-except | ||||
|                 wrapped = PropertyMappingExpressionException(str(exc)) | ||||
|                 wrapped = PropertyMappingExpressionException(exc, None) | ||||
|                 LOGGER.warning( | ||||
|                     "failed to evaluate prompt choices", | ||||
|                     exc=wrapped, | ||||
| @ -208,7 +208,7 @@ class Prompt(SerializerModel): | ||||
|             try: | ||||
|                 return evaluator.evaluate(self.placeholder) | ||||
|             except Exception as exc:  # pylint:disable=broad-except | ||||
|                 wrapped = PropertyMappingExpressionException(str(exc), None) | ||||
|                 wrapped = PropertyMappingExpressionException(exc, None) | ||||
|                 LOGGER.warning( | ||||
|                     "failed to evaluate prompt placeholder", | ||||
|                     exc=wrapped, | ||||
| @ -237,7 +237,7 @@ class Prompt(SerializerModel): | ||||
|             try: | ||||
|                 value = evaluator.evaluate(self.initial_value) | ||||
|             except Exception as exc:  # pylint:disable=broad-except | ||||
|                 wrapped = PropertyMappingExpressionException(str(exc)) | ||||
|                 wrapped = PropertyMappingExpressionException(exc, None) | ||||
|                 LOGGER.warning( | ||||
|                     "failed to evaluate prompt initial value", | ||||
|                     exc=wrapped, | ||||
|  | ||||
| @ -1,10 +1,9 @@ | ||||
| """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.signals import user_logged_out | ||||
| from django.contrib.auth.views import redirect_to_login | ||||
| from django.http.request import HttpRequest | ||||
| from django.shortcuts import redirect | ||||
| from structlog.stdlib import get_logger | ||||
|  | ||||
| from authentik.core.models import AuthenticatedSession | ||||
| @ -87,7 +86,7 @@ class BoundSessionMiddleware(SessionMiddleware): | ||||
|             AuthenticationMiddleware(lambda request: request).process_request(request) | ||||
|             logout_extra(request, exc) | ||||
|             request.session.clear() | ||||
|             return redirect(settings.LOGIN_URL) | ||||
|             return redirect_to_login(request.get_full_path()) | ||||
|         return None | ||||
|  | ||||
|     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.utils import IntegrityError, InternalError | ||||
| from django.http import HttpRequest, HttpResponse | ||||
| from django.utils.functional import SimpleLazyObject | ||||
| from django.utils.translation import gettext as _ | ||||
| from rest_framework.exceptions import ValidationError | ||||
|  | ||||
| @ -118,6 +119,14 @@ class UserWriteStageView(StageView): | ||||
|                 UserWriteStageView.write_attribute(user, key, value) | ||||
|             # User has this key already | ||||
|             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) | ||||
|             # 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 | ||||
|  | ||||
| @ -2,7 +2,7 @@ | ||||
|     "$schema": "http://json-schema.org/draft-07/schema", | ||||
|     "$id": "https://goauthentik.io/blueprints/schema.json", | ||||
|     "type": "object", | ||||
|     "title": "authentik 2024.6.0 Blueprint schema", | ||||
|     "title": "authentik 2024.6.2 Blueprint schema", | ||||
|     "required": [ | ||||
|         "version", | ||||
|         "entries" | ||||
|  | ||||
| @ -31,7 +31,7 @@ services: | ||||
|     volumes: | ||||
|       - redis:/data | ||||
|   server: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.0} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.2} | ||||
|     restart: unless-stopped | ||||
|     command: server | ||||
|     environment: | ||||
| @ -52,7 +52,7 @@ services: | ||||
|       - postgresql | ||||
|       - redis | ||||
|   worker: | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.0} | ||||
|     image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.2} | ||||
|     restart: unless-stopped | ||||
|     command: worker | ||||
|     environment: | ||||
|  | ||||
							
								
								
									
										2
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								go.mod
									
									
									
									
									
								
							| @ -28,7 +28,7 @@ require ( | ||||
| 	github.com/spf13/cobra v1.8.0 | ||||
| 	github.com/stretchr/testify v1.9.0 | ||||
| 	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/oauth2 v0.21.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.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= | ||||
| 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.2024042.11/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= | ||||
| goauthentik.io/api/v3 v3.2024060.5 h1:AjvPUZoObk7a86ZZaz2tmruteY+1vAEfVzIOzQpWSXM= | ||||
| 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-20190510104115-cbcb75029529/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()) | ||||
| } | ||||
|  | ||||
| const VERSION = "2024.6.0" | ||||
| const VERSION = "2024.6.2" | ||||
|  | ||||
| @ -183,7 +183,19 @@ func (ac *APIController) startWSHealth() { | ||||
|  | ||||
| func (ac *APIController) startIntervalUpdater() { | ||||
| 	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 { | ||||
| 		logger.Debug("Running interval update") | ||||
| 		err := ac.OnRefresh() | ||||
| @ -198,6 +210,7 @@ func (ac *APIController) startIntervalUpdater() { | ||||
| 				"build":        constants.BUILD("tagged"), | ||||
| 			}).SetToCurrentTime() | ||||
| 		} | ||||
| 		ticker.Reset(getInterval()) | ||||
| 	} | ||||
| } | ||||
|  | ||||
|  | ||||
| @ -48,9 +48,9 @@ | ||||
|                 <footer class="pf-c-login__footer"> | ||||
|                     <ul class="pf-c-list pf-m-inline"> | ||||
|                         <li> | ||||
|                             <a href="https://goauthentik.io?utm_source=authentik_outpost&utm_campaign=proxy_error"> | ||||
|                             <span> | ||||
|                                 Powered by authentik | ||||
|                             </a> | ||||
|                             </span> | ||||
|                         </li> | ||||
|                     </ul> | ||||
|                 </footer> | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| # flake8: noqa | ||||
| from pathlib import Path | ||||
|  | ||||
| from authentik.lib.config import CONFIG | ||||
| from lifecycle.migrate import BaseMigration | ||||
|  | ||||
| MEDIA_ROOT = Path(__file__).parent.parent.parent / "media" | ||||
| @ -9,7 +10,9 @@ TENANT_MEDIA_ROOT = MEDIA_ROOT / "public" | ||||
|  | ||||
| class Migration(BaseMigration): | ||||
|     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): | ||||
|         TENANT_MEDIA_ROOT.mkdir(parents=True) | ||||
|  | ||||
| @ -1,5 +1,5 @@ | ||||
| { | ||||
|     "name": "@goauthentik/authentik", | ||||
|     "version": "2024.6.0", | ||||
|     "version": "2024.6.2", | ||||
|     "private": true | ||||
| } | ||||
|  | ||||
| @ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "authentik" | ||||
| version = "2024.6.0" | ||||
| version = "2024.6.2" | ||||
| description = "" | ||||
| authors = ["authentik Team <hello@goauthentik.io>"] | ||||
|  | ||||
|  | ||||
							
								
								
									
										16
									
								
								schema.yml
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								schema.yml
									
									
									
									
									
								
							| @ -1,7 +1,7 @@ | ||||
| openapi: 3.0.3 | ||||
| info: | ||||
|   title: authentik | ||||
|   version: 2024.6.0 | ||||
|   version: 2024.6.2 | ||||
|   description: Making authentication simple. | ||||
|   contact: | ||||
|     email: hello@goauthentik.io | ||||
| @ -13080,6 +13080,15 @@ paths: | ||||
|         name: identifier | ||||
|         schema: | ||||
|           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 | ||||
|         name: ip | ||||
|         schema: | ||||
| @ -36625,6 +36634,7 @@ components: | ||||
|         href: | ||||
|           type: string | ||||
|           readOnly: true | ||||
|           nullable: true | ||||
|         name: | ||||
|           type: string | ||||
|           readOnly: true | ||||
| @ -39488,6 +39498,9 @@ components: | ||||
|           allOf: | ||||
|           - $ref: '#/components/schemas/ServiceConnection' | ||||
|           readOnly: true | ||||
|         refresh_interval_s: | ||||
|           type: integer | ||||
|           readOnly: true | ||||
|         token_identifier: | ||||
|           type: string | ||||
|           description: Get Token identifier | ||||
| @ -39509,6 +39522,7 @@ components: | ||||
|       - pk | ||||
|       - providers | ||||
|       - providers_obj | ||||
|       - refresh_interval_s | ||||
|       - service_connection_obj | ||||
|       - token_identifier | ||||
|       - 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.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.core.exceptions import LDAPInvalidCredentialsResult | ||||
|  | ||||
| @ -180,15 +179,13 @@ class TestProviderLDAP(SeleniumTestCase): | ||||
|         ) | ||||
|         with self.assertRaises(LDAPInvalidCredentialsResult): | ||||
|             _connection.bind() | ||||
|         anon = get_anonymous_user() | ||||
|         self.assertTrue( | ||||
|             Event.objects.filter( | ||||
|                 action=EventAction.LOGIN_FAILED, | ||||
|                 user={ | ||||
|                     "pk": anon.pk, | ||||
|                     "email": anon.email, | ||||
|                     "username": anon.username, | ||||
|                     "is_anonymous": True, | ||||
|                     "pk": self.user.pk, | ||||
|                     "email": self.user.email, | ||||
|                     "username": self.user.username, | ||||
|                 }, | ||||
|             ).exists(), | ||||
|         ) | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| """test OAuth Source""" | ||||
|  | ||||
| from json import loads | ||||
| from pathlib import Path | ||||
| from time import sleep | ||||
| from typing import Any | ||||
| @ -194,3 +195,41 @@ class TestSourceOAuth2(SeleniumTestCase): | ||||
|         self.driver.get(self.if_user_url("/settings")) | ||||
|  | ||||
|         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""" | ||||
|  | ||||
| from pathlib import Path | ||||
| from time import sleep | ||||
| from typing import Any | ||||
|  | ||||
| @ -88,8 +89,20 @@ class TestSourceSAML(SeleniumTestCase): | ||||
|                 interval=5 * 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": { | ||||
|                 "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": ( | ||||
|                     self.url("authentik_sources_saml:acs", source_slug=self.slug) | ||||
|                 ), | ||||
| @ -318,3 +331,109 @@ class TestSourceSAML(SeleniumTestCase): | ||||
|             .exclude(pk=self.user.pk) | ||||
|             .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", | ||||
|         "@formatjs/intl-listformat": "^7.5.7", | ||||
|         "@fortawesome/fontawesome-free": "^6.5.2", | ||||
|         "@goauthentik/api": "^2024.4.2-1718378698", | ||||
|         "@goauthentik/api": "^2024.6.0-1719577139", | ||||
|         "@lit/context": "^1.1.2", | ||||
|         "@lit/localize": "^0.12.1", | ||||
|         "@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> | ||||
|                 ${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>`, | ||||
|                 )} | ||||
|             </li>`; | ||||
|  | ||||
| @ -56,6 +56,6 @@ export class VersionStatusCard extends AdminStatusCard<Version> { | ||||
|             text = this.value.buildHash?.substring(0, 7); | ||||
|             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(); | ||||
|         outpostStats.sort((a, b) => a.label.localeCompare(b.label)); | ||||
|         return outpostStats; | ||||
|     } | ||||
|  | ||||
|  | ||||
| @ -15,7 +15,6 @@ const doGroupBy = (items: Provider[]) => groupBy(items, (item) => item.verboseNa | ||||
| async function fetch(query?: string) { | ||||
|     const args: ProvidersAllListRequest = { | ||||
|         ordering: "name", | ||||
|         backchannel: false, | ||||
|     }; | ||||
|     if (query !== undefined) { | ||||
|         args.search = query; | ||||
|  | ||||
| @ -157,6 +157,7 @@ export class BlueprintForm extends ModelForm<BlueprintInstance, string> { | ||||
|                                   ${msg("See more about OCI support here:")}  | ||||
|                                   <a | ||||
|                                       target="_blank" | ||||
|                                       rel="noopener noreferrer" | ||||
|                                       href="${docLink( | ||||
|                                           "/developer-docs/blueprints/?utm_source=authentik#storage---oci", | ||||
|                                       )}" | ||||
|  | ||||
| @ -23,6 +23,7 @@ export class OutpostDeploymentModal extends ModalButton { | ||||
|                     <a | ||||
|                         target="_blank" | ||||
|                         href="${docLink("/docs/outposts?utm_source=authentik#deploy")}" | ||||
|                         rel="noopener noreferrer" | ||||
|                         >${msg("View deployment documentation")}</a | ||||
|                     > | ||||
|                 </p> | ||||
|  | ||||
| @ -210,9 +210,11 @@ export class OutpostForm extends ModelForm<Outpost, string> { | ||||
|                     )} | ||||
|                 </p> | ||||
|                 <p class="pf-c-form__helper-text"> | ||||
|                     See | ||||
|                     <a target="_blank" href="${docLink("/docs/outposts?utm_source=authentik")}" | ||||
|                         >documentation</a | ||||
|                     <a | ||||
|                         target="_blank" | ||||
|                         rel="noopener noreferrer" | ||||
|                         href="${docLink("/docs/outposts?utm_source=authentik")}" | ||||
|                         >${msg("See documentation")}</a | ||||
|                     >. | ||||
|                 </p> | ||||
|             </ak-form-element-horizontal> | ||||
| @ -245,6 +247,7 @@ export class OutpostForm extends ModelForm<Outpost, string> { | ||||
|                             ${msg("See more here:")}  | ||||
|                             <a | ||||
|                                 target="_blank" | ||||
|                                 rel="noopener noreferrer" | ||||
|                                 href="${docLink( | ||||
|                                     "/docs/outposts?utm_source=authentik#configuration", | ||||
|                                 )}" | ||||
|  | ||||
| @ -85,6 +85,7 @@ export class ExpressionPolicyForm extends BasePolicyForm<ExpressionPolicy> { | ||||
|                         <p class="pf-c-form__helper-text"> | ||||
|                             ${msg("Expression using Python.")} | ||||
|                             <a | ||||
|                                 rel="noopener noreferrer" | ||||
|                                 target="_blank" | ||||
|                                 href="${docLink("/docs/policies/expression?utm_source=authentik")}" | ||||
|                             > | ||||
|  | ||||
| @ -62,6 +62,7 @@ export class PropertyMappingGoogleWorkspaceForm extends BasePropertyMappingForm< | ||||
|                     ${msg("Expression using Python.")} | ||||
|                     <a | ||||
|                         target="_blank" | ||||
|                         rel="noopener noreferrer" | ||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||
|                     > | ||||
|                         ${msg("See documentation for a list of all variables.")} | ||||
|  | ||||
| @ -71,6 +71,7 @@ export class PropertyMappingLDAPForm extends BasePropertyMappingForm<LDAPPropert | ||||
|                     ${msg("Expression using Python.")} | ||||
|                     <a | ||||
|                         target="_blank" | ||||
|                         rel="noopener noreferrer" | ||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||
|                     > | ||||
|                         ${msg("See documentation for a list of all variables.")} | ||||
|  | ||||
| @ -62,6 +62,7 @@ export class PropertyMappingMicrosoftEntraForm extends BasePropertyMappingForm<M | ||||
|                     ${msg("Expression using Python.")} | ||||
|                     <a | ||||
|                         target="_blank" | ||||
|                         rel="noopener noreferrer" | ||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||
|                     > | ||||
|                         ${msg("See documentation for a list of all variables.")} | ||||
|  | ||||
| @ -62,6 +62,7 @@ export class PropertyMappingNotification extends ModelForm<NotificationWebhookMa | ||||
|                     ${msg("Expression using Python.")} | ||||
|                     <a | ||||
|                         target="_blank" | ||||
|                         rel="noopener noreferrer" | ||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||
|                     > | ||||
|                         ${msg("See documentation for a list of all variables.")} | ||||
|  | ||||
| @ -160,6 +160,7 @@ export class PropertyMappingLDAPForm extends ModelForm<RACPropertyMapping, strin | ||||
|                             ${msg("Expression using Python.")} | ||||
|                             <a | ||||
|                                 target="_blank" | ||||
|                                 rel="noopener noreferrer" | ||||
|                                 href="${docLink( | ||||
|                                     "/docs/property-mappings/expression?utm_source=authentik", | ||||
|                                 )}" | ||||
|  | ||||
| @ -83,6 +83,7 @@ export class PropertyMappingSAMLForm extends BasePropertyMappingForm<SAMLPropert | ||||
|                     ${msg("Expression using Python.")} | ||||
|                     <a | ||||
|                         target="_blank" | ||||
|                         rel="noopener noreferrer" | ||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||
|                     > | ||||
|                         ${msg("See documentation for a list of all variables.")} | ||||
|  | ||||
| @ -56,6 +56,7 @@ export class PropertyMappingSCIMForm extends BasePropertyMappingForm<SCIMMapping | ||||
|                     ${msg("Expression using Python.")} | ||||
|                     <a | ||||
|                         target="_blank" | ||||
|                         rel="noopener noreferrer" | ||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||
|                     > | ||||
|                         ${msg("See documentation for a list of all variables.")} | ||||
|  | ||||
| @ -83,6 +83,7 @@ export class PropertyMappingScopeForm extends BasePropertyMappingForm<ScopeMappi | ||||
|                     ${msg("Expression using Python.")} | ||||
|                     <a | ||||
|                         target="_blank" | ||||
|                         rel="noopener noreferrer" | ||||
|                         href="${docLink("/docs/property-mappings/expression?utm_source=authentik")}" | ||||
|                     > | ||||
|                         ${msg("See documentation for a list of all variables.")} | ||||
|  | ||||
| @ -1,3 +1,7 @@ | ||||
| import { | ||||
|     digestAlgorithmOptions, | ||||
|     signatureAlgorithmOptions, | ||||
| } from "@goauthentik/admin/applications/wizard/methods/saml/SamlProviderOptions"; | ||||
| import "@goauthentik/admin/common/ak-crypto-certificate-search"; | ||||
| import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; | ||||
| import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; | ||||
| @ -14,7 +18,6 @@ import { customElement } from "lit/decorators.js"; | ||||
| import { ifDefined } from "lit/directives/if-defined.js"; | ||||
|  | ||||
| import { | ||||
|     DigestAlgorithmEnum, | ||||
|     FlowsInstancesListDesignationEnum, | ||||
|     PaginatedSAMLPropertyMappingList, | ||||
|     PropertymappingsApi, | ||||
| @ -22,7 +25,6 @@ import { | ||||
|     ProvidersApi, | ||||
|     SAMLPropertyMapping, | ||||
|     SAMLProvider, | ||||
|     SignatureAlgorithmEnum, | ||||
|     SpBindingEnum, | ||||
| } from "@goauthentik/api"; | ||||
|  | ||||
| @ -333,25 +335,7 @@ export class SAMLProviderFormPage extends BaseProviderForm<SAMLProvider> { | ||||
|                         name="digestAlgorithm" | ||||
|                     > | ||||
|                         <ak-radio | ||||
|                             .options=${[ | ||||
|                                 { | ||||
|                                     label: "SHA1", | ||||
|                                     value: DigestAlgorithmEnum._200009Xmldsigsha1, | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     label: "SHA256", | ||||
|                                     value: DigestAlgorithmEnum._200104Xmlencsha256, | ||||
|                                     default: true, | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     label: "SHA384", | ||||
|                                     value: DigestAlgorithmEnum._200104XmldsigMoresha384, | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     label: "SHA512", | ||||
|                                     value: DigestAlgorithmEnum._200104Xmlencsha512, | ||||
|                                 }, | ||||
|                             ]} | ||||
|                             .options=${digestAlgorithmOptions} | ||||
|                             .value=${this.instance?.digestAlgorithm} | ||||
|                         > | ||||
|                         </ak-radio> | ||||
| @ -362,29 +346,7 @@ export class SAMLProviderFormPage extends BaseProviderForm<SAMLProvider> { | ||||
|                         name="signatureAlgorithm" | ||||
|                     > | ||||
|                         <ak-radio | ||||
|                             .options=${[ | ||||
|                                 { | ||||
|                                     label: "RSA-SHA1", | ||||
|                                     value: SignatureAlgorithmEnum._200009XmldsigrsaSha1, | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     label: "RSA-SHA256", | ||||
|                                     value: SignatureAlgorithmEnum._200104XmldsigMorersaSha256, | ||||
|                                     default: true, | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     label: "RSA-SHA384", | ||||
|                                     value: SignatureAlgorithmEnum._200104XmldsigMorersaSha384, | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     label: "RSA-SHA512", | ||||
|                                     value: SignatureAlgorithmEnum._200104XmldsigMorersaSha512, | ||||
|                                 }, | ||||
|                                 { | ||||
|                                     label: "DSA-SHA1", | ||||
|                                     value: SignatureAlgorithmEnum._200009XmldsigdsaSha1, | ||||
|                                 }, | ||||
|                             ]} | ||||
|                             .options=${signatureAlgorithmOptions} | ||||
|                             .value=${this.instance?.signatureAlgorithm} | ||||
|                         > | ||||
|                         </ak-radio> | ||||
|  | ||||
| @ -36,11 +36,13 @@ import "@goauthentik/elements/oauth/UserRefreshTokenList"; | ||||
| import "@goauthentik/elements/rbac/ObjectPermissionsPage"; | ||||
| import "@goauthentik/elements/user/SessionList"; | ||||
| import "@goauthentik/elements/user/UserConsentList"; | ||||
| import "@goauthentik/elements/user/UserReputationList"; | ||||
| import "@goauthentik/elements/user/sources/SourceSettings"; | ||||
|  | ||||
| import { msg, str } from "@lit/localize"; | ||||
| import { TemplateResult, css, html, nothing } from "lit"; | ||||
| import { customElement, property, state } from "lit/decorators.js"; | ||||
| import { ifDefined } from "lit/directives/if-defined.js"; | ||||
|  | ||||
| import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; | ||||
| import PFButton from "@patternfly/patternfly/components/Button/button.css"; | ||||
| @ -274,6 +276,21 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) { | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </section> | ||||
|                 <section | ||||
|                     slot="page-reputation" | ||||
|                     data-tab-title="${msg("Reputation scores")}" | ||||
|                     class="pf-c-page__main-section pf-m-no-padding-mobile" | ||||
|                 > | ||||
|                     <div class="pf-c-card"> | ||||
|                         <div class="pf-c-card__body"> | ||||
|                             <ak-user-reputation-list | ||||
|                                 targetUsername=${user.username} | ||||
|                                 targetEmail=${ifDefined(user.email)} | ||||
|                             > | ||||
|                             </ak-user-reputation-list> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </section> | ||||
|                 <section | ||||
|                     slot="page-consent" | ||||
|                     data-tab-title="${msg("Explicit Consent")}" | ||||
|  | ||||
| @ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success"; | ||||
| export const ERROR_CLASS = "pf-m-danger"; | ||||
| export const PROGRESS_CLASS = "pf-m-in-progress"; | ||||
| export const CURRENT_CLASS = "pf-m-current"; | ||||
| export const VERSION = "2024.6.0"; | ||||
| export const VERSION = "2024.6.2"; | ||||
| export const TITLE_DEFAULT = "authentik"; | ||||
| export const ROUTE_SEPARATOR = ";"; | ||||
|  | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; | ||||
| import { globalAK } from "@goauthentik/common/global"; | ||||
| import { UIConfig } from "@goauthentik/common/ui/config"; | ||||
| import { adaptCSS } from "@goauthentik/common/utils"; | ||||
| import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; | ||||
| @ -16,6 +17,7 @@ type AkInterface = HTMLElement & { | ||||
|     brand?: CurrentBrand; | ||||
|     uiConfig?: UIConfig; | ||||
|     config?: Config; | ||||
|     get activeTheme(): UiThemeEnum | undefined; | ||||
| }; | ||||
|  | ||||
| export const rootInterface = <T extends AkInterface>(): T | undefined => | ||||
| @ -41,7 +43,11 @@ function fetchCustomCSS(): Promise<string[]> { | ||||
|     return css; | ||||
| } | ||||
|  | ||||
| const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)"; | ||||
| export const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)"; | ||||
|  | ||||
| // Ensure themes are converted to a static instance of CSS Stylesheet, otherwise the | ||||
| // when changing themes we might not remove the correct css stylesheet instance. | ||||
| const _darkTheme = ensureCSSStyleSheet(ThemeDark); | ||||
|  | ||||
| @localized() | ||||
| export class AKElement extends LitElement { | ||||
| @ -90,12 +96,7 @@ export class AKElement extends LitElement { | ||||
|     async _initTheme(root: DocumentOrShadowRoot): Promise<void> { | ||||
|         // Early activate theme based on media query to prevent light flash | ||||
|         // when dark is preferred | ||||
|         this._activateTheme( | ||||
|             root, | ||||
|             window.matchMedia(QUERY_MEDIA_COLOR_LIGHT).matches | ||||
|                 ? UiThemeEnum.Light | ||||
|                 : UiThemeEnum.Dark, | ||||
|         ); | ||||
|         this._applyTheme(root, globalAK().brand.uiTheme); | ||||
|         this._applyTheme(root, await this.getTheme()); | ||||
|     } | ||||
|  | ||||
| @ -127,6 +128,7 @@ export class AKElement extends LitElement { | ||||
|                             : UiThemeEnum.Dark; | ||||
|                     this._activateTheme(root, theme); | ||||
|                 }; | ||||
|                 this._mediaMatcherHandler(undefined); | ||||
|                 this._mediaMatcher.addEventListener("change", this._mediaMatcherHandler); | ||||
|             } | ||||
|             return; | ||||
| @ -141,7 +143,7 @@ export class AKElement extends LitElement { | ||||
|  | ||||
|     static themeToStylesheet(theme?: UiThemeEnum): CSSStyleSheet | undefined { | ||||
|         if (theme === UiThemeEnum.Dark) { | ||||
|             return ThemeDark; | ||||
|             return _darkTheme; | ||||
|         } | ||||
|         return undefined; | ||||
|     } | ||||
|  | ||||
| @ -9,7 +9,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; | ||||
| import type { Config, CurrentBrand, LicenseSummary } from "@goauthentik/api"; | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| import { AKElement } from "../Base"; | ||||
| import { AKElement, rootInterface } from "../Base"; | ||||
| import { BrandContextController } from "./BrandContextController"; | ||||
| import { ConfigContextController } from "./ConfigContextController"; | ||||
| import { EnterpriseContextController } from "./EnterpriseContextController"; | ||||
| @ -51,8 +51,14 @@ export class Interface extends AKElement implements AkInterface { | ||||
|     } | ||||
|  | ||||
|     _activateTheme(root: DocumentOrShadowRoot, theme: UiThemeEnum): void { | ||||
|         super._activateTheme(root, theme); | ||||
|         if (theme === this._activeTheme) { | ||||
|             return; | ||||
|         } | ||||
|         console.debug( | ||||
|             `authentik/interface[${rootInterface()?.tagName.toLowerCase()}]: Enabling theme ${theme}`, | ||||
|         ); | ||||
|         super._activateTheme(document as unknown as DocumentOrShadowRoot, theme); | ||||
|         super._activateTheme(root, theme); | ||||
|     } | ||||
|  | ||||
|     async getTheme(): Promise<UiThemeEnum> { | ||||
|  | ||||
| @ -78,7 +78,7 @@ export class Markdown extends AKElement { | ||||
|             const pathName = path.replace(".md", ""); | ||||
|             const link = `docs/${baseName}${pathName}`; | ||||
|             const url = new URL(link, baseUrl).toString(); | ||||
|             return `href="${url}" _target="blank"`; | ||||
|             return `href="${url}" _target="blank" rel="noopener noreferrer"`; | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|  | ||||
| @ -1,7 +1,8 @@ | ||||
| import { EVENT_LOCALE_CHANGE, EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants"; | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import { customEvent } from "@goauthentik/elements/utils/customEvents"; | ||||
|  | ||||
| import { LitElement, html } from "lit"; | ||||
| import { html } from "lit"; | ||||
| import { customElement, property } from "lit/decorators.js"; | ||||
|  | ||||
| import { WithBrandConfig } from "../Interface/brandProvider"; | ||||
| @ -9,8 +10,6 @@ import { initializeLocalization } from "./configureLocale"; | ||||
| import type { LocaleGetter, LocaleSetter } from "./configureLocale"; | ||||
| import { DEFAULT_LOCALE, autoDetectLanguage, getBestMatchLocale } from "./helpers"; | ||||
|  | ||||
| const LocaleContextBase = WithBrandConfig(LitElement); | ||||
|  | ||||
| /** | ||||
|  * A component to manage your locale settings. | ||||
|  * | ||||
| @ -25,7 +24,7 @@ const LocaleContextBase = WithBrandConfig(LitElement); | ||||
|  * @fires ak-locale-change - When a valid locale has been swapped in | ||||
|  */ | ||||
| @customElement("ak-locale-context") | ||||
| export class LocaleContext extends LocaleContextBase { | ||||
| export class LocaleContext extends WithBrandConfig(AKElement) { | ||||
|     /// @attribute The text representation of the current locale */ | ||||
|     @property({ attribute: true, type: String }) | ||||
|     locale = DEFAULT_LOCALE; | ||||
| @ -78,7 +77,7 @@ export class LocaleContext extends LocaleContextBase { | ||||
|             return; | ||||
|         } | ||||
|         locale.locale().then(() => { | ||||
|             console.debug(`Setting Locale to ... ${locale.label()} (${locale.code})`); | ||||
|             console.debug(`authentik/locale: Setting Locale to ${locale.label()} (${locale.code})`); | ||||
|             this.setLocale(locale.code).then(() => { | ||||
|                 window.setTimeout(this.notifyApplication, 0); | ||||
|             }); | ||||
|  | ||||
| @ -66,15 +66,15 @@ export class UserOAuthAccessTokenList extends Table<TokenModel> { | ||||
|     renderToolbarSelected(): TemplateResult { | ||||
|         const disabled = this.selectedElements.length < 1; | ||||
|         return html`<ak-forms-delete-bulk | ||||
|             objectLabel=${msg("Refresh Tokens(s)")} | ||||
|             objectLabel=${msg("Access Tokens(s)")} | ||||
|             .objects=${this.selectedElements} | ||||
|             .usedBy=${(item: ExpiringBaseGrantModel) => { | ||||
|                 return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensUsedByList({ | ||||
|                 return new Oauth2Api(DEFAULT_CONFIG).oauth2AccessTokensUsedByList({ | ||||
|                     id: item.pk, | ||||
|                 }); | ||||
|             }} | ||||
|             .delete=${(item: ExpiringBaseGrantModel) => { | ||||
|                 return new Oauth2Api(DEFAULT_CONFIG).oauth2RefreshTokensDestroy({ | ||||
|                 return new Oauth2Api(DEFAULT_CONFIG).oauth2AccessTokensDestroy({ | ||||
|                     id: item.pk, | ||||
|                 }); | ||||
|             }} | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants"; | ||||
| import { AKElement } from "@goauthentik/elements/Base"; | ||||
| import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider"; | ||||
| import { themeImage } from "@goauthentik/elements/utils/images"; | ||||
|  | ||||
| import { CSSResult, TemplateResult, css, html } from "lit"; | ||||
| import { customElement } from "lit/decorators.js"; | ||||
| @ -84,7 +85,7 @@ export class SidebarBrand extends WithBrandConfig(AKElement) { | ||||
|             <a href="#/" class="pf-c-page__header-brand-link"> | ||||
|                 <div class="pf-c-brand ak-brand"> | ||||
|                     <img | ||||
|                         src=${this.brand?.brandingLogo ?? DefaultBrand.brandingLogo} | ||||
|                         src=${themeImage(this.brand?.brandingLogo ?? DefaultBrand.brandingLogo)} | ||||
|                         alt="authentik Logo" | ||||
|                         loading="lazy" | ||||
|                     /> | ||||
|  | ||||
							
								
								
									
										83
									
								
								web/src/elements/user/UserReputationList.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										83
									
								
								web/src/elements/user/UserReputationList.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,83 @@ | ||||
| import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; | ||||
| import { uiConfig } from "@goauthentik/common/ui/config"; | ||||
| import { getRelativeTime } from "@goauthentik/common/utils"; | ||||
| import "@goauthentik/elements/forms/DeleteBulkForm"; | ||||
| import { PaginatedResponse } from "@goauthentik/elements/table/Table"; | ||||
| import { Table, TableColumn } from "@goauthentik/elements/table/Table"; | ||||
| import getUnicodeFlagIcon from "country-flag-icons/unicode"; | ||||
|  | ||||
| import { msg } from "@lit/localize"; | ||||
| import { TemplateResult, html } from "lit"; | ||||
| import { customElement, property } from "lit/decorators.js"; | ||||
|  | ||||
| import { PoliciesApi, Reputation } from "@goauthentik/api"; | ||||
|  | ||||
| @customElement("ak-user-reputation-list") | ||||
| export class UserReputationList extends Table<Reputation> { | ||||
|     @property() | ||||
|     targetUsername!: string; | ||||
|  | ||||
|     @property() | ||||
|     targetEmail!: string | undefined; | ||||
|  | ||||
|     async apiEndpoint(page: number): Promise<PaginatedResponse<Reputation>> { | ||||
|         const identifiers = [this.targetUsername]; | ||||
|         if (this.targetEmail !== undefined) { | ||||
|             identifiers.push(this.targetEmail); | ||||
|         } | ||||
|         return new PoliciesApi(DEFAULT_CONFIG).policiesReputationScoresList({ | ||||
|             identifierIn: identifiers, | ||||
|             ordering: this.order, | ||||
|             page: page, | ||||
|             pageSize: (await uiConfig()).pagination.perPage, | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     checkbox = true; | ||||
|     clearOnRefresh = true; | ||||
|     order = "identifier"; | ||||
|  | ||||
|     columns(): TableColumn[] { | ||||
|         return [ | ||||
|             new TableColumn(msg("Identifier"), "identifier"), | ||||
|             new TableColumn(msg("IP"), "ip"), | ||||
|             new TableColumn(msg("Score"), "score"), | ||||
|             new TableColumn(msg("Updated"), "updated"), | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     renderToolbarSelected(): TemplateResult { | ||||
|         const disabled = this.selectedElements.length < 1; | ||||
|         return html`<ak-forms-delete-bulk | ||||
|             objectLabel=${msg("Reputation score(s)")} | ||||
|             .objects=${this.selectedElements} | ||||
|             .usedBy=${(item: Reputation) => { | ||||
|                 return new PoliciesApi(DEFAULT_CONFIG).policiesReputationScoresUsedByList({ | ||||
|                     reputationUuid: item.pk || "", | ||||
|                 }); | ||||
|             }} | ||||
|             .delete=${(item: Reputation) => { | ||||
|                 return new PoliciesApi(DEFAULT_CONFIG).policiesReputationScoresDestroy({ | ||||
|                     reputationUuid: item.pk || "", | ||||
|                 }); | ||||
|             }} | ||||
|         > | ||||
|             <button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger"> | ||||
|                 ${msg("Delete")} | ||||
|             </button> | ||||
|         </ak-forms-delete-bulk>`; | ||||
|     } | ||||
|  | ||||
|     row(item: Reputation): TemplateResult[] { | ||||
|         return [ | ||||
|             html`${item.identifier}`, | ||||
|             html`${item.ipGeoData?.country | ||||
|                 ? html` ${getUnicodeFlagIcon(item.ipGeoData.country)} ` | ||||
|                 : html``} | ||||
|             ${item.ip}`, | ||||
|             html`${item.score}`, | ||||
|             html`<div>${getRelativeTime(item.updated)}</div> | ||||
|                 <small>${item.updated.toLocaleString()}</small>`, | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										13
									
								
								web/src/elements/utils/images.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								web/src/elements/utils/images.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,13 @@ | ||||
| import { QUERY_MEDIA_COLOR_LIGHT, rootInterface } from "@goauthentik/elements/Base"; | ||||
|  | ||||
| import { UiThemeEnum } from "@goauthentik/api"; | ||||
|  | ||||
| export function themeImage(rawPath: string) { | ||||
|     let enabledTheme = rootInterface()?.activeTheme; | ||||
|     if (!enabledTheme || enabledTheme === UiThemeEnum.Automatic) { | ||||
|         enabledTheme = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT).matches | ||||
|             ? UiThemeEnum.Light | ||||
|             : UiThemeEnum.Dark; | ||||
|     } | ||||
|     return rawPath.replace("%(theme)s", enabledTheme); | ||||
| } | ||||
| @ -11,6 +11,7 @@ import { WebsocketClient } from "@goauthentik/common/ws"; | ||||
| import { Interface } from "@goauthentik/elements/Interface"; | ||||
| import "@goauthentik/elements/LoadingOverlay"; | ||||
| import "@goauthentik/elements/ak-locale-context"; | ||||
| import { themeImage } from "@goauthentik/elements/utils/images"; | ||||
| import "@goauthentik/flow/sources/apple/AppleLoginInit"; | ||||
| import "@goauthentik/flow/sources/plex/PlexLoginInit"; | ||||
| import "@goauthentik/flow/stages/FlowErrorStage"; | ||||
| @ -442,7 +443,9 @@ export class FlowExecutor extends Interface implements StageHost { | ||||
|     renderChallengeWrapper(): TemplateResult { | ||||
|         const logo = html`<div class="pf-c-login__main-header pf-c-brand ak-brand"> | ||||
|             <img | ||||
|                 src="${first(this.brand?.brandingLogo, globalAK()?.brand.brandingLogo, "")}" | ||||
|                 src="${themeImage( | ||||
|                     first(this.brand?.brandingLogo, globalAK()?.brand.brandingLogo, ""), | ||||
|                 )}" | ||||
|                 alt="authentik Logo" | ||||
|             /> | ||||
|         </div>`; | ||||
| @ -503,28 +506,18 @@ export class FlowExecutor extends Interface implements StageHost { | ||||
|                                         <footer class="pf-c-login__footer"> | ||||
|                                             <ul class="pf-c-list pf-m-inline"> | ||||
|                                                 ${this.brand?.uiFooterLinks?.map((link) => { | ||||
|                                                     if (link.href) { | ||||
|                                                         return html`<li> | ||||
|                                                             <a href="${link.href}">${link.name}</a> | ||||
|                                                         </li>`; | ||||
|                                                     } | ||||
|                                                     return html`<li> | ||||
|                                                         <a href="${link.href || ""}" | ||||
|                                                             >${link.name}</a | ||||
|                                                         > | ||||
|                                                         <span>${link.name}</span> | ||||
|                                                     </li>`; | ||||
|                                                 })} | ||||
|                                                 <li> | ||||
|                                                     <a | ||||
|                                                         href="https://goauthentik.io?utm_source=authentik&utm_medium=flow" | ||||
|                                                         >${msg("Powered by authentik")}</a | ||||
|                                                     > | ||||
|                                                     <span>${msg("Powered by authentik")}</span> | ||||
|                                                 </li> | ||||
|                                                 ${this.flowInfo?.background?.startsWith("/static") | ||||
|                                                     ? html` | ||||
|                                                           <li> | ||||
|                                                               <a | ||||
|                                                                   href="https://unsplash.com/@benjaminpunzalan" | ||||
|                                                                   >${msg("Background image")}</a | ||||
|                                                               > | ||||
|                                                           </li> | ||||
|                                                       ` | ||||
|                                                     : html``} | ||||
|                                             </ul> | ||||
|                                         </footer> | ||||
|                                     </div> | ||||
|  | ||||
| @ -51,11 +51,6 @@ export class AutosubmitStage extends BaseStage< | ||||
|                         />`; | ||||
|                     })} | ||||
|                     <ak-empty-state ?loading="${true}"> </ak-empty-state> | ||||
|                     <div class="pf-c-form__group pf-m-action"> | ||||
|                         <button type="submit" class="pf-c-button pf-m-primary pf-m-block"> | ||||
|                             ${msg("Continue")} | ||||
|                         </button> | ||||
|                     </div> | ||||
|                 </form> | ||||
|             </div> | ||||
|             <footer class="pf-c-login__main-footer"> | ||||
|  | ||||
| @ -5,6 +5,7 @@ import { first, getCookie } from "@goauthentik/common/utils"; | ||||
| import { Interface } from "@goauthentik/elements/Interface"; | ||||
| import "@goauthentik/elements/ak-locale-context"; | ||||
| import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand"; | ||||
| import { themeImage } from "@goauthentik/elements/utils/images"; | ||||
| import "rapidoc"; | ||||
|  | ||||
| import { CSSResult, TemplateResult, css, html } from "lit"; | ||||
| @ -103,7 +104,9 @@ export class APIBrowser extends Interface { | ||||
|                         <img | ||||
|                             alt="authentik Logo" | ||||
|                             class="logo" | ||||
|                             src="${first(this.brand?.brandingLogo, DefaultBrand.brandingLogo)}" | ||||
|                             src="${themeImage( | ||||
|                                 first(this.brand?.brandingLogo, DefaultBrand.brandingLogo), | ||||
|                             )}" | ||||
|                         /> | ||||
|                     </div> | ||||
|                 </rapi-doc> | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user
	