From 821b74d7c1e59ca673fb3068dcc9f395550583e1 Mon Sep 17 00:00:00 2001
From: Dominic R
Date: Thu, 30 Apr 2026 19:02:46 -0400
Subject: [PATCH] enterprise: account lockdown (#18615)
---
.github/actions/setup/action.yml | 2 +-
.github/actions/setup/compose.yml | 6 +
authentik/blueprints/api.py | 2 +-
.../blueprints/tests/test_v1_conditions.py | 45 ++
authentik/blueprints/v1/importer.py | 4 +-
authentik/brands/api.py | 3 +
.../migrations/0012_brand_flow_lockdown.py | 25 +
authentik/brands/models.py | 3 +
authentik/core/api/users.py | 3 +
authentik/enterprise/settings.py | 1 +
.../stages/account_lockdown/__init__.py | 0
.../enterprise/stages/account_lockdown/api.py | 141 ++++
.../stages/account_lockdown/apps.py | 12 +
.../migrations/0001_initial.py | 74 +++
.../account_lockdown/migrations/__init__.py | 0
.../stages/account_lockdown/models.py | 62 ++
.../stages/account_lockdown/stage.py | 345 ++++++++++
.../stages/account_lockdown/tests/__init__.py | 0
.../stages/account_lockdown/tests/test_api.py | 148 +++++
.../account_lockdown/tests/test_blueprint.py | 46 ++
.../account_lockdown/tests/test_stage.py | 627 ++++++++++++++++++
.../stages/account_lockdown/urls.py | 5 +
authentik/root/settings.py | 1 +
.../migrations/0012_alter_prompt_type.py | 64 ++
authentik/stages/prompt/models.py | 12 +-
authentik/stages/prompt/stage.py | 3 +
authentik/stages/prompt/tests.py | 11 +
.../flow-default-account-lockdown.yaml | 306 +++++++++
blueprints/schema.json | 121 ++++
packages/client-go/api_core.go | 9 +
packages/client-go/model_brand.go | 48 ++
packages/client-go/model_prompt_type_enum.go | 6 +
packages/client-rust/src/apis/core_api.rs | 5 +
packages/client-rust/src/models/brand.rs | 8 +
.../src/models/prompt_type_enum.rs | 9 +
packages/client-ts/src/apis/CoreApi.ts | 71 ++
packages/client-ts/src/apis/StagesApi.ts | 576 ++++++++++++++++
.../src/models/AccountLockdownStage.ts | 166 +++++
.../src/models/AccountLockdownStageRequest.ts | 114 ++++
packages/client-ts/src/models/AppEnum.ts | 1 +
packages/client-ts/src/models/Brand.ts | 8 +
packages/client-ts/src/models/BrandRequest.ts | 8 +
packages/client-ts/src/models/CurrentBrand.ts | 8 +
packages/client-ts/src/models/ModelEnum.ts | 2 +
.../PaginatedAccountLockdownStageList.ts | 97 +++
.../PatchedAccountLockdownStageRequest.ts | 117 ++++
.../src/models/PatchedBrandRequest.ts | 8 +
.../client-ts/src/models/PromptTypeEnum.ts | 3 +
.../src/models/UserAccountLockdownRequest.ts | 69 ++
packages/client-ts/src/models/index.ts | 5 +
schema.yml | 405 +++++++++++
web/src/admin/brands/BrandForm.ts | 77 ++-
web/src/admin/events/RuleForm.ts | 4 +-
web/src/admin/events/RuleListPage.ts | 2 +-
web/src/admin/stages/BaseStageForm.ts | 2 +-
.../AccountLockdownStageForm.ts | 119 ++++
web/src/admin/stages/prompt/PromptForm.ts | 3 +
web/src/admin/stages/register.ts | 1 +
web/src/admin/users/UserActiveForm.ts | 37 --
web/src/admin/users/UserListPage.ts | 7 +-
web/src/admin/users/UserViewPage.ts | 56 +-
web/src/common/users.ts | 12 +
web/src/flow/stages/prompt/PromptStage.ts | 43 +-
.../user/user-settings/UserSettingsPage.ts | 73 +-
.../stages/account_lockdown/index.md | 117 ++++
website/docs/security/account-lockdown.md | 128 ++++
website/docs/sidebar.mjs | 2 +
website/docs/sys-mgmt/events/notifications.md | 2 +-
68 files changed, 4412 insertions(+), 88 deletions(-)
create mode 100644 authentik/brands/migrations/0012_brand_flow_lockdown.py
create mode 100644 authentik/enterprise/stages/account_lockdown/__init__.py
create mode 100644 authentik/enterprise/stages/account_lockdown/api.py
create mode 100644 authentik/enterprise/stages/account_lockdown/apps.py
create mode 100644 authentik/enterprise/stages/account_lockdown/migrations/0001_initial.py
create mode 100644 authentik/enterprise/stages/account_lockdown/migrations/__init__.py
create mode 100644 authentik/enterprise/stages/account_lockdown/models.py
create mode 100644 authentik/enterprise/stages/account_lockdown/stage.py
create mode 100644 authentik/enterprise/stages/account_lockdown/tests/__init__.py
create mode 100644 authentik/enterprise/stages/account_lockdown/tests/test_api.py
create mode 100644 authentik/enterprise/stages/account_lockdown/tests/test_blueprint.py
create mode 100644 authentik/enterprise/stages/account_lockdown/tests/test_stage.py
create mode 100644 authentik/enterprise/stages/account_lockdown/urls.py
create mode 100644 authentik/stages/prompt/migrations/0012_alter_prompt_type.py
create mode 100644 blueprints/example/flow-default-account-lockdown.yaml
create mode 100644 packages/client-ts/src/models/AccountLockdownStage.ts
create mode 100644 packages/client-ts/src/models/AccountLockdownStageRequest.ts
create mode 100644 packages/client-ts/src/models/PaginatedAccountLockdownStageList.ts
create mode 100644 packages/client-ts/src/models/PatchedAccountLockdownStageRequest.ts
create mode 100644 packages/client-ts/src/models/UserAccountLockdownRequest.ts
create mode 100644 web/src/admin/stages/account_lockdown/AccountLockdownStageForm.ts
create mode 100644 website/docs/add-secure-apps/flows-stages/stages/account_lockdown/index.md
create mode 100644 website/docs/security/account-lockdown.md
diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index db93be87f9..f298e4cb4f 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -104,7 +104,7 @@ runs:
working-directory: ${{ inputs.working-directory }}
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
- docker compose -f .github/actions/setup/compose.yml up -d
+ docker compose -f .github/actions/setup/compose.yml up -d --wait
cd web && npm ci
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}
diff --git a/.github/actions/setup/compose.yml b/.github/actions/setup/compose.yml
index 2fda7a13ac..ce905a0b7e 100644
--- a/.github/actions/setup/compose.yml
+++ b/.github/actions/setup/compose.yml
@@ -8,8 +8,14 @@ services:
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
POSTGRES_DB: authentik
+ PGDATA: /var/lib/postgresql/data/pgdata
ports:
- 5432:5432
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h 127.0.0.1"]
+ interval: 1s
+ timeout: 5s
+ retries: 60
restart: always
s3:
container_name: s3
diff --git a/authentik/blueprints/api.py b/authentik/blueprints/api.py
index 766a4dfe86..6a4148cc2d 100644
--- a/authentik/blueprints/api.py
+++ b/authentik/blueprints/api.py
@@ -126,7 +126,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
def check_blueprint_perms(blueprint: Blueprint, user: User, explicit_action: str | None = None):
"""Check for individual permissions for each model in a blueprint"""
- for entry in blueprint.entries:
+ for entry in blueprint.iter_entries():
full_model = entry.get_model(blueprint)
app, __, model = full_model.partition(".")
perms = [
diff --git a/authentik/blueprints/tests/test_v1_conditions.py b/authentik/blueprints/tests/test_v1_conditions.py
index 8dd79563e1..83b544d107 100644
--- a/authentik/blueprints/tests/test_v1_conditions.py
+++ b/authentik/blueprints/tests/test_v1_conditions.py
@@ -1,8 +1,11 @@
"""Test blueprints v1"""
+from unittest.mock import patch
+
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
+from authentik.enterprise.license import LicenseKey
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
@@ -42,3 +45,45 @@ class TestBlueprintsV1Conditions(TransactionTestCase):
# Ensure objects do not exist
self.assertFalse(Flow.objects.filter(slug=flow_slug1))
self.assertFalse(Flow.objects.filter(slug=flow_slug2))
+
+ def test_enterprise_license_context_unlicensed(self):
+ """Test enterprise license context defaults to a false boolean when unlicensed."""
+ license_key = LicenseKey("test", 0, "Test license", 0, 0)
+
+ with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
+ importer = Importer.from_string("""
+version: 1
+entries:
+ - identifiers:
+ name: enterprise-test
+ slug: enterprise-test
+ model: authentik_flows.flow
+ conditions:
+ - !Context goauthentik.io/enterprise/licensed
+ attrs:
+ designation: stage_configuration
+ title: foo
+""")
+
+ self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], False)
+
+ def test_enterprise_license_context_licensed(self):
+ """Test enterprise license context defaults to a true boolean when licensed."""
+ license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
+
+ with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
+ importer = Importer.from_string("""
+version: 1
+entries:
+ - identifiers:
+ name: enterprise-test
+ slug: enterprise-test
+ model: authentik_flows.flow
+ conditions:
+ - !Context goauthentik.io/enterprise/licensed
+ attrs:
+ designation: stage_configuration
+ title: foo
+""")
+
+ self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], True)
diff --git a/authentik/blueprints/v1/importer.py b/authentik/blueprints/v1/importer.py
index ed43187961..4e66d7d293 100644
--- a/authentik/blueprints/v1/importer.py
+++ b/authentik/blueprints/v1/importer.py
@@ -146,9 +146,7 @@ class Importer:
try:
from authentik.enterprise.license import LicenseKey
- context["goauthentik.io/enterprise/licensed"] = (
- LicenseKey.get_total().status().is_valid,
- )
+ context["goauthentik.io/enterprise/licensed"] = LicenseKey.get_total().status().is_valid
except ModuleNotFoundError:
pass
return context
diff --git a/authentik/brands/api.py b/authentik/brands/api.py
index a642347cde..96833ad49f 100644
--- a/authentik/brands/api.py
+++ b/authentik/brands/api.py
@@ -64,6 +64,7 @@ class BrandSerializer(ModelSerializer):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
+ "flow_lockdown",
"default_application",
"web_certificate",
"client_certificates",
@@ -117,6 +118,7 @@ class CurrentBrandSerializer(PassiveSerializer):
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
flow_user_settings = CharField(source="flow_user_settings.slug", required=False)
flow_device_code = CharField(source="flow_device_code.slug", required=False)
+ flow_lockdown = CharField(source="flow_lockdown.slug", required=False)
default_locale = CharField(read_only=True)
flags = SerializerMethodField()
@@ -154,6 +156,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
+ "flow_lockdown",
"web_certificate",
"client_certificates",
]
diff --git a/authentik/brands/migrations/0012_brand_flow_lockdown.py b/authentik/brands/migrations/0012_brand_flow_lockdown.py
new file mode 100644
index 0000000000..deaf9b1326
--- /dev/null
+++ b/authentik/brands/migrations/0012_brand_flow_lockdown.py
@@ -0,0 +1,25 @@
+# Generated by Django 5.2.12 on 2026-03-14 02:58
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("authentik_brands", "0011_alter_brand_branding_default_flow_background_and_more"),
+ ("authentik_flows", "0031_alter_flow_layout"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="brand",
+ name="flow_lockdown",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="brand_lockdown",
+ to="authentik_flows.flow",
+ ),
+ ),
+ ]
diff --git a/authentik/brands/models.py b/authentik/brands/models.py
index 0d34f1d0cb..94c772767a 100644
--- a/authentik/brands/models.py
+++ b/authentik/brands/models.py
@@ -58,6 +58,9 @@ class Brand(SerializerModel):
flow_device_code = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
)
+ flow_lockdown = models.ForeignKey(
+ Flow, null=True, on_delete=models.SET_NULL, related_name="brand_lockdown"
+ )
default_application = models.ForeignKey(
"authentik_core.Application",
diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py
index 059e211c45..18803b2c95 100644
--- a/authentik/core/api/users.py
+++ b/authentik/core/api/users.py
@@ -563,6 +563,9 @@ class UsersFilter(FilterSet):
class UserViewSet(
+ ConditionalInheritance(
+ "authentik.enterprise.stages.account_lockdown.api.UserAccountLockdownMixin"
+ ),
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"),
UsedByMixin,
ModelViewSet,
diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py
index 555dd11483..d6cc407a5d 100644
--- a/authentik/enterprise/settings.py
+++ b/authentik/enterprise/settings.py
@@ -14,6 +14,7 @@ TENANT_APPS = [
"authentik.enterprise.providers.ssf",
"authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
+ "authentik.enterprise.stages.account_lockdown",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source",
diff --git a/authentik/enterprise/stages/account_lockdown/__init__.py b/authentik/enterprise/stages/account_lockdown/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/authentik/enterprise/stages/account_lockdown/api.py b/authentik/enterprise/stages/account_lockdown/api.py
new file mode 100644
index 0000000000..d7f5afd00d
--- /dev/null
+++ b/authentik/enterprise/stages/account_lockdown/api.py
@@ -0,0 +1,141 @@
+"""Account Lockdown Stage API Views"""
+
+from django.utils.translation import gettext as _
+from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema
+from rest_framework.decorators import action
+from rest_framework.exceptions import ValidationError
+from rest_framework.permissions import IsAuthenticated
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.serializers import PrimaryKeyRelatedField
+from rest_framework.viewsets import ModelViewSet
+from structlog.stdlib import get_logger
+
+from authentik.api.validation import validate
+from authentik.core.api.used_by import UsedByMixin
+from authentik.core.api.utils import LinkSerializer, PassiveSerializer
+from authentik.core.models import (
+ User,
+)
+from authentik.enterprise.api import EnterpriseRequiredMixin, enterprise_action
+from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
+from authentik.enterprise.stages.account_lockdown.stage import (
+ can_lock_user,
+ get_lockdown_target_users,
+)
+from authentik.flows.api.stages import StageSerializer
+from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
+
+LOGGER = get_logger()
+
+
+class AccountLockdownStageSerializer(EnterpriseRequiredMixin, StageSerializer):
+ """AccountLockdownStage Serializer"""
+
+ class Meta:
+ model = AccountLockdownStage
+ fields = StageSerializer.Meta.fields + [
+ "deactivate_user",
+ "set_unusable_password",
+ "delete_sessions",
+ "revoke_tokens",
+ "self_service_completion_flow",
+ ]
+
+
+class AccountLockdownStageViewSet(UsedByMixin, ModelViewSet):
+ """AccountLockdownStage Viewset"""
+
+ queryset = AccountLockdownStage.objects.all()
+ serializer_class = AccountLockdownStageSerializer
+ filterset_fields = "__all__"
+ ordering = ["name"]
+ search_fields = ["name"]
+
+
+class UserAccountLockdownSerializer(PassiveSerializer):
+ """Choose the target account before starting the lockdown flow."""
+
+ user = PrimaryKeyRelatedField(
+ queryset=get_lockdown_target_users(),
+ required=False,
+ allow_null=True,
+ help_text=_("User to lock. If omitted, locks the current user (self-service)."),
+ )
+
+
+class UserAccountLockdownMixin:
+ """Enterprise account-lockdown API actions for UserViewSet."""
+
+ def _create_lockdown_flow_url(self, request: Request, user: User) -> str:
+ """Create a flow URL for account lockdown.
+
+ The request body selects the target before the flow starts. The API
+ pre-plans the lockdown flow with the target as the pending user, so the
+ account lockdown stage can use the normal flow context.
+ """
+ flow = request._request.brand.flow_lockdown
+ if flow is None:
+ raise ValidationError({"non_field_errors": [_("No lockdown flow configured.")]})
+ planner = FlowPlanner(flow)
+ planner.use_cache = False
+ try:
+ plan = planner.plan(request._request, {PLAN_CONTEXT_PENDING_USER: user})
+ except EmptyFlowException, FlowNonApplicableException:
+ raise ValidationError(
+ {"non_field_errors": [_("Lockdown flow is not applicable.")]}
+ ) from None
+ return plan.to_redirect(request._request, flow).url
+
+ @extend_schema(
+ description=_("Choose the target account, then return a flow link."),
+ request=UserAccountLockdownSerializer,
+ responses={
+ "200": OpenApiResponse(
+ response=LinkSerializer,
+ examples=[
+ OpenApiExample(
+ "Lockdown flow URL",
+ value={
+ "link": "https://example.invalid/if/flow/default-account-lockdown/",
+ },
+ response_only=True,
+ status_codes=["200"],
+ )
+ ],
+ ),
+ "400": OpenApiResponse(
+ description=_("No lockdown flow configured or the flow is not applicable")
+ ),
+ "403": OpenApiResponse(
+ description=_("Permission denied (when targeting another user)")
+ ),
+ },
+ )
+ @action(
+ detail=False,
+ methods=["POST"],
+ permission_classes=[IsAuthenticated],
+ url_path="account_lockdown",
+ )
+ @validate(UserAccountLockdownSerializer)
+ @enterprise_action
+ def account_lockdown(self, request: Request, body: UserAccountLockdownSerializer) -> Response:
+ """Trigger account lockdown for a user.
+
+ If no user is specified, locks the current user (self-service).
+ When targeting another user, admin permissions are required.
+
+ Returns a flow link for the frontend to follow. The flow is pre-planned
+ with the target user as pending user for the lockdown stage.
+ """
+ user = body.validated_data.get("user") or request.user
+
+ if not can_lock_user(request.user, user):
+ LOGGER.debug("Permission denied for account lockdown", user=request.user)
+ self.permission_denied(request)
+
+ flow_url = self._create_lockdown_flow_url(request, user)
+ LOGGER.debug("Returning lockdown flow URL", flow_url=flow_url, user=user.username)
+ return Response({"link": flow_url})
diff --git a/authentik/enterprise/stages/account_lockdown/apps.py b/authentik/enterprise/stages/account_lockdown/apps.py
new file mode 100644
index 0000000000..c3060cc5cf
--- /dev/null
+++ b/authentik/enterprise/stages/account_lockdown/apps.py
@@ -0,0 +1,12 @@
+"""authentik account lockdown stage app config"""
+
+from authentik.enterprise.apps import EnterpriseConfig
+
+
+class AuthentikEnterpriseStageAccountLockdownConfig(EnterpriseConfig):
+ """authentik account lockdown stage config"""
+
+ name = "authentik.enterprise.stages.account_lockdown"
+ label = "authentik_stages_account_lockdown"
+ verbose_name = "authentik Enterprise.Stages.Account Lockdown"
+ default = True
diff --git a/authentik/enterprise/stages/account_lockdown/migrations/0001_initial.py b/authentik/enterprise/stages/account_lockdown/migrations/0001_initial.py
new file mode 100644
index 0000000000..390af91557
--- /dev/null
+++ b/authentik/enterprise/stages/account_lockdown/migrations/0001_initial.py
@@ -0,0 +1,74 @@
+# Generated by Django 5.2.13 on 2026-04-19 21:56
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ ("authentik_flows", "0031_alter_flow_layout"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="AccountLockdownStage",
+ fields=[
+ (
+ "stage_ptr",
+ models.OneToOneField(
+ auto_created=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ serialize=False,
+ to="authentik_flows.stage",
+ ),
+ ),
+ (
+ "deactivate_user",
+ models.BooleanField(
+ default=True,
+ help_text="Deactivate the user account (set is_active to False)",
+ ),
+ ),
+ (
+ "set_unusable_password",
+ models.BooleanField(
+ default=True, help_text="Set an unusable password for the user"
+ ),
+ ),
+ (
+ "delete_sessions",
+ models.BooleanField(
+ default=True, help_text="Delete all active sessions for the user"
+ ),
+ ),
+ (
+ "revoke_tokens",
+ models.BooleanField(
+ default=True,
+ help_text="Revoke all tokens for the user (API, app password, recovery, verification, OAuth)",
+ ),
+ ),
+ (
+ "self_service_completion_flow",
+ models.ForeignKey(
+ blank=True,
+ help_text="Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted.",
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="account_lockdown_stages",
+ to="authentik_flows.flow",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Account Lockdown Stage",
+ "verbose_name_plural": "Account Lockdown Stages",
+ },
+ bases=("authentik_flows.stage",),
+ ),
+ ]
diff --git a/authentik/enterprise/stages/account_lockdown/migrations/__init__.py b/authentik/enterprise/stages/account_lockdown/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/authentik/enterprise/stages/account_lockdown/models.py b/authentik/enterprise/stages/account_lockdown/models.py
new file mode 100644
index 0000000000..3da03ed851
--- /dev/null
+++ b/authentik/enterprise/stages/account_lockdown/models.py
@@ -0,0 +1,62 @@
+"""Account lockdown stage models"""
+
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from django.views import View
+from rest_framework.serializers import BaseSerializer
+
+from authentik.flows.models import Stage
+
+
+class AccountLockdownStage(Stage):
+ """Lock down a target user account."""
+
+ deactivate_user = models.BooleanField(
+ default=True,
+ help_text=_("Deactivate the user account (set is_active to False)"),
+ )
+ set_unusable_password = models.BooleanField(
+ default=True,
+ help_text=_("Set an unusable password for the user"),
+ )
+ delete_sessions = models.BooleanField(
+ default=True,
+ help_text=_("Delete all active sessions for the user"),
+ )
+ revoke_tokens = models.BooleanField(
+ default=True,
+ help_text=_(
+ "Revoke all tokens for the user (API, app password, recovery, verification, OAuth)"
+ ),
+ )
+ self_service_completion_flow = models.ForeignKey(
+ "authentik_flows.Flow",
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="account_lockdown_stages",
+ help_text=_(
+ "Flow to redirect users to after self-service lockdown. "
+ "This flow should not require authentication since the user's session is deleted."
+ ),
+ )
+
+ @property
+ def serializer(self) -> type[BaseSerializer]:
+ from authentik.enterprise.stages.account_lockdown.api import AccountLockdownStageSerializer
+
+ return AccountLockdownStageSerializer
+
+ @property
+ def view(self) -> type[View]:
+ from authentik.enterprise.stages.account_lockdown.stage import AccountLockdownStageView
+
+ return AccountLockdownStageView
+
+ @property
+ def component(self) -> str:
+ return "ak-stage-account-lockdown-form"
+
+ class Meta:
+ verbose_name = _("Account Lockdown Stage")
+ verbose_name_plural = _("Account Lockdown Stages")
diff --git a/authentik/enterprise/stages/account_lockdown/stage.py b/authentik/enterprise/stages/account_lockdown/stage.py
new file mode 100644
index 0000000000..282d2cfec6
--- /dev/null
+++ b/authentik/enterprise/stages/account_lockdown/stage.py
@@ -0,0 +1,345 @@
+"""Account lockdown stage logic"""
+
+from django.apps import apps
+from django.core.exceptions import FieldDoesNotExist
+from django.db.models import Model, QuerySet
+from django.db.models.query_utils import Q
+from django.db.transaction import atomic
+from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+from dramatiq.actor import Actor
+from dramatiq.composition import group
+from dramatiq.results.errors import ResultTimeout
+
+from authentik.core.models import (
+ AuthenticatedSession,
+ ExpiringModel,
+ Session,
+ Token,
+ User,
+ UserTypes,
+)
+from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
+from authentik.events.models import Event, EventAction
+from authentik.flows.stage import StageView
+from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
+from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
+from authentik.lib.utils.reflection import class_to_path
+from authentik.lib.utils.time import timedelta_from_string
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+PLAN_CONTEXT_LOCKDOWN_REASON = "lockdown_reason"
+LOCKDOWN_EVENT_ACTION_ID = "account_lockdown"
+
+TARGET_REQUIRED_MESSAGE = _("No target user specified for account lockdown")
+PERMISSION_DENIED_MESSAGE = _("You do not have permission to lock down this account.")
+ACCOUNT_LOCKDOWN_FAILED_MESSAGE = _("Account lockdown failed for this account.")
+SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE = _(
+ "Self-service account lockdown requires a completion flow."
+)
+
+
+def get_lockdown_target_users() -> QuerySet[User]:
+ """Return users that can be targeted by account lockdown."""
+ return User.objects.exclude_anonymous().exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
+
+
+def _get_model_field(model: type[Model], field_name: str):
+ """Get a model field by name, if present."""
+ try:
+ return model._meta.get_field(field_name)
+ except FieldDoesNotExist:
+ return None
+
+
+def _has_user_field(model: type[Model]) -> bool:
+ """Check if a model has a direct user foreign key."""
+ field = _get_model_field(model, "user")
+ return bool(field and getattr(field, "remote_field", None) and field.remote_field.model is User)
+
+
+def _has_authenticated_session_field(model: type[Model]) -> bool:
+ """Check if a model is linked to an authenticated session."""
+ field = _get_model_field(model, "session")
+ return bool(
+ field
+ and getattr(field, "remote_field", None)
+ and field.remote_field.model is AuthenticatedSession
+ )
+
+
+def _has_provider_field(model: type[Model]) -> bool:
+ """Check if a model is linked to a provider."""
+ return _get_model_field(model, "provider") is not None
+
+
+def get_lockdown_token_models() -> tuple[type[Model], ...]:
+ """Return token, grant, and provider session models removed by account lockdown."""
+ token_models: list[type[Model]] = []
+ for model in apps.get_models():
+ if model._meta.abstract or not issubclass(model, ExpiringModel):
+ continue
+ if model is Token:
+ token_models.append(model)
+ elif _has_user_field(model) and (
+ _has_provider_field(model) or _has_authenticated_session_field(model)
+ ):
+ token_models.append(model)
+ elif _has_authenticated_session_field(model):
+ token_models.append(model)
+ return tuple(token_models)
+
+
+def get_lockdown_token_queryset(model: type[Model], user: User) -> QuerySet:
+ """Return account lockdown artifacts for a model and user."""
+ manager = model.objects.including_expired()
+ if _has_user_field(model):
+ return manager.filter(user=user)
+ return manager.filter(session__user=user)
+
+
+def can_lock_user(actor, user: User) -> bool:
+ """Check whether the actor may lock the target user."""
+ if not actor.is_authenticated:
+ return False
+ if user.pk == actor.pk:
+ return True
+ return actor.has_perm("authentik_core.change_user", user)
+
+
+def get_outgoing_sync_tasks() -> tuple[tuple[type[OutgoingSyncProvider], Actor], ...]:
+ """Return outgoing sync provider types and their direct sync tasks."""
+ from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
+ from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync_direct
+ from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
+ from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync_direct
+ from authentik.providers.scim.models import SCIMProvider
+ from authentik.providers.scim.tasks import scim_sync_direct
+
+ return (
+ (SCIMProvider, scim_sync_direct),
+ (GoogleWorkspaceProvider, google_workspace_sync_direct),
+ (MicrosoftEntraProvider, microsoft_entra_sync_direct),
+ )
+
+
+class AccountLockdownStageView(StageView):
+ """Execute account lockdown actions on the target user."""
+
+ def is_self_service(self, request: HttpRequest, user: User) -> bool:
+ """Check whether the currently authenticated user is locking their own account."""
+ return request.user.is_authenticated and user.pk == request.user.pk
+
+ def get_reason(self) -> str:
+ """Get the lockdown reason from the plan context.
+
+ Priority:
+ 1. prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
+ 2. PLAN_CONTEXT_LOCKDOWN_REASON (explicitly set)
+ 3. Empty string as fallback
+ """
+ prompt_data = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
+ if PLAN_CONTEXT_LOCKDOWN_REASON in prompt_data:
+ return prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
+ return self.executor.plan.context.get(PLAN_CONTEXT_LOCKDOWN_REASON, "")
+
+ def _apply_lockdown_actions(self, stage: AccountLockdownStage, user: User) -> None:
+ """Apply the configured account changes to the target user."""
+ if stage.deactivate_user:
+ user.is_active = False
+ if stage.set_unusable_password:
+ user.set_unusable_password()
+ if stage.deactivate_user:
+ with sync_outgoing_inhibit_dispatch():
+ user.save()
+ return
+ user.save()
+
+ def _sync_deactivated_user_to_outgoing_providers(self, user: User) -> None:
+ """Synchronize a deactivated user to outgoing sync providers."""
+ messages = []
+ wait_timeout = 0
+ model = class_to_path(User)
+ provider_filter = Q(backchannel_application__isnull=False) | Q(application__isnull=False)
+
+ for provider_model, task_sync_direct in get_outgoing_sync_tasks():
+ for provider in provider_model.objects.filter(provider_filter):
+ time_limit = int(
+ timedelta_from_string(provider.sync_page_timeout).total_seconds() * 1000
+ )
+ messages.append(
+ task_sync_direct.message_with_options(
+ args=(model, user.pk, provider.pk),
+ rel_obj=provider,
+ time_limit=time_limit,
+ uid=f"{provider.name}:user:{user.pk}:direct",
+ )
+ )
+ wait_timeout += time_limit
+
+ if not messages:
+ return
+ try:
+ group(messages).run().wait(timeout=wait_timeout)
+ except ResultTimeout:
+ self.logger.warning(
+ "Timed out waiting for outgoing sync tasks; tasks remain queued",
+ user=user.username,
+ timeout=wait_timeout,
+ )
+
+ def _get_lockdown_artifact_querysets(
+ self, stage: AccountLockdownStage, user: User
+ ) -> tuple[QuerySet, ...]:
+ """Return the configured sessions and tokens targeted by lockdown."""
+ querysets: list[QuerySet] = []
+ if stage.delete_sessions:
+ querysets.append(Session.objects.filter(authenticatedsession__user=user))
+ if stage.revoke_tokens:
+ querysets.extend(
+ get_lockdown_token_queryset(model, user) for model in get_lockdown_token_models()
+ )
+ return tuple(querysets)
+
+ def _delete_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> None:
+ """Delete sessions and tokens selected by the lockdown configuration."""
+ for queryset in self._get_lockdown_artifact_querysets(stage, user):
+ queryset.delete()
+
+ def _has_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> bool:
+ """Check whether there are still sessions or tokens to remove."""
+ return any(
+ queryset.exists() for queryset in self._get_lockdown_artifact_querysets(stage, user)
+ )
+
+ def _emit_lockdown_event(self, request: HttpRequest, user: User, reason: str) -> None:
+ """Emit the audit event for a completed lockdown."""
+ # Emit the audit event after the transaction commits. If event creation
+ # fails here, dispatch() would otherwise treat the whole lockdown as
+ # failed even though the account changes have already been committed.
+ try:
+ Event.new(
+ EventAction.USER_WRITE,
+ action_id=LOCKDOWN_EVENT_ACTION_ID,
+ reason=reason,
+ affected_user=user.username,
+ ).from_http(request)
+ except Exception as exc: # noqa: BLE001
+ # Event emission should not make the lockdown itself fail.
+ self.logger.warning(
+ "Failed to emit account lockdown event",
+ user=user.username,
+ exc=exc,
+ )
+
+ def _lockdown_user(
+ self,
+ request: HttpRequest,
+ stage: AccountLockdownStage,
+ user: User,
+ reason: str,
+ ) -> None:
+ """Execute lockdown actions on a single user."""
+ with atomic():
+ user = User.objects.get(pk=user.pk)
+ self._apply_lockdown_actions(stage, user)
+ self._delete_lockdown_artifacts(stage, user)
+
+ # These additional checks/deletes are done to prevent a timing attack that creates tokens
+ # with a compromised token that is simultaneously being deleted.
+ while self._has_lockdown_artifacts(stage, user):
+ with atomic():
+ self._delete_lockdown_artifacts(stage, user)
+
+ if stage.deactivate_user:
+ try:
+ self._sync_deactivated_user_to_outgoing_providers(user)
+ except Exception as exc: # noqa: BLE001
+ # Local lockdown has already committed. Provider sync failures
+ # must not reopen access or mark the lockdown itself as failed.
+ self.logger.warning(
+ "Failed to sync account lockdown deactivation to outgoing providers",
+ user=user.username,
+ exc=exc,
+ )
+ self._emit_lockdown_event(request, user, reason)
+
+ def dispatch(self, request: HttpRequest) -> HttpResponse:
+ """Execute account lockdown actions."""
+ self.request = request
+ stage: AccountLockdownStage = self.executor.current_stage
+
+ pending_user = self.get_pending_user()
+ if not pending_user.is_authenticated:
+ self.logger.warning("No target user found for account lockdown")
+ return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
+ user = get_lockdown_target_users().filter(pk=pending_user.pk).first()
+ if user is None:
+ self.logger.warning("Target user is not eligible for account lockdown")
+ return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
+ if not can_lock_user(request.user, user):
+ self.logger.warning(
+ "Permission denied for account lockdown",
+ actor=getattr(request.user, "username", None),
+ target=user.username,
+ )
+ return self.executor.stage_invalid(PERMISSION_DENIED_MESSAGE)
+
+ reason = self.get_reason()
+ self_service = self.is_self_service(request, user)
+ if self_service and stage.delete_sessions and not stage.self_service_completion_flow:
+ self.logger.warning("No completion flow configured for self-service account lockdown")
+ return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)
+
+ self.logger.info(
+ "Executing account lockdown",
+ user=user.username,
+ reason=reason,
+ self_service=self_service,
+ deactivate_user=stage.deactivate_user,
+ set_unusable_password=stage.set_unusable_password,
+ delete_sessions=stage.delete_sessions,
+ revoke_tokens=stage.revoke_tokens,
+ )
+
+ try:
+ self._lockdown_user(request, stage, user, reason)
+ self.logger.info("Account lockdown completed", user=user.username)
+ except Exception as exc: # noqa: BLE001
+ # Convert unexpected lockdown errors to a flow-stage failure instead
+ # of leaking an exception through the flow executor.
+ self.logger.warning("Account lockdown failed", user=user.username, exc=exc)
+ return self.executor.stage_invalid(ACCOUNT_LOCKDOWN_FAILED_MESSAGE)
+
+ if self_service:
+ if stage.delete_sessions:
+ return self._self_service_completion_response(request)
+ return self.executor.stage_ok()
+
+ return self.executor.stage_ok()
+
+ def _self_service_completion_response(self, request: HttpRequest) -> HttpResponse:
+ """Redirect to completion flow after self-service lockdown.
+
+ Since all sessions are deleted, the user cannot continue in the flow.
+ Redirect them to an unauthenticated completion flow that shows the
+ lockdown message.
+
+ We use a direct HTTP redirect instead of a challenge because the
+ flow executor's challenge handling may try to access the session
+ which we just deleted.
+ """
+ stage: AccountLockdownStage = self.executor.current_stage
+ completion_flow = stage.self_service_completion_flow
+ if completion_flow:
+ # Flush the current request's session to prevent Django's session
+ # middleware from trying to save a deleted session
+ if hasattr(request, "session"):
+ request.session.flush()
+ redirect_to = reverse(
+ "authentik_core:if-flow",
+ kwargs={"flow_slug": completion_flow.slug},
+ )
+ return HttpResponseRedirect(redirect_to)
+ return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)
diff --git a/authentik/enterprise/stages/account_lockdown/tests/__init__.py b/authentik/enterprise/stages/account_lockdown/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/authentik/enterprise/stages/account_lockdown/tests/test_api.py b/authentik/enterprise/stages/account_lockdown/tests/test_api.py
new file mode 100644
index 0000000000..60e3724c48
--- /dev/null
+++ b/authentik/enterprise/stages/account_lockdown/tests/test_api.py
@@ -0,0 +1,148 @@
+"""Test Users Account Lockdown API"""
+
+from json import loads
+from unittest.mock import MagicMock, patch
+from urllib.parse import urlparse
+
+from django.urls import reverse
+from rest_framework.test import APITestCase
+
+from authentik.core.tests.utils import (
+ create_test_brand,
+ create_test_flow,
+ create_test_user,
+)
+from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
+from authentik.flows.models import FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
+from authentik.flows.views.executor import SESSION_KEY_PLAN
+from authentik.lib.generators import generate_id
+
+# Patch for enterprise license check
+patch_license = patch(
+ "authentik.enterprise.models.LicenseUsageStatus.is_valid",
+ MagicMock(return_value=True),
+)
+
+
+@patch_license
+class AccountLockdownAPITestCase(APITestCase):
+ """Shared helpers for account lockdown API tests."""
+
+ def setUp(self) -> None:
+ self.lockdown_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
+ self.lockdown_stage = AccountLockdownStage.objects.create(name=generate_id())
+ FlowStageBinding.objects.create(
+ target=self.lockdown_flow,
+ stage=self.lockdown_stage,
+ order=0,
+ )
+ self.brand = create_test_brand()
+ self.brand.flow_lockdown = self.lockdown_flow
+ self.brand.save()
+
+ def create_user_with_email(self):
+ """Create a regular user with a unique email address."""
+ user = create_test_user()
+ user.email = f"{generate_id()}@test.com"
+ user.save()
+ return user
+
+ def assert_redirect_targets(self, response, user):
+ """Assert that a response contains a pre-planned lockdown flow link for a user."""
+ self.assertEqual(response.status_code, 200)
+ body = loads(response.content)
+ self.assertIn(self.lockdown_flow.slug, body["link"])
+ self.assertEqual(urlparse(body["link"]).query, "")
+ plan = self.client.session[SESSION_KEY_PLAN]
+ self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER].pk, user.pk)
+
+ def assert_no_flow_configured(self, response):
+ """Assert that the API reports a missing lockdown flow."""
+ self.assertEqual(response.status_code, 400)
+ body = loads(response.content)
+ self.assertIn("No lockdown flow configured", body["non_field_errors"][0])
+
+
+@patch_license
+class TestUsersAccountLockdownAPI(AccountLockdownAPITestCase):
+ """Test Users Account Lockdown API"""
+
+ def setUp(self) -> None:
+ super().setUp()
+ self.actor = create_test_user()
+ self.user = self.create_user_with_email()
+
+ def test_account_lockdown_with_change_user_returns_redirect(self):
+ """Test that account lockdown allows users with change_user permission."""
+ self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
+ self.client.force_login(self.actor)
+
+ response = self.client.post(
+ reverse("authentik_api:user-account-lockdown"),
+ data={"user": self.user.pk},
+ format="json",
+ )
+
+ self.assert_redirect_targets(response, self.user)
+
+ def test_account_lockdown_no_flow_configured(self):
+ """Test account lockdown when no flow is configured"""
+ self.brand.flow_lockdown = None
+ self.brand.save()
+ self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
+ self.client.force_login(self.actor)
+
+ response = self.client.post(
+ reverse("authentik_api:user-account-lockdown"),
+ data={"user": self.user.pk},
+ format="json",
+ )
+
+ self.assert_no_flow_configured(response)
+
+ def test_account_lockdown_unauthenticated(self):
+ """Test account lockdown requires authentication"""
+ response = self.client.post(
+ reverse("authentik_api:user-account-lockdown"),
+ data={"user": self.user.pk},
+ format="json",
+ )
+
+ self.assertEqual(response.status_code, 403)
+
+ def test_account_lockdown_without_change_user_denied(self):
+ """Test account lockdown denies users without change_user permission."""
+ self.client.force_login(self.actor)
+
+ response = self.client.post(
+ reverse("authentik_api:user-account-lockdown"),
+ data={"user": self.user.pk},
+ format="json",
+ )
+
+ self.assertEqual(response.status_code, 403)
+
+ def test_account_lockdown_self_returns_redirect(self):
+ """Test successful self-service account lockdown returns a direct redirect."""
+ self.client.force_login(self.user)
+
+ response = self.client.post(
+ reverse("authentik_api:user-account-lockdown"),
+ data={},
+ format="json",
+ )
+
+ self.assert_redirect_targets(response, self.user)
+
+ def test_account_lockdown_self_target_without_change_user_returns_redirect(self):
+ """Test self-service does not require change_user permission."""
+ self.client.force_login(self.user)
+
+ response = self.client.post(
+ reverse("authentik_api:user-account-lockdown"),
+ data={"user": self.user.pk},
+ format="json",
+ )
+
+ self.assert_redirect_targets(response, self.user)
diff --git a/authentik/enterprise/stages/account_lockdown/tests/test_blueprint.py b/authentik/enterprise/stages/account_lockdown/tests/test_blueprint.py
new file mode 100644
index 0000000000..bd8772f9b3
--- /dev/null
+++ b/authentik/enterprise/stages/account_lockdown/tests/test_blueprint.py
@@ -0,0 +1,46 @@
+"""Tests for the packaged account-lockdown blueprint."""
+
+from unittest.mock import patch
+
+from django.test import TransactionTestCase
+
+from authentik.blueprints.models import BlueprintInstance
+from authentik.blueprints.v1.importer import Importer
+from authentik.blueprints.v1.tasks import blueprints_find, check_blueprint_v1_file
+from authentik.enterprise.license import LicenseKey
+from authentik.flows.models import Flow
+
+BLUEPRINT_PATH = "example/flow-default-account-lockdown.yaml"
+
+
+class TestAccountLockdownBlueprint(TransactionTestCase):
+ """Test the packaged account-lockdown blueprint behavior."""
+
+ def test_blueprint_is_not_auto_instantiated(self):
+ """Test the packaged blueprint is opt-in and skipped by discovery."""
+ BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).delete()
+ blueprint = next(item for item in blueprints_find() if item.path == BLUEPRINT_PATH)
+
+ check_blueprint_v1_file(blueprint)
+
+ self.assertFalse(BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).exists())
+
+ def test_blueprint_requires_licensed_context(self):
+ """Test manual import only creates flows when enterprise is licensed."""
+ content = BlueprintInstance(path=BLUEPRINT_PATH).retrieve()
+ license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
+
+ with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
+ importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": False})
+ valid, logs = importer.validate()
+ self.assertTrue(valid, logs)
+ self.assertTrue(importer.apply())
+ self.assertFalse(Flow.objects.filter(slug="default-account-lockdown").exists())
+ self.assertFalse(Flow.objects.filter(slug="default-account-lockdown-complete").exists())
+
+ importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": True})
+ valid, logs = importer.validate()
+ self.assertTrue(valid, logs)
+ self.assertTrue(importer.apply())
+ self.assertTrue(Flow.objects.filter(slug="default-account-lockdown").exists())
+ self.assertTrue(Flow.objects.filter(slug="default-account-lockdown-complete").exists())
diff --git a/authentik/enterprise/stages/account_lockdown/tests/test_stage.py b/authentik/enterprise/stages/account_lockdown/tests/test_stage.py
new file mode 100644
index 0000000000..9bbccddd4f
--- /dev/null
+++ b/authentik/enterprise/stages/account_lockdown/tests/test_stage.py
@@ -0,0 +1,627 @@
+"""Account lockdown stage tests"""
+
+import json
+from dataclasses import asdict
+from threading import Event as ThreadEvent
+from threading import Thread
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from django.db import connection
+from django.http import HttpResponse
+from django.test import TransactionTestCase
+from django.urls import reverse
+from django.utils import timezone
+from dramatiq.results.errors import ResultTimeout
+
+from authentik.core.models import AuthenticatedSession, Session, Token, TokenIntents
+from authentik.core.tests.utils import (
+ RequestFactory,
+ create_test_admin_user,
+ create_test_cert,
+ create_test_flow,
+ create_test_user,
+)
+from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
+from authentik.enterprise.stages.account_lockdown.stage import (
+ LOCKDOWN_EVENT_ACTION_ID,
+ PLAN_CONTEXT_LOCKDOWN_REASON,
+ AccountLockdownStageView,
+ can_lock_user,
+)
+from authentik.events.models import Event, EventAction
+from authentik.flows.markers import StageMarker
+from authentik.flows.models import FlowDesignation, FlowStageBinding
+from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
+from authentik.flows.tests import FlowTestCase
+from authentik.lib.generators import generate_id
+from authentik.lib.utils.reflection import class_to_path
+from authentik.providers.oauth2.id_token import IDToken
+from authentik.providers.oauth2.models import (
+ AccessToken,
+ AuthorizationCode,
+ DeviceToken,
+ OAuth2Provider,
+ RedirectURI,
+ RedirectURIMatchingMode,
+ RefreshToken,
+)
+from authentik.providers.saml.models import SAMLProvider, SAMLSession
+from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
+
+patch_enterprise_enabled = patch(
+ "authentik.enterprise.apps.AuthentikEnterpriseConfig.check_enabled",
+ return_value=True,
+)
+
+
+class AccountLockdownStageTestMixin:
+ """Shared setup helpers for account lockdown stage tests."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.patch_enterprise_enabled = patch_enterprise_enabled.start()
+ cls.patch_event_dispatch = patch("authentik.events.tasks.event_trigger_dispatch.send")
+ cls.patch_event_dispatch.start()
+ super().setUpClass()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.patch_event_dispatch.stop()
+ patch_enterprise_enabled.stop()
+ super().tearDownClass()
+
+ def setUp(self):
+ super().setUp()
+ self.user = create_test_admin_user()
+ self.target_user = create_test_admin_user()
+ self.flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
+ self.stage = AccountLockdownStage.objects.create(
+ name="lockdown",
+ )
+ self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
+ self.request_factory = RequestFactory()
+
+ def make_stage_view(self, plan: FlowPlan):
+ def _stage_ok():
+ return HttpResponse(status=204)
+
+ def _stage_invalid(_error_message=None):
+ return HttpResponse(status=400)
+
+ return AccountLockdownStageView(
+ SimpleNamespace(
+ plan=plan,
+ current_stage=self.stage,
+ current_binding=self.binding,
+ flow=self.flow,
+ stage_ok=_stage_ok,
+ stage_invalid=_stage_invalid,
+ )
+ )
+
+ def make_request(self, *, user=None, query=None):
+ return self.request_factory.post(
+ reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
+ query_params=query or {},
+ user=user,
+ )
+
+ def get_lockdown_event(self):
+ """Return the account-lockdown user-write event."""
+ return Event.objects.filter(
+ action=EventAction.USER_WRITE,
+ context__action_id=LOCKDOWN_EVENT_ACTION_ID,
+ ).first()
+
+
+class TestAccountLockdownStage(AccountLockdownStageTestMixin, FlowTestCase):
+ """Account lockdown stage tests"""
+
+ def test_lockdown_no_target(self):
+ """Test lockdown stage with no pending user fails"""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+
+ response = view.dispatch(self.make_request())
+
+ self.assertEqual(response.status_code, 400)
+
+ def test_lockdown_with_pending_user(self):
+ """Test lockdown stage with a pending target user."""
+ self.target_user.is_active = True
+ self.target_user.save()
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Security incident"
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
+ view = self.make_stage_view(plan)
+ request = self.make_request(user=self.user)
+
+ self.assertTrue(can_lock_user(request.user, self.target_user))
+ response = view.dispatch(request)
+
+ self.target_user.refresh_from_db()
+ self.assertFalse(self.target_user.is_active)
+ self.assertFalse(self.target_user.has_usable_password())
+ self.assertEqual(response.status_code, 204)
+
+ # Check event was created
+ event = self.get_lockdown_event()
+ self.assertIsNotNone(event)
+ self.assertEqual(event.context["action_id"], LOCKDOWN_EVENT_ACTION_ID)
+ self.assertEqual(event.context["reason"], "Security incident")
+ self.assertEqual(event.context["affected_user"], self.target_user.username)
+
+ def test_lockdown_with_pending_user_reason(self):
+ """Test lockdown stage with a pending target and explicit reason."""
+ self.target_user.is_active = True
+ self.target_user.save()
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Compromised account"
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
+ view = self.make_stage_view(plan)
+ request = self.make_request(user=self.user)
+
+ self.assertTrue(can_lock_user(request.user, self.target_user))
+ response = view.dispatch(request)
+
+ self.target_user.refresh_from_db()
+ self.assertFalse(self.target_user.is_active)
+ self.assertEqual(response.status_code, 204)
+
+ def test_lockdown_reason_from_prompt(self):
+ """Test lockdown stage reads the reason from prompt data."""
+ self.target_user.is_active = True
+ self.target_user.save()
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ plan.context[PLAN_CONTEXT_PROMPT] = {
+ PLAN_CONTEXT_LOCKDOWN_REASON: "User requested lockdown",
+ }
+ view = self.make_stage_view(plan)
+ request = self.make_request(user=self.user)
+ view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
+
+ event = self.get_lockdown_event()
+ self.assertIsNotNone(event)
+ self.assertEqual(event.context["reason"], "User requested lockdown")
+
+ def test_lockdown_event_failure_does_not_fail_self_service(self):
+ """Test lockdown still succeeds when event emission fails."""
+ self.stage.delete_sessions = False
+ self.stage.save()
+
+ self.target_user.is_active = True
+ self.target_user.save()
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
+ view = self.make_stage_view(plan)
+ request = self.make_request(user=self.target_user)
+
+ original_event_new = Event.new
+
+ def _event_new_side_effect(action, *args, **kwargs):
+ if (
+ action == EventAction.USER_WRITE
+ and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
+ ):
+ raise RuntimeError("simulated event failure")
+ return original_event_new(action, *args, **kwargs)
+
+ with patch(
+ "authentik.enterprise.stages.account_lockdown.stage.Event.new",
+ side_effect=_event_new_side_effect,
+ ):
+ view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
+
+ self.target_user.refresh_from_db()
+ self.assertFalse(self.target_user.is_active)
+
+ def test_dispatch_records_success_when_event_emission_fails(self):
+ """Test dispatch still completes if event emission fails."""
+ self.stage.delete_sessions = False
+ self.stage.save()
+
+ self.target_user.is_active = True
+ self.target_user.save()
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
+ view = self.make_stage_view(plan)
+ request = self.make_request(
+ user=self.target_user,
+ )
+
+ original_event_new = Event.new
+
+ def _event_new_side_effect(action, *args, **kwargs):
+ if (
+ action == EventAction.USER_WRITE
+ and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
+ ):
+ raise RuntimeError("simulated event failure")
+ return original_event_new(action, *args, **kwargs)
+
+ with patch(
+ "authentik.enterprise.stages.account_lockdown.stage.Event.new",
+ side_effect=_event_new_side_effect,
+ ):
+ response = view.dispatch(request)
+
+ self.target_user.refresh_from_db()
+ self.assertFalse(self.target_user.is_active)
+ self.assertEqual(response.status_code, 204)
+
+ def test_lockdown_self_service_redirects_to_completion_flow(self):
+ """Test self-service lockdown redirects to completion flow when sessions are deleted."""
+ completion_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
+ self.stage.self_service_completion_flow = completion_flow
+ self.stage.save()
+
+ self.target_user.is_active = True
+ self.target_user.save()
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+ request = self.make_request(user=self.target_user)
+ view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
+ response = view._self_service_completion_response(request)
+
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(
+ response.url,
+ reverse("authentik_core:if-flow", kwargs={"flow_slug": completion_flow.slug}),
+ )
+
+ def test_lockdown_self_service_requires_completion_flow(self):
+ """Test self-service lockdown fails before deleting sessions without a completion flow."""
+ self.stage.self_service_completion_flow = None
+ self.stage.save()
+
+ self.target_user.is_active = True
+ self.target_user.save()
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
+ view = self.make_stage_view(plan)
+ request = self.make_request(user=self.target_user)
+
+ response = view.dispatch(request)
+
+ self.assertEqual(response.status_code, 400)
+ self.target_user.refresh_from_db()
+ self.assertTrue(self.target_user.is_active)
+
+ def test_lockdown_denies_other_user_without_permission(self):
+ """Test lockdown stage rejects non-self requests without change_user permission."""
+ actor = create_test_user()
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
+ view = self.make_stage_view(plan)
+ request = self.make_request(user=actor)
+
+ self.assertFalse(can_lock_user(request.user, self.target_user))
+ response = view.dispatch(request)
+ self.assertEqual(response.status_code, 400)
+
+ def test_lockdown_revokes_tokens(self):
+ """Test lockdown stage revokes tokens"""
+ Token.objects.create(
+ user=self.target_user,
+ identifier="test-token",
+ intent=TokenIntents.INTENT_API,
+ key=generate_id(),
+ expiring=False,
+ )
+ self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+ view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
+
+ self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)
+
+ def test_lockdown_revokes_provider_tokens(self):
+ """Test lockdown stage revokes provider tokens and sessions."""
+ oauth_provider = OAuth2Provider.objects.create(
+ name=generate_id(),
+ authorization_flow=create_test_flow(),
+ redirect_uris=[
+ RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver/callback")
+ ],
+ signing_key=create_test_cert(),
+ )
+ saml_provider = SAMLProvider.objects.create(
+ name=generate_id(),
+ authorization_flow=create_test_flow(),
+ acs_url="https://sp.example.com/acs",
+ issuer_override="https://idp.example.com",
+ )
+ session = Session.objects.create(
+ session_key=generate_id(),
+ expires=timezone.now() + timezone.timedelta(hours=1),
+ last_ip="127.0.0.1",
+ )
+ auth_session = AuthenticatedSession.objects.create(
+ session=session,
+ user=self.target_user,
+ )
+ grant_kwargs = {
+ "provider": oauth_provider,
+ "user": self.target_user,
+ "auth_time": timezone.now(),
+ "_scope": "openid profile",
+ "expiring": False,
+ }
+ token_kwargs = grant_kwargs | {"_id_token": json.dumps(asdict(IDToken("foo", "bar")))}
+ AuthorizationCode.objects.create(
+ code=generate_id(),
+ session=auth_session,
+ **grant_kwargs,
+ )
+ AccessToken.objects.create(
+ token=generate_id(),
+ session=auth_session,
+ **token_kwargs,
+ )
+ RefreshToken.objects.create(
+ token=generate_id(),
+ session=auth_session,
+ **token_kwargs,
+ )
+ DeviceToken.objects.create(
+ provider=oauth_provider,
+ user=self.target_user,
+ session=auth_session,
+ _scope="openid profile",
+ expiring=False,
+ )
+ SAMLSession.objects.create(
+ provider=saml_provider,
+ user=self.target_user,
+ session=auth_session,
+ session_index=generate_id(),
+ name_id=self.target_user.email,
+ expires=timezone.now() + timezone.timedelta(hours=1),
+ expiring=True,
+ )
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+ view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
+
+ self.assertEqual(AuthorizationCode.objects.filter(user=self.target_user).count(), 0)
+ self.assertEqual(AccessToken.objects.filter(user=self.target_user).count(), 0)
+ self.assertEqual(RefreshToken.objects.filter(user=self.target_user).count(), 0)
+ self.assertEqual(DeviceToken.objects.filter(user=self.target_user).count(), 0)
+ self.assertEqual(SAMLSession.objects.filter(user=self.target_user).count(), 0)
+
+ def test_lockdown_selective_actions(self):
+ """Test lockdown stage with selective actions"""
+ self.stage.deactivate_user = True
+ self.stage.set_unusable_password = False
+ self.stage.delete_sessions = False
+ self.stage.revoke_tokens = False
+ self.stage.save()
+
+ self.target_user.is_active = True
+ self.target_user.set_password("testpassword")
+ self.target_user.save()
+
+ Token.objects.create(
+ user=self.target_user,
+ identifier="test-token",
+ intent=TokenIntents.INTENT_API,
+ key=generate_id(),
+ expiring=False,
+ )
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+ view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
+
+ self.target_user.refresh_from_db()
+ # User should be deactivated
+ self.assertFalse(self.target_user.is_active)
+ # Password should still be usable
+ self.assertTrue(self.target_user.has_usable_password())
+ # Token should still exist
+ self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
+
+ def test_lockdown_no_actions(self):
+ """Test lockdown stage with all actions disabled"""
+ self.stage.deactivate_user = False
+ self.stage.set_unusable_password = False
+ self.stage.delete_sessions = False
+ self.stage.revoke_tokens = False
+ self.stage.save()
+
+ self.target_user.is_active = True
+ self.target_user.set_password("testpassword")
+ self.target_user.save()
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+ view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
+
+ self.target_user.refresh_from_db()
+ # User should still be active
+ self.assertTrue(self.target_user.is_active)
+ # Password should still be usable
+ self.assertTrue(self.target_user.has_usable_password())
+ # Event should still be created
+ event = self.get_lockdown_event()
+ self.assertIsNotNone(event)
+
+ def test_lockdown_deactivation_inhibits_signal_dispatch_until_after_commit(self):
+ """Test lockdown queues explicit outgoing syncs after the deactivation transaction."""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+
+ with (
+ patch(
+ "authentik.enterprise.stages.account_lockdown.stage.sync_outgoing_inhibit_dispatch"
+ ) as inhibit,
+ patch.object(view, "_sync_deactivated_user_to_outgoing_providers") as sync_outgoing,
+ ):
+ view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
+
+ inhibit.assert_called_once()
+ sync_outgoing.assert_called_once()
+ synced_user = sync_outgoing.call_args.args[0]
+ self.assertEqual(synced_user.pk, self.target_user.pk)
+ self.assertFalse(synced_user.is_active)
+
+ def test_lockdown_waits_for_direct_outgoing_provider_syncs(self):
+ """Test direct outgoing sync tasks are enqueued and waited on."""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+ provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
+ task_sync_direct = MagicMock()
+ task_sync_direct.message_with_options.return_value = "direct-message"
+ provider_model = SimpleNamespace(
+ objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
+ )
+ task_group = MagicMock()
+
+ with (
+ patch(
+ "authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
+ return_value=((provider_model, task_sync_direct),),
+ ),
+ patch(
+ "authentik.enterprise.stages.account_lockdown.stage.group",
+ return_value=task_group,
+ ) as task_group_cls,
+ ):
+ view._sync_deactivated_user_to_outgoing_providers(self.target_user)
+
+ task_sync_direct.message_with_options.assert_called_once_with(
+ args=(class_to_path(type(self.target_user)), self.target_user.pk, provider.pk),
+ rel_obj=provider,
+ time_limit=5000,
+ uid=f"{provider.name}:user:{self.target_user.pk}:direct",
+ )
+ task_group_cls.assert_called_once_with(["direct-message"])
+ task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
+
+ def test_lockdown_outgoing_provider_sync_timeout_leaves_tasks_running(self):
+ """Test timeout while waiting for direct outgoing syncs does not fail lockdown."""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+ provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
+ task_sync_direct = MagicMock()
+ task_sync_direct.message_with_options.return_value = "direct-message"
+ provider_model = SimpleNamespace(
+ objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
+ )
+ task_group = MagicMock()
+ task_group.run.return_value.wait.side_effect = ResultTimeout("timed out")
+
+ with (
+ patch(
+ "authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
+ return_value=((provider_model, task_sync_direct),),
+ ),
+ patch(
+ "authentik.enterprise.stages.account_lockdown.stage.group",
+ return_value=task_group,
+ ),
+ ):
+ view._sync_deactivated_user_to_outgoing_providers(self.target_user)
+
+ task_group.run.assert_called_once_with()
+ task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
+
+ def test_lockdown_outgoing_provider_sync_failure_does_not_fail_lockdown(self):
+ """Test completed local lockdown still emits an event if outgoing sync fails."""
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+
+ with patch.object(
+ view,
+ "_sync_deactivated_user_to_outgoing_providers",
+ side_effect=ValueError("sync failed"),
+ ):
+ view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
+
+ self.target_user.refresh_from_db()
+ self.assertFalse(self.target_user.is_active)
+ event = self.get_lockdown_event()
+ self.assertIsNotNone(event)
+
+
+class TestAccountLockdownStageConcurrency(AccountLockdownStageTestMixin, TransactionTestCase):
+ """Account lockdown concurrency tests."""
+
+ def test_lockdown_retries_when_another_transaction_recreates_a_token(self):
+ """Lockdown should remove a token recreated before the retry check runs."""
+ Token.objects.create(
+ user=self.target_user,
+ identifier=f"initial-token-{generate_id()}",
+ intent=TokenIntents.INTENT_API,
+ key=generate_id(),
+ expiring=False,
+ )
+
+ plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
+ view = self.make_stage_view(plan)
+ original_has_artifacts = view._has_lockdown_artifacts
+ target_user = self.target_user
+ thread_ready = ThreadEvent()
+ start_create = ThreadEvent()
+ thread_done = ThreadEvent()
+ thread_errors = []
+
+ class TokenCreatorThread(Thread):
+ __test__ = False
+
+ def run(self):
+ try:
+ thread_ready.set()
+ if not start_create.wait(timeout=5):
+ thread_errors.append("timed out waiting to recreate token")
+ return
+ Token.objects.create(
+ user=target_user,
+ identifier=f"concurrent-token-{generate_id()}",
+ intent=TokenIntents.INTENT_API,
+ key=generate_id(),
+ expiring=False,
+ )
+ except Exception as exc: # noqa: BLE001
+ thread_errors.append(exc)
+ finally:
+ thread_done.set()
+ connection.close()
+
+ def has_artifacts_after_concurrent_create(stage, user):
+ if not start_create.is_set():
+ start_create.set()
+ self.assertTrue(
+ thread_done.wait(timeout=30),
+ (
+ "Concurrent token creation did not complete "
+ f"before retry check: {thread_errors}"
+ ),
+ )
+ return original_has_artifacts(stage, user)
+
+ creator = TokenCreatorThread()
+ with patch.object(
+ view, "_has_lockdown_artifacts", side_effect=has_artifacts_after_concurrent_create
+ ):
+ creator.start()
+ self.assertTrue(
+ thread_ready.wait(timeout=5),
+ "Concurrent token creation thread did not start",
+ )
+ view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
+ creator.join()
+
+ self.assertEqual(thread_errors, [])
+ self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)
diff --git a/authentik/enterprise/stages/account_lockdown/urls.py b/authentik/enterprise/stages/account_lockdown/urls.py
new file mode 100644
index 0000000000..6c656c1ef3
--- /dev/null
+++ b/authentik/enterprise/stages/account_lockdown/urls.py
@@ -0,0 +1,5 @@
+"""API URLs"""
+
+from authentik.enterprise.stages.account_lockdown.api import AccountLockdownStageViewSet
+
+api_urlpatterns = [("stages/account_lockdown", AccountLockdownStageViewSet)]
diff --git a/authentik/root/settings.py b/authentik/root/settings.py
index cfb5f7acc0..91916fb290 100644
--- a/authentik/root/settings.py
+++ b/authentik/root/settings.py
@@ -172,6 +172,7 @@ SPECTACULAR_SETTINGS = {
},
"ENUM_NAME_OVERRIDES": {
"AppEnum": "authentik.lib.api.Apps",
+ "AuthenticationEnum": "authentik.flows.models.FlowAuthenticationRequirement",
"ConsentModeEnum": "authentik.stages.consent.models.ConsentMode",
"CountryCodeEnum": "django_countries.countries",
"DeviceClassesEnum": "authentik.stages.authenticator_validate.models.DeviceClasses",
diff --git a/authentik/stages/prompt/migrations/0012_alter_prompt_type.py b/authentik/stages/prompt/migrations/0012_alter_prompt_type.py
new file mode 100644
index 0000000000..0ee2d9f44a
--- /dev/null
+++ b/authentik/stages/prompt/migrations/0012_alter_prompt_type.py
@@ -0,0 +1,64 @@
+# Generated by Django 5.2.12 on 2026-03-14 02:58
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ (
+ "authentik_stages_prompt",
+ "0011_prompt_initial_value_prompt_initial_value_expression_and_more",
+ ),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="prompt",
+ name="type",
+ field=models.CharField(
+ choices=[
+ ("text", "Text: Simple Text input"),
+ ("text_area", "Text area: Multiline Text Input."),
+ (
+ "text_read_only",
+ "Text (read-only): Simple Text input, but cannot be edited.",
+ ),
+ (
+ "text_area_read_only",
+ "Text area (read-only): Multiline Text input, but cannot be edited.",
+ ),
+ (
+ "username",
+ "Username: Same as Text input, but checks for and prevents duplicate usernames.",
+ ),
+ ("email", "Email: Text field with Email type."),
+ (
+ "password",
+ "Password: Masked input, multiple inputs of this type on the same prompt need to be identical.",
+ ),
+ ("number", "Number"),
+ ("checkbox", "Checkbox"),
+ (
+ "radio-button-group",
+ "Fixed choice field rendered as a group of radio buttons.",
+ ),
+ ("dropdown", "Fixed choice field rendered as a dropdown."),
+ ("date", "Date"),
+ ("date-time", "Date Time"),
+ (
+ "file",
+ "File: File upload for arbitrary files. File content will be available in flow context as data-URI",
+ ),
+ ("separator", "Separator: Static Separator Line"),
+ ("hidden", "Hidden: Hidden field, can be used to insert data into form."),
+ ("static", "Static: Static value, displayed as-is."),
+ ("alert_info", "Alert (Info): Static alert box with info styling"),
+ ("alert_warning", "Alert (Warning): Static alert box with warning styling"),
+ ("alert_danger", "Alert (Danger): Static alert box with danger styling"),
+ ("ak-locale", "authentik: Selection of locales authentik supports"),
+ ],
+ max_length=100,
+ ),
+ ),
+ ]
diff --git a/authentik/stages/prompt/models.py b/authentik/stages/prompt/models.py
index 317e210384..f4c37031b0 100644
--- a/authentik/stages/prompt/models.py
+++ b/authentik/stages/prompt/models.py
@@ -87,6 +87,11 @@ class FieldTypes(models.TextChoices):
HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.")
STATIC = "static", _("Static: Static value, displayed as-is.")
+ # Alert box types for displaying styled messages
+ ALERT_INFO = "alert_info", _("Alert (Info): Static alert box with info styling")
+ ALERT_WARNING = "alert_warning", _("Alert (Warning): Static alert box with warning styling")
+ ALERT_DANGER = "alert_danger", _("Alert (Danger): Static alert box with danger styling")
+
AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
@@ -299,7 +304,12 @@ class Prompt(SerializerModel):
field_class = HiddenField
kwargs["required"] = False
kwargs["default"] = self.placeholder
- case FieldTypes.STATIC:
+ case (
+ FieldTypes.STATIC
+ | FieldTypes.ALERT_INFO
+ | FieldTypes.ALERT_WARNING
+ | FieldTypes.ALERT_DANGER
+ ):
kwargs["default"] = self.placeholder
kwargs["required"] = False
kwargs["label"] = ""
diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py
index ecc048f14f..f9061c5845 100644
--- a/authentik/stages/prompt/stage.py
+++ b/authentik/stages/prompt/stage.py
@@ -124,6 +124,9 @@ class PromptChallengeResponse(ChallengeResponse):
type__in=[
FieldTypes.HIDDEN,
FieldTypes.STATIC,
+ FieldTypes.ALERT_INFO,
+ FieldTypes.ALERT_WARNING,
+ FieldTypes.ALERT_DANGER,
FieldTypes.TEXT_READ_ONLY,
FieldTypes.TEXT_AREA_READ_ONLY,
]
diff --git a/authentik/stages/prompt/tests.py b/authentik/stages/prompt/tests.py
index 3273b4fae0..6e6f7f5f5c 100644
--- a/authentik/stages/prompt/tests.py
+++ b/authentik/stages/prompt/tests.py
@@ -330,10 +330,20 @@ class TestPromptStage(FlowTestCase):
def test_static_hidden_overwrite(self):
"""Test that static and hidden fields ignore any value sent to them"""
+ alert_prompt = Prompt.objects.create(
+ name=generate_id(),
+ field_key="alert_prompt",
+ type=FieldTypes.ALERT_INFO,
+ required=True,
+ placeholder="alert fallback",
+ initial_value="alert content",
+ )
+ self.stage.fields.add(alert_prompt)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PROMPT] = {"hidden_prompt": "hidden"}
self.prompt_data["hidden_prompt"] = "foo"
self.prompt_data["static_prompt"] = "foo"
+ self.prompt_data["alert_prompt"] = "foo"
challenge_response = PromptChallengeResponse(
None, stage_instance=self.stage, plan=plan, data=self.prompt_data, stage=self.stage_view
)
@@ -341,6 +351,7 @@ class TestPromptStage(FlowTestCase):
self.assertNotEqual(challenge_response.validated_data["hidden_prompt"], "foo")
self.assertEqual(challenge_response.validated_data["hidden_prompt"], "hidden")
self.assertNotEqual(challenge_response.validated_data["static_prompt"], "foo")
+ self.assertEqual(challenge_response.validated_data["alert_prompt"], "alert content")
def test_prompt_placeholder(self):
"""Test placeholder and expression"""
diff --git a/blueprints/example/flow-default-account-lockdown.yaml b/blueprints/example/flow-default-account-lockdown.yaml
new file mode 100644
index 0000000000..6d49f065c1
--- /dev/null
+++ b/blueprints/example/flow-default-account-lockdown.yaml
@@ -0,0 +1,306 @@
+version: 1
+metadata:
+ name: Example - Account lockdown flow
+ labels:
+ blueprints.goauthentik.io/instantiate: "false"
+entries:
+ flows:
+ # Main lockdown flow - requires authentication
+ - conditions:
+ - !Context goauthentik.io/enterprise/licensed
+ attrs:
+ designation: stage_configuration
+ name: Account Lockdown
+ title: Lock Account
+ authentication: require_authenticated
+ identifiers:
+ slug: default-account-lockdown
+ model: authentik_flows.flow
+ id: flow
+ # Self-service completion flow - no authentication required
+ - conditions:
+ - !Context goauthentik.io/enterprise/licensed
+ attrs:
+ designation: stage_configuration
+ name: Account Lockdown Complete
+ title: Account Locked
+ authentication: none
+ identifiers:
+ slug: default-account-lockdown-complete
+ model: authentik_flows.flow
+ id: completion-flow
+ prompt_fields:
+ # Warning field - danger alert box (content varies based on self-service vs admin)
+ - conditions:
+ - !Context goauthentik.io/enterprise/licensed
+ attrs:
+ order: 50
+ initial_value: |
+ target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
+ current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
+ is_self_service = not target_uuid or target_uuid == current_user_uuid
+ pending_user = None
+ if target_uuid and not is_self_service:
+ from authentik.core.models import User
+
+ pending_user = User.objects.filter(pk=target_uuid).first()
+ if is_self_service:
+ return (
+ "
You are about to lock down your own account.
"
+ "
This is an emergency action for cutting off access to your account right away.
"
+ "
This will immediately:
"
+ "
"
+ "
Invalidate your password - Your password will be set to a random value "
+ "and cannot be recovered
"
+ "
Deactivate your account - Your account will be disabled
"
+ "
Terminate all your sessions - You will be logged out everywhere
"
+ "
Revoke all your tokens - All your API, app password, recovery, "
+ "verification, and OAuth2 tokens and grants will be revoked
"
+ "
"
+ "
This action cannot be easily undone.
"
+ )
+
+ from django.utils.html import escape
+
+ if pending_user:
+ email = escape(pending_user.email or pending_user.name or "No email")
+ user_html = f"
{escape(pending_user.username)} ({email})
"
+ else:
+ user_html = "
the account selected when this one-time lockdown link was created
"
+
+ return (
+ "
You are about to lock down the following account:
"
+ f"{user_html}"
+ "
This is an emergency action for cutting off access to the account right away. "
+ "It does not lock the administrator who opened this page.
"
+ "
This will immediately:
"
+ "
"
+ "
Invalidate the user's password
"
+ "
Deactivate the user
"
+ "
Terminate all sessions - All active sessions will be ended
"
+ "
Revoke all tokens - All API, app password, recovery, verification, and OAuth2 "
+ "tokens and grants will be revoked
"
+ "
"
+ "
This action cannot be easily undone.
"
+ )
+ initial_value_expression: true
+ required: false
+ type: alert_danger
+ field_key: lockdown_warning
+ label: Warning
+ sub_text: ""
+ identifiers:
+ name: default-account-lockdown-field-warning
+ id: prompt-field-warning
+ model: authentik_stages_prompt.prompt
+ # Info field - when to use lockdown (content varies based on self-service vs admin)
+ - conditions:
+ - !Context goauthentik.io/enterprise/licensed
+ attrs:
+ order: 100
+ initial_value: |
+ target_uuid = (http_request.session.get("authentik/flows/get", {}) or {}).get("user_uuid")
+ current_user_uuid = str(getattr(user, "pk", "") or getattr(http_request.user, "pk", ""))
+ is_self_service = not target_uuid or target_uuid == current_user_uuid
+ if is_self_service:
+ info = (
+ "Use this if you no longer trust your current password or sessions. "
+ "After lockdown, you will need help from your administrator or security team to regain access."
+ )
+ else:
+ info = (
+ "Use this for incident response on the listed account, for example after a compromise report "
+ "or suspicious activity. The reason you enter below will be recorded in the audit log."
+ )
+ return (
+ f"
You have been logged out of all sessions and your password has been invalidated.
"
+ "
To regain access to your account, please contact your IT administrator or security team.
"
+ )
+ initial_value_expression: true
+ required: false
+ type: alert_warning
+ field_key: self_lockdown_complete
+ label: Account locked
+ sub_text: ""
+ identifiers:
+ name: default-account-lockdown-self-field-complete
+ id: self-prompt-field-complete
+ model: authentik_stages_prompt.prompt
+ # Prompt stage for self-service completion (unauthenticated)
+ - conditions:
+ - !Context goauthentik.io/enterprise/licensed
+ attrs:
+ fields:
+ - !KeyOf self-prompt-field-complete
+ identifiers:
+ name: default-account-lockdown-self-complete-prompt
+ id: default-account-lockdown-self-complete-prompt
+ model: authentik_stages_prompt.promptstage
+ # Bind self-service completion stage to the completion flow
+ - conditions:
+ - !Context goauthentik.io/enterprise/licensed
+ identifiers:
+ order: 0
+ stage: !KeyOf default-account-lockdown-self-complete-prompt
+ target: !Find [authentik_flows.flow, [slug, default-account-lockdown-complete]]
+ model: authentik_flows.flowstagebinding
diff --git a/blueprints/schema.json b/blueprints/schema.json
index 7d9463cc7f..22938bfcc1 100644
--- a/blueprints/schema.json
+++ b/blueprints/schema.json
@@ -1216,6 +1216,46 @@
}
}
},
+ {
+ "type": "object",
+ "required": [
+ "model",
+ "identifiers"
+ ],
+ "properties": {
+ "model": {
+ "const": "authentik_stages_account_lockdown.accountlockdownstage"
+ },
+ "id": {
+ "type": "string"
+ },
+ "state": {
+ "type": "string",
+ "enum": [
+ "absent",
+ "created",
+ "must_created",
+ "present"
+ ],
+ "default": "present"
+ },
+ "conditions": {
+ "type": "array",
+ "items": {
+ "type": "boolean"
+ }
+ },
+ "permissions": {
+ "$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage_permissions"
+ },
+ "attrs": {
+ "$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage"
+ },
+ "identifiers": {
+ "$ref": "#/$defs/model_authentik_stages_account_lockdown.accountlockdownstage"
+ }
+ }
+ },
{
"type": "object",
"required": [
@@ -5100,6 +5140,11 @@
"format": "uuid",
"title": "Flow device code"
},
+ "flow_lockdown": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Flow lockdown"
+ },
"default_application": {
"type": "string",
"format": "uuid",
@@ -6094,6 +6139,10 @@
"authentik_sources_telegram.view_telegramsource",
"authentik_sources_telegram.view_telegramsourcepropertymapping",
"authentik_sources_telegram.view_usertelegramsourceconnection",
+ "authentik_stages_account_lockdown.add_accountlockdownstage",
+ "authentik_stages_account_lockdown.change_accountlockdownstage",
+ "authentik_stages_account_lockdown.delete_accountlockdownstage",
+ "authentik_stages_account_lockdown.view_accountlockdownstage",
"authentik_stages_authenticator_duo.add_authenticatorduostage",
"authentik_stages_authenticator_duo.add_duodevice",
"authentik_stages_authenticator_duo.change_authenticatorduostage",
@@ -7757,6 +7806,69 @@
}
}
},
+ "model_authentik_stages_account_lockdown.accountlockdownstage": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "minLength": 1,
+ "title": "Name"
+ },
+ "deactivate_user": {
+ "type": "boolean",
+ "title": "Deactivate user",
+ "description": "Deactivate the user account (set is_active to False)"
+ },
+ "set_unusable_password": {
+ "type": "boolean",
+ "title": "Set unusable password",
+ "description": "Set an unusable password for the user"
+ },
+ "delete_sessions": {
+ "type": "boolean",
+ "title": "Delete sessions",
+ "description": "Delete all active sessions for the user"
+ },
+ "revoke_tokens": {
+ "type": "boolean",
+ "title": "Revoke tokens",
+ "description": "Revoke all tokens for the user (API, app password, recovery, verification, OAuth)"
+ },
+ "self_service_completion_flow": {
+ "type": "string",
+ "format": "uuid",
+ "title": "Self service completion flow",
+ "description": "Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted."
+ }
+ },
+ "required": []
+ },
+ "model_authentik_stages_account_lockdown.accountlockdownstage_permissions": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": [
+ "permission"
+ ],
+ "properties": {
+ "permission": {
+ "type": "string",
+ "enum": [
+ "add_accountlockdownstage",
+ "change_accountlockdownstage",
+ "delete_accountlockdownstage",
+ "view_accountlockdownstage"
+ ]
+ },
+ "user": {
+ "type": "integer"
+ },
+ "role": {
+ "type": "string"
+ }
+ }
+ }
+ },
"model_authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage": {
"type": "object",
"properties": {
@@ -8952,6 +9064,7 @@
"authentik.enterprise.providers.ssf",
"authentik.enterprise.providers.ws_federation",
"authentik.enterprise.reports",
+ "authentik.enterprise.stages.account_lockdown",
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
"authentik.enterprise.stages.mtls",
"authentik.enterprise.stages.source"
@@ -9084,6 +9197,7 @@
"authentik_providers_ssf.ssfprovider",
"authentik_providers_ws_federation.wsfederationprovider",
"authentik_reports.dataexport",
+ "authentik_stages_account_lockdown.accountlockdownstage",
"authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage",
"authentik_stages_mtls.mutualtlsstage",
"authentik_stages_source.sourcestage"
@@ -11791,6 +11905,10 @@
"authentik_sources_telegram.view_telegramsource",
"authentik_sources_telegram.view_telegramsourcepropertymapping",
"authentik_sources_telegram.view_usertelegramsourceconnection",
+ "authentik_stages_account_lockdown.add_accountlockdownstage",
+ "authentik_stages_account_lockdown.change_accountlockdownstage",
+ "authentik_stages_account_lockdown.delete_accountlockdownstage",
+ "authentik_stages_account_lockdown.view_accountlockdownstage",
"authentik_stages_authenticator_duo.add_authenticatorduostage",
"authentik_stages_authenticator_duo.add_duodevice",
"authentik_stages_authenticator_duo.change_authenticatorduostage",
@@ -15657,6 +15775,9 @@
"separator",
"hidden",
"static",
+ "alert_info",
+ "alert_warning",
+ "alert_danger",
"ak-locale"
],
"title": "Type"
diff --git a/packages/client-go/api_core.go b/packages/client-go/api_core.go
index 239b01b916..e9e982e740 100644
--- a/packages/client-go/api_core.go
+++ b/packages/client-go/api_core.go
@@ -39,6 +39,7 @@ type ApiCoreBrandsListRequest struct {
flowAuthentication *string
flowDeviceCode *string
flowInvalidation *string
+ flowLockdown *string
flowRecovery *string
flowUnenrollment *string
flowUserSettings *string
@@ -104,6 +105,11 @@ func (r ApiCoreBrandsListRequest) FlowInvalidation(flowInvalidation string) ApiC
return r
}
+func (r ApiCoreBrandsListRequest) FlowLockdown(flowLockdown string) ApiCoreBrandsListRequest {
+ r.flowLockdown = &flowLockdown
+ return r
+}
+
func (r ApiCoreBrandsListRequest) FlowRecovery(flowRecovery string) ApiCoreBrandsListRequest {
r.flowRecovery = &flowRecovery
return r
@@ -230,6 +236,9 @@ func (a *CoreAPIService) CoreBrandsListExecute(r ApiCoreBrandsListRequest) (*Pag
if r.flowInvalidation != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "flow_invalidation", r.flowInvalidation, "form", "")
}
+ if r.flowLockdown != nil {
+ parameterAddToHeaderOrQuery(localVarQueryParams, "flow_lockdown", r.flowLockdown, "form", "")
+ }
if r.flowRecovery != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "flow_recovery", r.flowRecovery, "form", "")
}
diff --git a/packages/client-go/model_brand.go b/packages/client-go/model_brand.go
index a8611d7842..ff3109dc97 100644
--- a/packages/client-go/model_brand.go
+++ b/packages/client-go/model_brand.go
@@ -36,6 +36,7 @@ type Brand struct {
FlowUnenrollment NullableString `json:"flow_unenrollment,omitempty"`
FlowUserSettings NullableString `json:"flow_user_settings,omitempty"`
FlowDeviceCode NullableString `json:"flow_device_code,omitempty"`
+ FlowLockdown NullableString `json:"flow_lockdown,omitempty"`
// When set, external users will be redirected to this application after authenticating.
DefaultApplication NullableString `json:"default_application,omitempty"`
// Web Certificate used by the authentik Core webserver.
@@ -565,6 +566,49 @@ func (o *Brand) UnsetFlowDeviceCode() {
o.FlowDeviceCode.Unset()
}
+// GetFlowLockdown returns the FlowLockdown field value if set, zero value otherwise (both if not set or set to explicit null).
+func (o *Brand) GetFlowLockdown() string {
+ if o == nil || IsNil(o.FlowLockdown.Get()) {
+ var ret string
+ return ret
+ }
+ return *o.FlowLockdown.Get()
+}
+
+// GetFlowLockdownOk returns a tuple with the FlowLockdown field value if set, nil otherwise
+// and a boolean to check if the value has been set.
+// NOTE: If the value is an explicit nil, `nil, true` will be returned
+func (o *Brand) GetFlowLockdownOk() (*string, bool) {
+ if o == nil {
+ return nil, false
+ }
+ return o.FlowLockdown.Get(), o.FlowLockdown.IsSet()
+}
+
+// HasFlowLockdown returns a boolean if a field has been set.
+func (o *Brand) HasFlowLockdown() bool {
+ if o != nil && o.FlowLockdown.IsSet() {
+ return true
+ }
+
+ return false
+}
+
+// SetFlowLockdown gets a reference to the given NullableString and assigns it to the FlowLockdown field.
+func (o *Brand) SetFlowLockdown(v string) {
+ o.FlowLockdown.Set(&v)
+}
+
+// SetFlowLockdownNil sets the value for FlowLockdown to be an explicit nil
+func (o *Brand) SetFlowLockdownNil() {
+ o.FlowLockdown.Set(nil)
+}
+
+// UnsetFlowLockdown ensures that no value is present for FlowLockdown, not even an explicit nil
+func (o *Brand) UnsetFlowLockdown() {
+ o.FlowLockdown.Unset()
+}
+
// GetDefaultApplication returns the DefaultApplication field value if set, zero value otherwise (both if not set or set to explicit null).
func (o *Brand) GetDefaultApplication() string {
if o == nil || IsNil(o.DefaultApplication.Get()) {
@@ -763,6 +807,9 @@ func (o Brand) ToMap() (map[string]interface{}, error) {
if o.FlowDeviceCode.IsSet() {
toSerialize["flow_device_code"] = o.FlowDeviceCode.Get()
}
+ if o.FlowLockdown.IsSet() {
+ toSerialize["flow_lockdown"] = o.FlowLockdown.Get()
+ }
if o.DefaultApplication.IsSet() {
toSerialize["default_application"] = o.DefaultApplication.Get()
}
@@ -833,6 +880,7 @@ func (o *Brand) UnmarshalJSON(data []byte) (err error) {
delete(additionalProperties, "flow_unenrollment")
delete(additionalProperties, "flow_user_settings")
delete(additionalProperties, "flow_device_code")
+ delete(additionalProperties, "flow_lockdown")
delete(additionalProperties, "default_application")
delete(additionalProperties, "web_certificate")
delete(additionalProperties, "client_certificates")
diff --git a/packages/client-go/model_prompt_type_enum.go b/packages/client-go/model_prompt_type_enum.go
index b771328418..408cbdb1ca 100644
--- a/packages/client-go/model_prompt_type_enum.go
+++ b/packages/client-go/model_prompt_type_enum.go
@@ -38,6 +38,9 @@ const (
PROMPTTYPEENUM_SEPARATOR PromptTypeEnum = "separator"
PROMPTTYPEENUM_HIDDEN PromptTypeEnum = "hidden"
PROMPTTYPEENUM_STATIC PromptTypeEnum = "static"
+ PROMPTTYPEENUM_ALERT_INFO PromptTypeEnum = "alert_info"
+ PROMPTTYPEENUM_ALERT_WARNING PromptTypeEnum = "alert_warning"
+ PROMPTTYPEENUM_ALERT_DANGER PromptTypeEnum = "alert_danger"
PROMPTTYPEENUM_AK_LOCALE PromptTypeEnum = "ak-locale"
)
@@ -60,6 +63,9 @@ var AllowedPromptTypeEnumEnumValues = []PromptTypeEnum{
"separator",
"hidden",
"static",
+ "alert_info",
+ "alert_warning",
+ "alert_danger",
"ak-locale",
}
diff --git a/packages/client-rust/src/apis/core_api.rs b/packages/client-rust/src/apis/core_api.rs
index 6e0361ffd7..adb9ca23c1 100644
--- a/packages/client-rust/src/apis/core_api.rs
+++ b/packages/client-rust/src/apis/core_api.rs
@@ -71,6 +71,7 @@ pub async fn core_brands_list(
flow_authentication: Option<&str>,
flow_device_code: Option<&str>,
flow_invalidation: Option<&str>,
+ flow_lockdown: Option<&str>,
flow_recovery: Option<&str>,
flow_unenrollment: Option<&str>,
flow_user_settings: Option<&str>,
@@ -92,6 +93,7 @@ pub async fn core_brands_list(
let p_query_flow_authentication = flow_authentication;
let p_query_flow_device_code = flow_device_code;
let p_query_flow_invalidation = flow_invalidation;
+ let p_query_flow_lockdown = flow_lockdown;
let p_query_flow_recovery = flow_recovery;
let p_query_flow_unenrollment = flow_unenrollment;
let p_query_flow_user_settings = flow_user_settings;
@@ -154,6 +156,9 @@ pub async fn core_brands_list(
if let Some(ref param_value) = p_query_flow_invalidation {
req_builder = req_builder.query(&[("flow_invalidation", ¶m_value.to_string())]);
}
+ if let Some(ref param_value) = p_query_flow_lockdown {
+ req_builder = req_builder.query(&[("flow_lockdown", ¶m_value.to_string())]);
+ }
if let Some(ref param_value) = p_query_flow_recovery {
req_builder = req_builder.query(&[("flow_recovery", ¶m_value.to_string())]);
}
diff --git a/packages/client-rust/src/models/brand.rs b/packages/client-rust/src/models/brand.rs
index 95097c8d5a..528335ed1a 100644
--- a/packages/client-rust/src/models/brand.rs
+++ b/packages/client-rust/src/models/brand.rs
@@ -78,6 +78,13 @@ pub struct Brand {
skip_serializing_if = "Option::is_none"
)]
pub flow_device_code: Option
+
+
+
+ ${msg(
+ "Flow used when a user triggers account lockdown (e.g. in case of compromise). Should contain an Account Lockdown stage.",
+ )}
+
+ ${this.lockdownWarningVisible
+ ? html`
+ ${msg(
+ "Account lockdown flows should require authentication so they can only be started from a signed-in session.",
+ )}
+ `
+ : null}
+
diff --git a/web/src/admin/events/RuleForm.ts b/web/src/admin/events/RuleForm.ts
index e8e6be5099..dc40ab69a8 100644
--- a/web/src/admin/events/RuleForm.ts
+++ b/web/src/admin/events/RuleForm.ts
@@ -104,7 +104,7 @@ export class RuleForm extends ModelForm {
${msg(
- "If no group is selected and 'Send notification to event user' is disabled the rule is disabled. ",
+ "If no group is selected and 'Send notification to event user' is disabled, the rule is disabled.",
)}
@@ -113,7 +113,7 @@ export class RuleForm extends ModelForm {
label=${msg("Send notification to event user")}
?checked=${this.instance?.destinationEventUser ?? false}
help=${msg(
- "When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport. If no group is selected and 'Send notification to event user' is disabled the rule is disabled. ",
+ "When enabled, notification will be sent to the user that triggered the event in addition to any users in the group above. The event user will always be the first user, to send a notification only to the event user enabled 'Send once' in the notification transport.",
)}
>
diff --git a/web/src/admin/events/RuleListPage.ts b/web/src/admin/events/RuleListPage.ts
index 43bef45f1b..27e4fd4069 100644
--- a/web/src/admin/events/RuleListPage.ts
+++ b/web/src/admin/events/RuleListPage.ts
@@ -80,7 +80,7 @@ export class RuleListPage extends TablePage {
protected override row(item: NotificationRule): SlottedTemplateResult[] {
const enabled = !!item.destinationGroupObj || item.destinationEventUser;
return [
- html``,
+ html``,
html`${item.name}`,
html`${severityToLabel(item.severity)}`,
html`${item.destinationGroupObj
diff --git a/web/src/admin/stages/BaseStageForm.ts b/web/src/admin/stages/BaseStageForm.ts
index 6bcb04e7f3..a47d44a1a5 100644
--- a/web/src/admin/stages/BaseStageForm.ts
+++ b/web/src/admin/stages/BaseStageForm.ts
@@ -8,7 +8,7 @@ export abstract class BaseStageForm extends ModelForm {
+ #api = new StagesApi(DEFAULT_CONFIG);
+
+ protected override loadInstance(pk: string): Promise {
+ return this.#api.stagesAccountLockdownRetrieve({ stageUuid: pk });
+ }
+
+ protected override async send(data: AccountLockdownStage): Promise {
+ if (this.instance) {
+ return this.#api.stagesAccountLockdownUpdate({
+ stageUuid: this.instance.pk || "",
+ accountLockdownStageRequest: data,
+ });
+ }
+
+ return this.#api.stagesAccountLockdownCreate({
+ accountLockdownStageRequest: data,
+ });
+ }
+
+ protected override renderForm(): SlottedTemplateResult {
+ return html`
+ ${msg(
+ "This stage executes account lockdown actions on a target user. Configure which actions to perform when this stage runs.",
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${msg(
+ "Flow to redirect users to after self-service lockdown. This flow must not require authentication since the user's session is deleted.",
+ )}
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ak-stage-account-lockdown-form": AccountLockdownStageForm;
+ }
+}
diff --git a/web/src/admin/stages/prompt/PromptForm.ts b/web/src/admin/stages/prompt/PromptForm.ts
index 0b7eb81e15..a0b53d8474 100644
--- a/web/src/admin/stages/prompt/PromptForm.ts
+++ b/web/src/admin/stages/prompt/PromptForm.ts
@@ -148,6 +148,9 @@ export class PromptForm extends ModelForm {
[PromptTypeEnum.Separator, msg("Separator: Static Separator Line")],
[PromptTypeEnum.Hidden, msg("Hidden: Hidden field, can be used to insert data into form.")],
[PromptTypeEnum.Static, msg("Static: Static value, displayed as-is.")],
+ [PromptTypeEnum.AlertInfo, msg("Alert (Info): Static alert box with info styling")],
+ [PromptTypeEnum.AlertWarning, msg("Alert (Warning): Static alert box with warning styling")],
+ [PromptTypeEnum.AlertDanger, msg("Alert (Danger): Static alert box with danger styling")],
[PromptTypeEnum.AkLocale, msg("authentik: Locale: Displays a list of locales authentik supports.")],
];
const currentType = this.instance?.type;
diff --git a/web/src/admin/stages/register.ts b/web/src/admin/stages/register.ts
index 8ae39375c9..657c48c1c9 100644
--- a/web/src/admin/stages/register.ts
+++ b/web/src/admin/stages/register.ts
@@ -22,6 +22,7 @@ import "#admin/stages/password/PasswordStageForm";
import "#admin/stages/prompt/PromptStageForm";
import "#admin/stages/redirect/RedirectStageForm";
import "#admin/stages/source/SourceStageForm";
+import "#admin/stages/account_lockdown/AccountLockdownStageForm";
import "#admin/stages/user_delete/UserDeleteStageForm";
import "#admin/stages/user_login/UserLoginStageForm";
import "#admin/stages/user_logout/UserLogoutStageForm";
diff --git a/web/src/admin/users/UserActiveForm.ts b/web/src/admin/users/UserActiveForm.ts
index c80d4edd5f..18b7ada37d 100644
--- a/web/src/admin/users/UserActiveForm.ts
+++ b/web/src/admin/users/UserActiveForm.ts
@@ -4,9 +4,7 @@ import "#elements/forms/FormGroup";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatDisambiguatedUserDisplayName } from "#common/users";
-import { RawContent } from "#elements/ak-table/ak-simple-table";
import { modalInvoker } from "#elements/dialogs";
-import { pluckEntityName } from "#elements/entities/names";
import { DestructiveModelForm } from "#elements/forms/DestructiveModelForm";
import { WithLocale } from "#elements/mixins/locale";
import { SlottedTemplateResult } from "#elements/types";
@@ -77,41 +75,6 @@ export class UserActivationToggleForm extends WithLocale(DestructiveModelForm
-
- ${displayName}
-
- {
- return [pluckEntityName(ub) || msg("Unnamed"), html`${ub.pk}`];
- })}
- >
- `;
- }
}
declare global {
diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts
index 612914e444..a421bbabca 100644
--- a/web/src/admin/users/UserListPage.ts
+++ b/web/src/admin/users/UserListPage.ts
@@ -22,6 +22,7 @@ import { formatUserDisplayName } from "#common/users";
import { IconEditButton, modalInvoker } from "#elements/dialogs";
import { WithBrandConfig } from "#elements/mixins/branding";
import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities";
+import { WithLicenseSummary } from "#elements/mixins/license";
import { WithSession } from "#elements/mixins/session";
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
import { PaginatedResponse, TableColumn, Timestamp } from "#elements/table/Table";
@@ -56,8 +57,8 @@ const recoveryButtonStyles = css`
`;
@customElement("ak-user-list")
-export class UserListPage extends WithBrandConfig(
- WithCapabilitiesConfig(WithSession(TablePage)),
+export class UserListPage extends WithLicenseSummary(
+ WithBrandConfig(WithCapabilitiesConfig(WithSession(TablePage))),
) {
static styles: CSSResult[] = [
...TablePage.styles,
@@ -195,7 +196,7 @@ export class UserListPage extends WithBrandConfig(
${msg(
- str`Warning: You're about to delete the user you're logged in as (${shouldShowWarning.username}). Proceed at your own risk.`,
+ str`Warning: You are about to delete user ${shouldShowWarning.username}, but you are currently logged in as this user. Proceed at your own risk.`,
)}