flows: refresh unauthenticated tabs (#18621)

* flows: implement signaling

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add flag

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* better flag configuration

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* Update web/src/flow/FlowExecutor.ts

Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Signed-off-by: Jens L. <jens@beryju.org>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
This commit is contained in:
Jens L.
2025-12-05 16:03:16 +01:00
committed by GitHub
parent 024e6c1961
commit 31186baf25
12 changed files with 137 additions and 42 deletions
+14
View File
@@ -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", **_):
+1 -1
View File
@@ -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 = [
+7
View File
@@ -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"""
-27
View File
@@ -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)
+1 -1
View File
@@ -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",
+58
View File
@@ -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"})
@@ -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,
+12
View File
@@ -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
@@ -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<SettingsRequest> {
value="${settings.defaultTokenLength ?? 60}"
help=${msg("Default length of generated tokens")}
></ak-number-input>
<ak-form-element-horizontal label=${msg("Flags")} name="flags" required>
<ak-codemirror mode="yaml" value="${YAML.stringify(settings?.flags ?? {})}">
</ak-codemirror>
<p class="pf-c-form__helper-text">
${msg("Modify flags to opt into new authentik behaviours early.")}
</p>
</ak-form-element-horizontal>
<ak-form-group
label=${msg("Flags")}
description=${msg(
"Flags allow you to enable new functionality and behaviour in authentik early.",
)}
>
<div class="pf-c-form">
<ak-switch-input
name="flags.policiesBufferedAccessView"
?checked=${settings?.flags.policiesBufferedAccessView ?? false}
label=${msg("Buffer PolicyAccessVew requests")}
help=${msg(
"When enabled, parallel requests for application authorization will be buffered instead of conflicting with other flows.",
)}
>
</ak-switch-input>
<ak-switch-input
name="flags.flowsRefreshOthers"
?checked=${settings?.flags.flowsRefreshOthers ?? false}
label=${msg("Refresh other flow tabs upon authentication")}
help=${msg(
"When enabled, other flow tabs in a session will refresh upon a successful authentication.",
)}
>
</ak-switch-input>
</div>
</ak-form-group>
`;
}
}
+1
View File
@@ -15,6 +15,7 @@ export const DefaultBrand = {
defaultLocale: "",
flags: {
policiesBufferedAccessView: false,
flowsRefreshOthers: false,
},
} as const satisfies CurrentBrand;
+15 -2
View File
@@ -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<WSMessage>) => {
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();
}