diff --git a/authentik/core/signals.py b/authentik/core/signals.py index cd76e07b51..3ebda21be6 100644 --- a/authentik/core/signals.py +++ b/authentik/core/signals.py @@ -1,5 +1,7 @@ """authentik core signals""" +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer from django.contrib.auth.signals import user_logged_in from django.core.cache import cache from django.db.models import Model @@ -17,6 +19,8 @@ from authentik.core.models import ( User, default_token_duration, ) +from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication +from authentik.root.ws.consumer import build_device_group # Arguments: user: User, password: str password_changed = Signal() @@ -47,6 +51,16 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_): if session: session.save() + if not RefreshOtherFlowsAfterAuthentication().get(): + return + layer = get_channel_layer() + device_cookie = request.COOKIES.get("authentik_device") + if device_cookie: + async_to_sync(layer.group_send)( + build_device_group(device_cookie), + {"type": "event.session.authenticated"}, + ) + @receiver(post_delete, sender=AuthenticatedSession) def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): diff --git a/authentik/core/urls.py b/authentik/core/urls.py index b20498d741..7a97c1f379 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -28,8 +28,8 @@ from authentik.core.views.interface import ( ) from authentik.flows.views.interface import FlowInterfaceView from authentik.root.asgi_middleware import AuthMiddlewareStack -from authentik.root.messages.consumer import MessageConsumer from authentik.root.middleware import ChannelsLoggingMiddleware +from authentik.root.ws.consumer import MessageConsumer from authentik.tenants.channels import TenantsAwareMiddleware urlpatterns = [ diff --git a/authentik/flows/apps.py b/authentik/flows/apps.py index 95f50f735d..44f3c05574 100644 --- a/authentik/flows/apps.py +++ b/authentik/flows/apps.py @@ -4,6 +4,7 @@ from prometheus_client import Gauge, Histogram from authentik.blueprints.apps import ManagedAppConfig from authentik.lib.utils.reflection import all_subclasses +from authentik.tenants.flags import Flag GAUGE_FLOWS_CACHED = Gauge( "authentik_flows_cached", @@ -22,6 +23,12 @@ HIST_FLOWS_PLAN_TIME = Histogram( ) +class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others"): + + default = False + visibility = "public" + + class AuthentikFlowsConfig(ManagedAppConfig): """authentik flows app config""" diff --git a/authentik/root/messages/consumer.py b/authentik/root/messages/consumer.py deleted file mode 100644 index 964ea6bba1..0000000000 --- a/authentik/root/messages/consumer.py +++ /dev/null @@ -1,27 +0,0 @@ -"""websocket Message consumer""" - -from channels.generic.websocket import JsonWebsocketConsumer -from django.core.cache import cache - -from authentik.root.messages.storage import CACHE_PREFIX - - -class MessageConsumer(JsonWebsocketConsumer): - """Consumer which sends django.contrib.messages Messages over WS. - channel_name is saved into cache with user_id, and when a add_message is called""" - - session_key: str - - def connect(self): - self.accept() - self.session_key = self.scope["session"].session_key - if not self.session_key: - return - cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None) - - def disconnect(self, code): - cache.delete(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}") - - def event_update(self, event: dict): - """Event handler which is called by Messages Storage backend""" - self.send_json(event) diff --git a/authentik/root/settings.py b/authentik/root/settings.py index be95f67e3a..dc4805b833 100644 --- a/authentik/root/settings.py +++ b/authentik/root/settings.py @@ -254,7 +254,7 @@ SESSION_COOKIE_AGE = timedelta_from_string( ).total_seconds() SESSION_EXPIRE_AT_BROWSER_CLOSE = True -MESSAGE_STORAGE = "authentik.root.messages.storage.ChannelsStorage" +MESSAGE_STORAGE = "authentik.root.ws.storage.ChannelsStorage" MIDDLEWARE_FIRST = [ "django_prometheus.middleware.PrometheusBeforeMiddleware", diff --git a/authentik/root/messages/__init__.py b/authentik/root/ws/__init__.py similarity index 100% rename from authentik/root/messages/__init__.py rename to authentik/root/ws/__init__.py diff --git a/authentik/root/ws/consumer.py b/authentik/root/ws/consumer.py new file mode 100644 index 0000000000..082627ccfe --- /dev/null +++ b/authentik/root/ws/consumer.py @@ -0,0 +1,58 @@ +"""websocket Message consumer""" + +from hashlib import sha256 + +from asgiref.sync import async_to_sync +from channels.generic.websocket import JsonWebsocketConsumer +from django.core.cache import cache +from django.db import connection + +from authentik.root.ws.storage import CACHE_PREFIX + + +def build_session_group(session_key: str): + return sha256( + f"{connection.schema_name}/group_client_session_{str(session_key)}".encode() + ).hexdigest() + + +def build_device_group(session_key: str): + return sha256( + f"{connection.schema_name}/group_client_device_{str(session_key)}".encode() + ).hexdigest() + + +class MessageConsumer(JsonWebsocketConsumer): + """Consumer which sends django.contrib.messages Messages over WS. + channel_name is saved into cache with user_id, and when a add_message is called""" + + session_key: str + device_cookie: str | None = None + + def connect(self): + self.accept() + self.session_key = self.scope["session"].session_key + if self.session_key: + cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None) + if device_cookie := self.scope["cookies"]["authentik_device"]: + self.device_cookie = device_cookie + async_to_sync(self.channel_layer.group_add)( + build_device_group(self.device_cookie), self.channel_name + ) + + def disconnect(self, code): + if self.session_key: + cache.delete(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}") + if self.device_cookie: + print("removing from group", build_session_group(self.session_key)) + async_to_sync(self.channel_layer.group_discard)( + build_device_group(self.device_cookie), self.channel_name + ) + + def event_message(self, event: dict): + """Event handler which is called by Messages Storage backend""" + self.send_json(event) + + def event_session_authenticated(self, event: dict): + """Event handler post user authentication""" + self.send_json({"message_type": "session.authenticated"}) diff --git a/authentik/root/messages/storage.py b/authentik/root/ws/storage.py similarity index 96% rename from authentik/root/messages/storage.py rename to authentik/root/ws/storage.py index 4fb9760254..6ac974fe99 100644 --- a/authentik/root/messages/storage.py +++ b/authentik/root/ws/storage.py @@ -31,7 +31,7 @@ class ChannelsStorage(SessionStorage): async_to_sync(self.channel.send)( uid, { - "type": "event.update", + "type": "event.message", "message_type": "message", "level": message.level_tag, "tags": message.tags, diff --git a/schema.yml b/schema.yml index da7874e3b8..9322ad840a 100644 --- a/schema.yml +++ b/schema.yml @@ -35696,7 +35696,10 @@ components: properties: policies_buffered_access_view: type: boolean + flows_refresh_others: + type: boolean required: + - flows_refresh_others - policies_buffered_access_view readOnly: true required: @@ -48756,7 +48759,10 @@ components: properties: policies_buffered_access_view: type: boolean + flows_refresh_others: + type: boolean required: + - flows_refresh_others - policies_buffered_access_view PatchedSourceStageRequest: type: object @@ -53189,7 +53195,10 @@ components: properties: policies_buffered_access_view: type: boolean + flows_refresh_others: + type: boolean required: + - flows_refresh_others - policies_buffered_access_view required: - flags @@ -53251,7 +53260,10 @@ components: properties: policies_buffered_access_view: type: boolean + flows_refresh_others: + type: boolean required: + - flows_refresh_others - policies_buffered_access_view required: - flags diff --git a/web/src/admin/admin-settings/AdminSettingsForm.ts b/web/src/admin/admin-settings/AdminSettingsForm.ts index d3a7fd9530..401b232bd3 100644 --- a/web/src/admin/admin-settings/AdminSettingsForm.ts +++ b/web/src/admin/admin-settings/AdminSettingsForm.ts @@ -8,7 +8,6 @@ import "#elements/forms/Radio"; import "#elements/forms/SearchSelect/index"; import "#elements/utils/TimeDeltaHelp"; import "./AdminSettingsFooterLinks.js"; -import "#elements/CodeMirror"; import { akFooterLinkInput, IFooterLinkInput } from "./AdminSettingsFooterLinks.js"; @@ -18,8 +17,6 @@ import { Form } from "#elements/forms/Form"; import { AdminApi, FooterLink, Settings, SettingsRequest } from "@goauthentik/api"; -import YAML from "yaml"; - import { msg } from "@lit/localize"; import { css, CSSResult, html, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -248,13 +245,33 @@ export class AdminSettingsForm extends Form { value="${settings.defaultTokenLength ?? 60}" help=${msg("Default length of generated tokens")} > - - - -

