mirror of
https://github.com/goauthentik/authentik
synced 2026-04-25 17:15:26 +02:00
core: complete rework to oobe and setup experience (#21753)
* initial Signed-off-by: Jens Langhammer <jens@goauthentik.io> * use same startup template Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix check not working Signed-off-by: Jens Langhammer <jens@goauthentik.io> * unrelated: fix inspector auth Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * update docs Signed-off-by: Jens Langhammer <jens@goauthentik.io> * ensure oobe flow can only accessed via correct url Signed-off-by: Jens Langhammer <jens@goauthentik.io> * set setup flag when applying bootstrap blueprint when env is set Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add system visibility to flags to make them non-editable Signed-off-by: Jens Langhammer <jens@goauthentik.io> * set setup flag for e2e tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tests and linting Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make github lint happy Signed-off-by: Jens Langhammer <jens@goauthentik.io> * make tests have less assumptions Signed-off-by: Jens Langhammer <jens@goauthentik.io> * Update docs * include more heuristics in migration Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add management command to set any flag Signed-off-by: Jens Langhammer <jens@goauthentik.io> * migrate worker command to signal Signed-off-by: Jens Langhammer <jens@goauthentik.io> * improved api for setting flags Signed-off-by: Jens Langhammer <jens@goauthentik.io> * short circuit Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io> Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
"""Apply blueprint from commandline"""
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from sys import exit as sys_exit
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
@@ -31,5 +32,5 @@ class Command(BaseCommand):
|
||||
sys_exit(1)
|
||||
importer.apply()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
def add_arguments(self, parser: ArgumentParser):
|
||||
parser.add_argument("blueprints", nargs="+", type=str)
|
||||
|
||||
@@ -7,6 +7,12 @@ from authentik.tasks.schedules.common import ScheduleSpec
|
||||
from authentik.tenants.flags import Flag
|
||||
|
||||
|
||||
class Setup(Flag[bool], key="setup"):
|
||||
|
||||
default = False
|
||||
visibility = "system"
|
||||
|
||||
|
||||
class AppAccessWithoutBindings(Flag[bool], key="core_default_app_access"):
|
||||
|
||||
default = True
|
||||
@@ -26,6 +32,10 @@ class AuthentikCoreConfig(ManagedAppConfig):
|
||||
mountpoint = ""
|
||||
default = True
|
||||
|
||||
def import_related(self):
|
||||
super().import_related()
|
||||
self.import_module("authentik.core.setup.signals")
|
||||
|
||||
@ManagedAppConfig.reconcile_tenant
|
||||
def source_inbuilt(self):
|
||||
"""Reconcile inbuilt source"""
|
||||
|
||||
61
authentik/core/migrations/0058_setup.py
Normal file
61
authentik/core/migrations/0058_setup.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-21 18:49
|
||||
from django.apps.registry import Apps
|
||||
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def check_is_already_setup(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from django.conf import settings
|
||||
from authentik.flows.models import FlowAuthenticationRequirement
|
||||
|
||||
VersionHistory = apps.get_model("authentik_admin", "VersionHistory")
|
||||
Flow = apps.get_model("authentik_flows", "Flow")
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
# Upgrading from a previous version
|
||||
if not settings.TEST and VersionHistory.objects.using(db_alias).count() > 1:
|
||||
return True
|
||||
# OOBE flow sets itself to this authentication requirement once finished
|
||||
if (
|
||||
Flow.objects.using(db_alias)
|
||||
.filter(
|
||||
slug="initial-setup", authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER
|
||||
)
|
||||
.exists()
|
||||
):
|
||||
return True
|
||||
# non-akadmin and non-guardian anonymous user exist
|
||||
if (
|
||||
User.objects.using(db_alias)
|
||||
.exclude(username="akadmin")
|
||||
.exclude(username="AnonymousUser")
|
||||
.exists()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def update_setup_flag(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
is_already_setup = check_is_already_setup(apps, schema_editor)
|
||||
if is_already_setup:
|
||||
tenant = get_current_tenant()
|
||||
tenant.flags[Setup().key] = True
|
||||
tenant.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
|
||||
# 0024_flow_authentication adds the `authentication` field.
|
||||
("authentik_flows", "0024_flow_authentication"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(update_setup_flag, migrations.RunPython.noop)]
|
||||
0
authentik/core/setup/__init__.py
Normal file
0
authentik/core/setup/__init__.py
Normal file
38
authentik/core/setup/signals.py
Normal file
38
authentik/core/setup/signals.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from os import getenv
|
||||
|
||||
from django.dispatch import receiver
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.models import BlueprintInstance
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.root.signals import post_startup
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
BOOTSTRAP_BLUEPRINT = "system/bootstrap.yaml"
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@receiver(post_startup)
|
||||
def post_startup_setup_bootstrap(sender, **_):
|
||||
if not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD") and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN"):
|
||||
return
|
||||
LOGGER.info("Configuring authentik through bootstrap environment variables")
|
||||
content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve()
|
||||
# If we have bootstrap credentials set, run bootstrap tasks outside of main server
|
||||
# sync, so that we can sure the first start actually has working bootstrap
|
||||
# credentials
|
||||
for tenant in Tenant.objects.filter(ready=True):
|
||||
if Setup.get(tenant=tenant):
|
||||
LOGGER.info("Tenant is already setup, skipping", tenant=tenant.schema_name)
|
||||
continue
|
||||
with tenant:
|
||||
importer = Importer.from_string(content)
|
||||
valid, logs = importer.validate()
|
||||
if not valid:
|
||||
LOGGER.warning("Blueprint invalid", tenant=tenant.schema_name)
|
||||
for log in logs:
|
||||
log.log()
|
||||
importer.apply()
|
||||
Setup.set(True, tenant=tenant)
|
||||
80
authentik/core/setup/views.py
Normal file
80
authentik/core/setup/views.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from functools import lru_cache
|
||||
from http import HTTPMethod, HTTPStatus
|
||||
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.models import BlueprintInstance
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.flows.models import Flow, FlowAuthenticationRequirement, in_memory_stage
|
||||
from authentik.flows.planner import FlowPlanner
|
||||
from authentik.flows.stage import StageView
|
||||
|
||||
LOGGER = get_logger()
|
||||
FLOW_CONTEXT_START_BY = "goauthentik.io/core/setup/started-by"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def read_static(path: str) -> str | None:
|
||||
result = finders.find(path)
|
||||
if not result:
|
||||
return None
|
||||
with open(result, encoding="utf8") as _file:
|
||||
return _file.read()
|
||||
|
||||
|
||||
class SetupView(View):
|
||||
|
||||
setup_flow_slug = "initial-setup"
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs):
|
||||
if request.method != HTTPMethod.HEAD and Setup.get():
|
||||
return redirect(reverse("authentik_core:root-redirect"))
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def head(self, request: HttpRequest, *args, **kwargs):
|
||||
if Setup.get():
|
||||
return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
if not Flow.objects.filter(slug=self.setup_flow_slug).exists():
|
||||
return HttpResponse(status=HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
return HttpResponse(status=HTTPStatus.OK)
|
||||
|
||||
def get(self, request: HttpRequest):
|
||||
flow = Flow.objects.filter(slug=self.setup_flow_slug).first()
|
||||
if not flow:
|
||||
LOGGER.info("Setup flow does not exist yet, waiting for worker to finish")
|
||||
return HttpResponse(
|
||||
read_static("dist/standalone/loading/startup.html"),
|
||||
status=HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
planner = FlowPlanner(flow)
|
||||
plan = planner.plan(request, {FLOW_CONTEXT_START_BY: "setup"})
|
||||
plan.append_stage(in_memory_stage(PostSetupStageView))
|
||||
return plan.to_redirect(request, flow)
|
||||
|
||||
|
||||
class PostSetupStageView(StageView):
|
||||
"""Run post-setup tasks"""
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Wrapper when this stage gets hit with a post request"""
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
def get(self, requeset: HttpRequest, *args, **kwargs):
|
||||
with transaction.atomic():
|
||||
# Remember we're setup
|
||||
Setup.set(True)
|
||||
# Disable OOBE Blueprints
|
||||
BlueprintInstance.objects.filter(
|
||||
**{"metadata__labels__blueprints.goauthentik.io/system-oobe": "true"}
|
||||
).update(enabled=False)
|
||||
# Make flow inaccessible
|
||||
Flow.objects.filter(slug="initial-setup").update(
|
||||
authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER
|
||||
)
|
||||
return self.executor.stage_ok()
|
||||
@@ -4,6 +4,7 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.core.models import Application, UserTypes
|
||||
from authentik.core.tests.utils import create_test_brand, create_test_user
|
||||
|
||||
@@ -12,6 +13,7 @@ class TestInterfaceRedirects(TestCase):
|
||||
"""Test RootRedirectView and BrandDefaultRedirectView redirect logic by user type"""
|
||||
|
||||
def setUp(self):
|
||||
Setup.set(True)
|
||||
self.app = Application.objects.create(name="test-app", slug="test-app")
|
||||
self.brand: Brand = create_test_brand(default_application=self.app)
|
||||
|
||||
|
||||
156
authentik/core/tests/test_setup.py
Normal file
156
authentik/core/tests/test_setup.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from http import HTTPStatus
|
||||
from os import environ
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.root.signals import post_startup, pre_startup
|
||||
from authentik.tenants.flags import patch_flag
|
||||
|
||||
|
||||
class TestSetup(FlowTestCase):
|
||||
def tearDown(self):
|
||||
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None)
|
||||
environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None)
|
||||
|
||||
@patch_flag(Setup, True)
|
||||
def test_setup(self):
|
||||
"""Test existing instance"""
|
||||
res = self.client.get(reverse("authentik_core:root-redirect"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(
|
||||
res,
|
||||
reverse("authentik_flows:default-authentication") + "?next=/",
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
res = self.client.head(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
res = self.client.get(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(
|
||||
res,
|
||||
reverse("authentik_core:root-redirect"),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
@patch_flag(Setup, False)
|
||||
def test_not_setup_no_flow(self):
|
||||
"""Test case on initial startup; setup flag is not set and oobe flow does
|
||||
not exist yet"""
|
||||
Flow.objects.filter(slug="initial-setup").delete()
|
||||
res = self.client.get(reverse("authentik_core:root-redirect"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False)
|
||||
# Flow does not exist, hence 503
|
||||
res = self.client.get(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
res = self.client.head(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
|
||||
@patch_flag(Setup, False)
|
||||
@apply_blueprint("default/flow-oobe.yaml")
|
||||
def test_not_setup(self):
|
||||
"""Test case for when worker comes up, and has created flow"""
|
||||
res = self.client.get(reverse("authentik_core:root-redirect"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(res, reverse("authentik_core:setup"), fetch_redirect_response=False)
|
||||
# Flow does not exist, hence 503
|
||||
res = self.client.head(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.OK)
|
||||
res = self.client.get(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(
|
||||
res,
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
@apply_blueprint("default/flow-oobe.yaml")
|
||||
@apply_blueprint("system/bootstrap.yaml")
|
||||
def test_setup_flow_full(self):
|
||||
"""Test full setup flow"""
|
||||
Setup.set(False)
|
||||
|
||||
res = self.client.get(reverse("authentik_core:setup"))
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
self.assertRedirects(
|
||||
res,
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": "initial-setup"}),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.OK)
|
||||
self.assertStageResponse(res, component="ak-stage-prompt")
|
||||
|
||||
pw = generate_id()
|
||||
res = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
{
|
||||
"email": f"{generate_id()}@t.goauthentik.io",
|
||||
"password": pw,
|
||||
"password_repeat": pw,
|
||||
"component": "ak-stage-prompt",
|
||||
},
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.FOUND)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"}),
|
||||
)
|
||||
self.assertEqual(res.status_code, HTTPStatus.OK)
|
||||
|
||||
self.assertTrue(Setup.get())
|
||||
user = User.objects.get(username="akadmin")
|
||||
self.assertTrue(user.check_password(pw))
|
||||
|
||||
@patch_flag(Setup, False)
|
||||
@apply_blueprint("default/flow-oobe.yaml")
|
||||
@apply_blueprint("system/bootstrap.yaml")
|
||||
def test_setup_flow_direct(self):
|
||||
"""Test setup flow, directly accessing the flow"""
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": "initial-setup"})
|
||||
)
|
||||
self.assertStageResponse(
|
||||
res,
|
||||
component="ak-stage-access-denied",
|
||||
error_message="Access the authentik setup by navigating to http://testserver/",
|
||||
)
|
||||
|
||||
def test_setup_bootstrap_env(self):
|
||||
"""Test setup with env vars"""
|
||||
User.objects.filter(username="akadmin").delete()
|
||||
Setup.set(False)
|
||||
|
||||
environ["AUTHENTIK_BOOTSTRAP_PASSWORD"] = generate_id()
|
||||
environ["AUTHENTIK_BOOTSTRAP_TOKEN"] = generate_id()
|
||||
pre_startup.send(sender=self)
|
||||
post_startup.send(sender=self)
|
||||
|
||||
self.assertTrue(Setup.get())
|
||||
user = User.objects.get(username="akadmin")
|
||||
self.assertTrue(user.check_password(environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]))
|
||||
|
||||
token = Token.objects.filter(identifier="authentik-bootstrap-token").first()
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.key, environ["AUTHENTIK_BOOTSTRAP_TOKEN"])
|
||||
@@ -1,7 +1,6 @@
|
||||
"""authentik URL Configuration"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import path
|
||||
|
||||
from authentik.core.api.application_entitlements import ApplicationEntitlementViewSet
|
||||
@@ -19,6 +18,7 @@ from authentik.core.api.sources import (
|
||||
from authentik.core.api.tokens import TokenViewSet
|
||||
from authentik.core.api.transactional_applications import TransactionalApplicationView
|
||||
from authentik.core.api.users import UserViewSet
|
||||
from authentik.core.setup.views import SetupView
|
||||
from authentik.core.views.apps import RedirectToAppLaunch
|
||||
from authentik.core.views.debug import AccessDeniedView
|
||||
from authentik.core.views.interface import (
|
||||
@@ -35,7 +35,7 @@ from authentik.tenants.channels import TenantsAwareMiddleware
|
||||
urlpatterns = [
|
||||
path(
|
||||
"",
|
||||
login_required(RootRedirectView.as_view()),
|
||||
RootRedirectView.as_view(),
|
||||
name="root-redirect",
|
||||
),
|
||||
path(
|
||||
@@ -62,6 +62,11 @@ urlpatterns = [
|
||||
FlowInterfaceView.as_view(),
|
||||
name="if-flow",
|
||||
),
|
||||
path(
|
||||
"setup",
|
||||
SetupView.as_view(),
|
||||
name="setup",
|
||||
),
|
||||
# Fallback for WS
|
||||
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
|
||||
path(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from json import dumps
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.mixins import AccessMixin
|
||||
from django.http import HttpRequest
|
||||
from django.http.response import HttpResponse
|
||||
from django.shortcuts import redirect
|
||||
@@ -14,12 +15,13 @@ from authentik.admin.tasks import LOCAL_VERSION
|
||||
from authentik.api.v3.config import ConfigView
|
||||
from authentik.brands.api import CurrentBrandSerializer
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.core.models import UserTypes
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
|
||||
|
||||
class RootRedirectView(RedirectView):
|
||||
class RootRedirectView(AccessMixin, RedirectView):
|
||||
"""Root redirect view, redirect to brand's default application if set"""
|
||||
|
||||
pattern_name = "authentik_core:if-user"
|
||||
@@ -40,6 +42,10 @@ class RootRedirectView(RedirectView):
|
||||
return None
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
if not Setup.get():
|
||||
return redirect("authentik_core:setup")
|
||||
if not request.user.is_authenticated:
|
||||
return self.handle_no_permission()
|
||||
if redirect_response := RootRedirectView().redirect_to_app(request):
|
||||
return redirect_response
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
@@ -71,7 +71,11 @@ class FlowInspectorView(APIView):
|
||||
|
||||
flow: Flow
|
||||
_logger: BoundLogger
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_permissions(self):
|
||||
if settings.DEBUG:
|
||||
return []
|
||||
return [IsAuthenticated()]
|
||||
|
||||
def setup(self, request: HttpRequest, flow_slug: str):
|
||||
super().setup(request, flow_slug=flow_slug)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""authentik recovery create_admin_group"""
|
||||
|
||||
from argparse import ArgumentParser
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from authentik.core.models import User
|
||||
@@ -12,7 +14,7 @@ class Command(TenantCommand):
|
||||
|
||||
help = _("Create admin group if the default group gets deleted.")
|
||||
|
||||
def add_arguments(self, parser):
|
||||
def add_arguments(self, parser: ArgumentParser):
|
||||
parser.add_argument("user", action="store", help="User to add to the admin group.")
|
||||
|
||||
def handle_per_tenant(self, *args, **options):
|
||||
|
||||
@@ -19,19 +19,32 @@ from authentik.tenants.models import Tenant
|
||||
|
||||
class FlagJSONField(JSONDictField):
|
||||
|
||||
def to_representation(self, value: dict) -> dict:
|
||||
"""Exclude any system flags that aren't modifiable"""
|
||||
new_value = value.copy()
|
||||
for flag in Flag.available(exclude_system=False):
|
||||
_flag = flag()
|
||||
if _flag.visibility == "system":
|
||||
new_value.pop(_flag.key, None)
|
||||
return super().to_representation(new_value)
|
||||
|
||||
def run_validators(self, value: dict):
|
||||
super().run_validators(value)
|
||||
for flag in Flag.available():
|
||||
for flag in Flag.available(exclude_system=False):
|
||||
_flag = flag()
|
||||
if _flag.key in value:
|
||||
flag_value = value.get(_flag.key)
|
||||
flag_type = get_args(_flag.__orig_bases__[0])[0]
|
||||
if flag_value and not isinstance(flag_value, flag_type):
|
||||
raise ValidationError(
|
||||
_("Value for flag {flag_key} needs to be of type {type}.").format(
|
||||
flag_key=_flag.key, type=flag_type.__name__
|
||||
)
|
||||
if _flag.key not in value:
|
||||
continue
|
||||
if _flag.visibility == "system":
|
||||
value.pop(_flag.key, None)
|
||||
continue
|
||||
flag_value = value.get(_flag.key)
|
||||
flag_type = get_args(_flag.__orig_bases__[0])[0]
|
||||
if flag_value and not isinstance(flag_value, flag_type):
|
||||
raise ValidationError(
|
||||
_("Value for flag {flag_key} needs to be of type {type}.").format(
|
||||
flag_key=_flag.key, type=flag_type.__name__
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class FlagsJSONExtension(OpenApiSerializerFieldExtension):
|
||||
|
||||
@@ -4,6 +4,7 @@ from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from django.db.models import F, Func, JSONField, Value
|
||||
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
|
||||
@@ -13,7 +14,9 @@ if TYPE_CHECKING:
|
||||
|
||||
class Flag[T]:
|
||||
default: T | None = None
|
||||
visibility: Literal["none"] | Literal["public"] | Literal["authenticated"] = "none"
|
||||
visibility: (
|
||||
Literal["none"] | Literal["public"] | Literal["authenticated"] | Literal["system"]
|
||||
) = "none"
|
||||
description: str | None = None
|
||||
|
||||
def __init_subclass__(cls, key: str, **kwargs):
|
||||
@@ -24,12 +27,15 @@ class Flag[T]:
|
||||
return self.__key
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> T | None:
|
||||
def get(cls, tenant: Tenant | None = None) -> T | None:
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
if not tenant:
|
||||
tenant = get_current_tenant(["flags"])
|
||||
|
||||
flags = {}
|
||||
try:
|
||||
flags: dict[str, Any] = get_current_tenant(["flags"]).flags
|
||||
flags: dict[str, Any] = tenant.flags
|
||||
except DatabaseError, ProgrammingError, InternalError:
|
||||
pass
|
||||
value = flags.get(cls.__key, None)
|
||||
@@ -37,20 +43,38 @@ class Flag[T]:
|
||||
return cls().get_default()
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def set(cls, value: T, tenant: Tenant | None = None) -> T | None:
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
if not tenant:
|
||||
tenant = get_current_tenant()
|
||||
|
||||
Tenant.objects.filter(pk=tenant.pk).update(
|
||||
flags=Func(
|
||||
F("flags"),
|
||||
Value([cls.__key]),
|
||||
Value(value, JSONField()),
|
||||
function="jsonb_set",
|
||||
)
|
||||
)
|
||||
|
||||
def get_default(self) -> T | None:
|
||||
return self.default
|
||||
|
||||
@staticmethod
|
||||
def available(
|
||||
visibility: Literal["none"] | Literal["public"] | Literal["authenticated"] | None = None,
|
||||
exclude_system=True,
|
||||
):
|
||||
flags = all_subclasses(Flag)
|
||||
if visibility:
|
||||
for flag in flags:
|
||||
if flag.visibility == visibility:
|
||||
yield flag
|
||||
else:
|
||||
yield from flags
|
||||
for flag in flags:
|
||||
if visibility and flag.visibility != visibility:
|
||||
continue
|
||||
if exclude_system and flag.visibility == "system":
|
||||
continue
|
||||
yield flag
|
||||
|
||||
|
||||
def patch_flag[T](flag: Flag[T], value: T):
|
||||
|
||||
19
authentik/tenants/management/commands/set_flag.py
Normal file
19
authentik/tenants/management/commands/set_flag.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from argparse import ArgumentParser
|
||||
from typing import Any
|
||||
|
||||
from authentik.tenants.management import TenantCommand
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
class Command(TenantCommand):
|
||||
|
||||
def add_arguments(self, parser: ArgumentParser):
|
||||
parser.add_argument("flag_key", type=str)
|
||||
parser.add_argument("flag_value", type=str)
|
||||
|
||||
def handle(self, *, flag_key: str, flag_value: Any, **options):
|
||||
tenant = get_current_tenant()
|
||||
val = flag_value.lower() == "true"
|
||||
tenant.flags[flag_key] = val
|
||||
tenant.save()
|
||||
self.stdout.write(f"Set flag '{flag_key}' to {val}.")
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Test Settings API"""
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.tenants.flags import Flag
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
class TestLocalSettingsAPI(APITestCase):
|
||||
@@ -13,11 +15,19 @@ class TestLocalSettingsAPI(APITestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.local_admin = create_test_admin_user()
|
||||
self.tenant = get_current_tenant()
|
||||
|
||||
def tearDown(self):
|
||||
super().tearDown()
|
||||
self.tenant.flags = {}
|
||||
self.tenant.save()
|
||||
|
||||
def test_settings_flags(self):
|
||||
"""Test settings API"""
|
||||
self.tenant.flags = {}
|
||||
self.tenant.save()
|
||||
|
||||
class TestFlag(Flag[bool], key="tenants_test_flag"):
|
||||
class _TestFlag(Flag[bool], key="tenants_test_flag_bool"):
|
||||
|
||||
default = False
|
||||
visibility = "public"
|
||||
@@ -26,15 +36,19 @@ class TestLocalSettingsAPI(APITestCase):
|
||||
response = self.client.patch(
|
||||
reverse("authentik_api:tenant_settings"),
|
||||
data={
|
||||
"flags": {"tenants_test_flag": True},
|
||||
"flags": {"tenants_test_flag_bool": True},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.tenant.refresh_from_db()
|
||||
self.assertEqual(self.tenant.flags["tenants_test_flag_bool"], True)
|
||||
|
||||
def test_settings_flags_incorrect(self):
|
||||
"""Test settings API"""
|
||||
self.tenant.flags = {}
|
||||
self.tenant.save()
|
||||
|
||||
class TestFlag(Flag[bool], key="tenants_test_flag"):
|
||||
class _TestFlag(Flag[bool], key="tenants_test_flag_incorrect"):
|
||||
|
||||
default = False
|
||||
visibility = "public"
|
||||
@@ -43,11 +57,44 @@ class TestLocalSettingsAPI(APITestCase):
|
||||
response = self.client.patch(
|
||||
reverse("authentik_api:tenant_settings"),
|
||||
data={
|
||||
"flags": {"tenants_test_flag": 123},
|
||||
"flags": {"tenants_test_flag_incorrect": 123},
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content,
|
||||
{"flags": ["Value for flag tenants_test_flag needs to be of type bool."]},
|
||||
{"flags": ["Value for flag tenants_test_flag_incorrect needs to be of type bool."]},
|
||||
)
|
||||
self.tenant.refresh_from_db()
|
||||
self.assertEqual(self.tenant.flags, {})
|
||||
|
||||
def test_settings_flags_system(self):
|
||||
"""Test settings API"""
|
||||
self.tenant.flags = {}
|
||||
self.tenant.save()
|
||||
|
||||
class _TestFlag(Flag[bool], key="tenants_test_flag_sys"):
|
||||
|
||||
default = False
|
||||
visibility = "system"
|
||||
|
||||
self.client.force_login(self.local_admin)
|
||||
response = self.client.patch(
|
||||
reverse("authentik_api:tenant_settings"),
|
||||
data={
|
||||
"flags": {"tenants_test_flag_sys": 123},
|
||||
},
|
||||
)
|
||||
print(response.content)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.tenant.refresh_from_db()
|
||||
self.assertEqual(self.tenant.flags, {})
|
||||
|
||||
def test_command(self):
|
||||
self.tenant.flags = {}
|
||||
self.tenant.save()
|
||||
|
||||
call_command("set_flag", "foo", "true")
|
||||
|
||||
self.tenant.refresh_from_db()
|
||||
self.assertTrue(self.tenant.flags["foo"])
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
metadata:
|
||||
labels:
|
||||
blueprints.goauthentik.io/system-oobe: "true"
|
||||
blueprints.goauthentik.io/system: "true"
|
||||
name: Default - Out-of-box-experience flow
|
||||
version: 1
|
||||
entries:
|
||||
@@ -75,23 +78,20 @@ entries:
|
||||
- attrs:
|
||||
expression: |
|
||||
# This policy ensures that the setup flow can only be
|
||||
# executed when the admin user doesn''t have a password set
|
||||
# executed when the admin user doesn't have a password set
|
||||
akadmin = ak_user_by(username="akadmin")
|
||||
return not akadmin.has_usable_password()
|
||||
# Ensure flow was started correctly
|
||||
started_by = context.get("goauthentik.io/core/setup/started-by")
|
||||
if started_by != "setup":
|
||||
setup_url = request.http_request.build_absolute_uri("/")
|
||||
ak_message(f"Access the authentik setup by navigating to {setup_url}")
|
||||
return False
|
||||
return akadmin is None or not akadmin.has_usable_password()
|
||||
id: policy-default-oobe-password-usable
|
||||
identifiers:
|
||||
name: default-oobe-password-usable
|
||||
model: authentik_policies_expression.expressionpolicy
|
||||
- attrs:
|
||||
expression: |
|
||||
# This policy ensures that the setup flow can only be
|
||||
# used one time
|
||||
from authentik.flows.models import Flow, FlowAuthenticationRequirement
|
||||
Flow.objects.filter(slug="initial-setup").update(
|
||||
authentication=FlowAuthenticationRequirement.REQUIRE_SUPERUSER,
|
||||
)
|
||||
return True
|
||||
id: policy-default-oobe-flow-set-authentication
|
||||
- state: absent
|
||||
identifiers:
|
||||
name: default-oobe-flow-set-authentication
|
||||
model: authentik_policies_expression.expressionpolicy
|
||||
@@ -154,8 +154,3 @@ entries:
|
||||
policy: !KeyOf policy-default-oobe-prefill-user
|
||||
target: !KeyOf binding-password-write
|
||||
model: authentik_policies.policybinding
|
||||
- identifiers:
|
||||
order: 0
|
||||
policy: !KeyOf policy-default-oobe-flow-set-authentication
|
||||
target: !KeyOf binding-login
|
||||
model: authentik_policies.policybinding
|
||||
|
||||
@@ -84,12 +84,6 @@ if [[ "$1" == "server" ]]; then
|
||||
elif [[ "$1" == "worker" ]]; then
|
||||
set_mode "worker"
|
||||
shift
|
||||
# If we have bootstrap credentials set, run bootstrap tasks outside of main server
|
||||
# sync, so that we can sure the first start actually has working bootstrap
|
||||
# credentials
|
||||
if [[ -n "${AUTHENTIK_BOOTSTRAP_PASSWORD}" || -n "${AUTHENTIK_BOOTSTRAP_TOKEN}" ]]; then
|
||||
python -m manage apply_blueprint system/bootstrap.yaml || true
|
||||
fi
|
||||
check_if_root "python -m manage worker --pid-file ${TMPDIR}/authentik-worker.pid $@"
|
||||
elif [[ "$1" == "bash" ]]; then
|
||||
/bin/bash
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
||||
from dramatiq import get_broker
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.apps import Setup
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.tasks.test import use_test_broker
|
||||
@@ -27,6 +28,7 @@ class E2ETestMixin(DockerTestCase):
|
||||
self.wait_timeout = 60
|
||||
self.logger = get_logger()
|
||||
self.user = create_test_admin_user()
|
||||
Setup.set(True)
|
||||
super().setUp()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -62,5 +62,3 @@ If you're focusing solely on frontend development, you can create a minimal deve
|
||||
```
|
||||
|
||||
You can now access authentik on http://localhost:9000 (or https://localhost:9443).
|
||||
|
||||
You might also want to complete the initial setup under `/if/flow/initial-setup/`.
|
||||
|
||||
@@ -174,7 +174,7 @@ make web-watch
|
||||
|
||||
After the frontend build completes, set a password for the default admin user (**akadmin**):
|
||||
|
||||
1. Navigate to http://localhost:9000/if/flow/initial-setup/ in your browser.
|
||||
1. Navigate to http://localhost:9000 in your browser.
|
||||
2. Follow the prompts to set up your admin account.
|
||||
|
||||
From now on, you can access authentik at http://localhost:9000 using the credentials you defined in Step 2.
|
||||
|
||||
@@ -30,12 +30,12 @@ The stack will output the endpoint of the ALB to which you can point your DNS re
|
||||
|
||||
## Access authentik from AWS CloudFormation
|
||||
|
||||
To launch authentik, in your browser go to:
|
||||
To start the initial setup, navigate to `http://<domain_you_configured>`.
|
||||
|
||||
`http://<domain_you_configured>/if/flow/initial-setup/`
|
||||
You are then prompted to set a password for the `akadmin` user (the default user).
|
||||
|
||||
:::info Initial setup in browser
|
||||
You will get a `Not Found` error if initial setup URL doesn't include the trailing forward slash `/`. Also verify that the authentik server, worker, and PostgreSQL database are running and healthy. Review additional tips in our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps).
|
||||
:::info Issues with initial setup
|
||||
If you run into issues, refer to our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps).
|
||||
:::
|
||||
|
||||
### Further customization
|
||||
|
||||
@@ -116,14 +116,14 @@ The `compose.yml` file statically references the latest version available at the
|
||||
|
||||
## Access authentik
|
||||
|
||||
To start the initial setup, navigate to `http://<your server's IP or hostname>:9000/if/flow/initial-setup/`.
|
||||
|
||||
:::info Initial setup in browser
|
||||
You will get a `Not Found` error if initial setup URL doesn't include the trailing forward slash `/`. Also verify that the authentik server, worker, and PostgreSQL database are running and healthy. Review additional tips in our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps).
|
||||
:::
|
||||
To start the initial setup, navigate to `http://<your server's IP or hostname>:9000`.
|
||||
|
||||
You are then prompted to set a password for the `akadmin` user (the default user).
|
||||
|
||||
:::info Issues with initial setup
|
||||
If you run into issues, refer to our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps).
|
||||
:::
|
||||
|
||||
## First steps in authentik
|
||||
|
||||
import BlurbFirstSteps from "../first-steps/_blurb_first_steps.mdx";
|
||||
|
||||
@@ -109,10 +109,12 @@ During the installation process, the database migrations will be applied automat
|
||||
|
||||
## Access authentik
|
||||
|
||||
After the installation is complete, access authentik at `https://<authentik-host-name>/if/flow/initial-setup/`. Here, you can set a password for the default `akadmin` user.
|
||||
To start the initial setup, navigate to `http://<your server's IP or hostname>:9000`.
|
||||
|
||||
:::info Initial setup in browser
|
||||
You will get a `Not Found` error if initial setup URL doesn't include the trailing forward slash `/`. Also verify that the authentik server, worker, and PostgreSQL database are running and healthy. Review additional tips in our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps).
|
||||
You are then prompted to set a password for the `akadmin` user (the default user).
|
||||
|
||||
:::info Issues with initial setup
|
||||
If you run into issues, refer to our [troubleshooting docs](../../troubleshooting/login.md#cant-access-initial-setup-flow-during-installation-steps).
|
||||
:::
|
||||
|
||||
## First steps in authentik
|
||||
|
||||
Reference in New Issue
Block a user