- ${msg("Modify flags to opt into new authentik behaviours early.")} -

-
+ +
+ + + + +
+
`; } } diff --git a/web/src/common/ui/config.ts b/web/src/common/ui/config.ts index 6078069e4b..98fc5c0d79 100644 --- a/web/src/common/ui/config.ts +++ b/web/src/common/ui/config.ts @@ -15,6 +15,7 @@ export const DefaultBrand = { defaultLocale: "", flags: { policiesBufferedAccessView: false, + flowsRefreshOthers: false, }, } as const satisfies CurrentBrand; diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index c81fc316d5..d601a27645 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -11,12 +11,16 @@ import "#flow/stages/RedirectStage"; import Styles from "./FlowExecutor.css" with { type: "bundled-text" }; import { DEFAULT_CONFIG } from "#common/api/config"; -import { EVENT_FLOW_ADVANCE, EVENT_FLOW_INSPECTOR_TOGGLE } from "#common/constants"; +import { + EVENT_FLOW_ADVANCE, + EVENT_FLOW_INSPECTOR_TOGGLE, + EVENT_WS_MESSAGE, +} from "#common/constants"; import { pluckErrorDetail } from "#common/errors/network"; import { globalAK } from "#common/global"; import { configureSentry } from "#common/sentry/index"; import { applyBackgroundImageProperty } from "#common/theme"; -import { WebsocketClient } from "#common/ws"; +import { WebsocketClient, WSMessage } from "#common/ws"; import { Interface } from "#elements/Interface"; import { WithBrandConfig } from "#elements/mixins/branding"; @@ -154,16 +158,25 @@ export class FlowExecutor }); } + #websocketHandler = (e: CustomEvent) => { + if (e.detail.message_type === "session.authenticated") { + console.debug("authentik/ws: Reloading after session authenticated event"); + window.location.reload(); + } + }; + public connectedCallback(): void { super.connectedCallback(); window.addEventListener(EVENT_FLOW_INSPECTOR_TOGGLE, this.#toggleInspector); + window.addEventListener(EVENT_WS_MESSAGE, this.#websocketHandler as EventListener); } public disconnectedCallback(): void { super.disconnectedCallback(); window.removeEventListener(EVENT_FLOW_INSPECTOR_TOGGLE, this.#toggleInspector); + window.removeEventListener(EVENT_WS_MESSAGE, this.#websocketHandler as EventListener); WebsocketClient.close(); }