diff --git a/authentik/core/sources/flow_manager.py b/authentik/core/sources/flow_manager.py index 317e3062c..62153a9e5 100644 --- a/authentik/core/sources/flow_manager.py +++ b/authentik/core/sources/flow_manager.py @@ -25,7 +25,8 @@ from authentik.flows.planner import ( ) from authentik.flows.stage import StageView from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN -from authentik.lib.utils.urls import redirect_with_qs +from authentik.interfaces.models import InterfaceType +from authentik.interfaces.views import redirect_to_default_interface from authentik.lib.views import bad_request_message from authentik.policies.denied import AccessDeniedResponse from authentik.policies.utils import delete_none_keys @@ -226,7 +227,7 @@ class SourceFlowManager: # Ensure redirect is carried through when user was trying to # authorize application final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( - NEXT_ARG_NAME, "authentik_core:if-user" + NEXT_ARG_NAME, "authentik_core:root-redirect" ) kwargs.update( { @@ -253,9 +254,9 @@ class SourceFlowManager: for stage in stages: plan.append_stage(stage) self.request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "authentik_core:if-flow", - self.request.GET, + return redirect_to_default_interface( + self.request, + InterfaceType.FLOW, flow_slug=flow.slug, ) @@ -299,8 +300,9 @@ class SourceFlowManager: _("Successfully linked %(source)s!" % {"source": self.source.name}), ) return redirect( + # Not ideal that we don't directly redirect to the configured user interface reverse( - "authentik_core:if-user", + "authentik_core:root-redirect", ) + f"#/settings;page-{self.source.slug}" ) diff --git a/authentik/core/tests/test_impersonation.py b/authentik/core/tests/test_impersonation.py index 12d309d59..3c6125b89 100644 --- a/authentik/core/tests/test_impersonation.py +++ b/authentik/core/tests/test_impersonation.py @@ -59,4 +59,6 @@ class TestImpersonation(TestCase): self.client.force_login(self.other_user) response = self.client.get(reverse("authentik_core:impersonate-end")) - self.assertRedirects(response, reverse("authentik_core:if-user")) + self.assertRedirects( + response, reverse("authentik_interfaces:if", kwargs={"if_name", "user"}) + ) diff --git a/authentik/core/urls.py b/authentik/core/urls.py index d2c3d262c..f5f93053e 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -3,27 +3,30 @@ from channels.auth import AuthMiddleware from channels.sessions import CookieMiddleware from django.conf import settings from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, HttpResponse from django.urls import path from django.views.decorators.csrf import ensure_csrf_cookie -from django.views.generic import RedirectView -from django.http import HttpRequest, HttpResponse + from authentik.core.views import apps, impersonate from authentik.core.views.debug import AccessDeniedView from authentik.core.views.session import EndSessionView +from authentik.interfaces.models import InterfaceType +from authentik.interfaces.views import RedirectToInterface from authentik.root.asgi_middleware import SessionMiddleware from authentik.root.messages.consumer import MessageConsumer def placeholder_view(request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Empty view used as placeholder + + (Mounted to websocket endpoints and used by e2e tests)""" return HttpResponse(status_code=200) urlpatterns = [ path( "", - login_required( - RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True) - ), + login_required(RedirectToInterface.as_view(type=InterfaceType.USER)), name="root-redirect", ), path( diff --git a/authentik/core/views/apps.py b/authentik/core/views/apps.py index e52ba12d6..0dfe14f92 100644 --- a/authentik/core/views/apps.py +++ b/authentik/core/views/apps.py @@ -20,7 +20,8 @@ from authentik.flows.views.executor import ( SESSION_KEY_PLAN, ToDefaultFlow, ) -from authentik.lib.utils.urls import redirect_with_qs +from authentik.interfaces.models import InterfaceType +from authentik.interfaces.views import redirect_to_default_interface from authentik.stages.consent.stage import ( PLAN_CONTEXT_CONSENT_HEADER, PLAN_CONTEXT_CONSENT_PERMISSIONS, @@ -59,7 +60,7 @@ class RedirectToAppLaunch(View): raise Http404 plan.insert_stage(in_memory_stage(RedirectToAppStage)) request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug) + return redirect_to_default_interface(request, InterfaceType.FLOW, flow_slug=flow.slug) class RedirectToAppStage(ChallengeStageView): diff --git a/authentik/core/views/impersonate.py b/authentik/core/views/impersonate.py index c19a47f62..46f41a9f5 100644 --- a/authentik/core/views/impersonate.py +++ b/authentik/core/views/impersonate.py @@ -35,7 +35,7 @@ class ImpersonateInitView(View): Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) - return redirect("authentik_core:if-user") + return redirect("authentik_core:root-redirect") class ImpersonateEndView(View): @@ -48,7 +48,7 @@ class ImpersonateEndView(View): or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session ): LOGGER.debug("Can't end impersonation", user=request.user) - return redirect("authentik_core:if-user") + return redirect("authentik_core:root-redirect") original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] diff --git a/authentik/flows/views/executor.py b/authentik/flows/views/executor.py index 1279940b6..6332ada14 100644 --- a/authentik/flows/views/executor.py +++ b/authentik/flows/views/executor.py @@ -53,6 +53,8 @@ from authentik.flows.planner import ( FlowPlanner, ) from authentik.flows.stage import AccessDeniedChallengeView, StageView +from authentik.interfaces.models import InterfaceType +from authentik.interfaces.views import redirect_to_default_interface from authentik.lib.sentry import SentryIgnoredException from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.reflection import all_subclasses, class_to_path @@ -512,7 +514,7 @@ class ToDefaultFlow(View): flow_slug=flow.slug, ) del self.request.session[SESSION_KEY_PLAN] - return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug) + return redirect_to_default_interface(request, InterfaceType.FLOW, flow_slug=flow.slug) def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse: @@ -583,8 +585,8 @@ class ConfigureFlowInitView(LoginRequiredMixin, View): LOGGER.warning("Flow not applicable to user") raise Http404 request.session[SESSION_KEY_PLAN] = plan - return redirect_with_qs( - "authentik_core:if-flow", - self.request.GET, + return redirect_to_default_interface( + self.request, + InterfaceType.FLOW, flow_slug=stage.configure_flow.slug, ) diff --git a/authentik/interfaces/api.py b/authentik/interfaces/api.py index 43fe038f4..392cd8d31 100644 --- a/authentik/interfaces/api.py +++ b/authentik/interfaces/api.py @@ -1,3 +1,4 @@ +"""interfaces API""" from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet @@ -5,6 +6,8 @@ from authentik.interfaces.models import Interface class InterfaceSerializer(ModelSerializer): + """Interface serializer""" + class Meta: model = Interface fields = [ @@ -16,5 +19,8 @@ class InterfaceSerializer(ModelSerializer): class InterfaceViewSet(ModelViewSet): + """Interface serializer""" + queryset = Interface.objects.all() serializer_class = InterfaceSerializer + filterset_fields = ["url_name", "type", "template"] diff --git a/authentik/interfaces/migrations/0001_initial.py b/authentik/interfaces/migrations/0001_initial.py index 088745545..93f251cc4 100644 --- a/authentik/interfaces/migrations/0001_initial.py +++ b/authentik/interfaces/migrations/0001_initial.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): default=uuid.uuid4, editable=False, primary_key=True, serialize=False ), ), - ("url_name", models.SlugField()), + ("url_name", models.SlugField(unique=True)), ( "type", models.TextField( diff --git a/authentik/interfaces/models.py b/authentik/interfaces/models.py index cba2897eb..62e0e95da 100644 --- a/authentik/interfaces/models.py +++ b/authentik/interfaces/models.py @@ -21,7 +21,7 @@ class Interface(SerializerModel): interface_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) - url_name = models.SlugField() + url_name = models.SlugField(unique=True) type = models.TextField(choices=InterfaceType.choices) template = models.TextField() diff --git a/authentik/interfaces/urls.py b/authentik/interfaces/urls.py index 041ded9fc..666dc716a 100644 --- a/authentik/interfaces/urls.py +++ b/authentik/interfaces/urls.py @@ -10,7 +10,5 @@ urlpatterns = [ kwargs={"flow_slug": None}, name="if", ), - path( - "//", InterfaceView.as_view(), name="if" - ), + path("//", InterfaceView.as_view(), name="if"), ] diff --git a/authentik/interfaces/views.py b/authentik/interfaces/views.py index 85171d0e6..afc048445 100644 --- a/authentik/interfaces/views.py +++ b/authentik/interfaces/views.py @@ -1,23 +1,25 @@ """Interface views""" from json import dumps -from typing import Any +from typing import Any, Optional from django.http import Http404, HttpRequest, HttpResponse from django.shortcuts import get_object_or_404 from django.template import Template, TemplateSyntaxError, engines from django.template.response import TemplateResponse -from django.views import View -from rest_framework.request import Request -from django.views.decorators.cache import cache_page from django.utils.decorators import method_decorator +from django.views import View +from django.views.decorators.cache import cache_page from django.views.decorators.csrf import ensure_csrf_cookie +from rest_framework.request import Request from authentik import get_build_hash from authentik.admin.tasks import LOCAL_VERSION from authentik.api.v3.config import ConfigView from authentik.flows.models import Flow from authentik.interfaces.models import Interface, InterfaceType +from authentik.lib.utils.urls import redirect_with_qs from authentik.tenants.api import CurrentTenantSerializer +from authentik.tenants.models import Tenant def template_from_string(template_string: str) -> Template: @@ -32,6 +34,38 @@ def template_from_string(template_string: str) -> Template: raise TemplateSyntaxError(template_string, chain=chain) +def redirect_to_default_interface(request: HttpRequest, interface_type: InterfaceType, **kwargs): + """Shortcut to inline redirect to default interface, + keeping GET parameters of the passed request""" + return RedirectToInterface.as_view(type=interface_type)(request, **kwargs) + + +class RedirectToInterface(View): + """Redirect to tenant's configured view for specified type""" + + type: Optional[InterfaceType] = None + + def dispatch(self, request: HttpRequest, **kwargs: Any) -> HttpResponse: + tenant: Tenant = request.tenant + interface: Interface = None + + if self.type == InterfaceType.USER: + interface = tenant.interface_user + if self.type == InterfaceType.ADMIN: + interface = tenant.interface_admin + if self.type == InterfaceType.FLOW: + interface = tenant.interface_flow + + if not interface: + raise Http404() + return redirect_with_qs( + "authentik_interfaces:if", + self.request.GET, + if_name=interface.url_name, + **kwargs, + ) + + @method_decorator(ensure_csrf_cookie, name="dispatch") @method_decorator(cache_page(60 * 10), name="dispatch") class InterfaceView(View): diff --git a/authentik/recovery/views.py b/authentik/recovery/views.py index 200b44f52..8e2b636d1 100644 --- a/authentik/recovery/views.py +++ b/authentik/recovery/views.py @@ -22,4 +22,4 @@ class UseTokenView(View): login(request, token.user, backend=BACKEND_INBUILT) token.delete() messages.warning(request, _("Used recovery-link to authenticate.")) - return redirect("authentik_core:if-user") + return redirect("authentik_core:root-redirect") diff --git a/authentik/sources/saml/views.py b/authentik/sources/saml/views.py index 1ab6c860a..a34985947 100644 --- a/authentik/sources/saml/views.py +++ b/authentik/sources/saml/views.py @@ -72,7 +72,7 @@ class InitiateView(View): # Ensure redirect is carried through when user was trying to # authorize application final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( - NEXT_ARG_NAME, "authentik_core:if-user" + NEXT_ARG_NAME, "authentik_core:root-redirect" ) kwargs.update( { diff --git a/authentik/tenants/api.py b/authentik/tenants/api.py index 42b2985c0..2535e1094 100644 --- a/authentik/tenants/api.py +++ b/authentik/tenants/api.py @@ -54,6 +54,9 @@ class TenantSerializer(ModelSerializer): "flow_unenrollment", "flow_user_settings", "flow_device_code", + "interface_admin", + "interface_user", + "interface_flow", "event_retention", "web_certificate", "attributes", @@ -120,6 +123,9 @@ class TenantViewSet(UsedByMixin, ModelViewSet): "flow_unenrollment", "flow_user_settings", "flow_device_code", + "interface_admin", + "interface_user", + "interface_flow", "event_retention", "web_certificate", ] diff --git a/authentik/tenants/migrations/0005_tenant_interface_admin_tenant_interface_flow_and_more.py b/authentik/tenants/migrations/0005_tenant_interface_admin_tenant_interface_flow_and_more.py new file mode 100644 index 000000000..4c4f045ed --- /dev/null +++ b/authentik/tenants/migrations/0005_tenant_interface_admin_tenant_interface_flow_and_more.py @@ -0,0 +1,78 @@ +# Generated by Django 4.1.7 on 2023-02-21 14:18 + +import django.db.models.deletion +from django.apps.registry import Apps +from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def migrate_set_default(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + Tenant = apps.get_model("authentik_tenants", "tenant") + Interface = apps.get_model("authentik_interfaces", "Interface") + db_alias = schema_editor.connection.alias + + from authentik.blueprints.models import BlueprintInstance + from authentik.blueprints.v1.importer import Importer + from authentik.blueprints.v1.tasks import blueprints_discover + from authentik.interfaces.models import InterfaceType + + # If we don't have any tenants yet, we don't need wait for the default interface blueprint + if not Tenant.objects.using(db_alias).exists(): + return + + interface_blueprint = BlueprintInstance.objects.filter(path="system/interfaces.yaml").first() + if not interface_blueprint: + blueprints_discover.delay().get() + interface_blueprint = BlueprintInstance.objects.filter( + path="system/interfaces.yaml" + ).first() + if not interface_blueprint: + raise ValueError("Failed to apply system/interfaces.yaml blueprint") + Importer(interface_blueprint.retrieve()).apply() + + for tenant in Tenant.objects.using(db_alias).all(): + tenant.interface_admin = Interface.objects.filter(type=InterfaceType.ADMIN).first() + tenant.interface_user = Interface.objects.filter(type=InterfaceType.USER).first() + tenant.interface_flow = Interface.objects.filter(type=InterfaceType.FLOW).first() + tenant.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("authentik_interfaces", "0001_initial"), + ("authentik_tenants", "0004_tenant_flow_device_code"), + ] + + operations = [ + migrations.AddField( + model_name="tenant", + name="interface_admin", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tenant_admin", + to="authentik_interfaces.interface", + ), + ), + migrations.AddField( + model_name="tenant", + name="interface_flow", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tenant_flow", + to="authentik_interfaces.interface", + ), + ), + migrations.AddField( + model_name="tenant", + name="interface_user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="tenant_user", + to="authentik_interfaces.interface", + ), + ), + migrations.RunPython(migrate_set_default), + ] diff --git a/authentik/tenants/models.py b/authentik/tenants/models.py index b729eaaf2..de20fdebc 100644 --- a/authentik/tenants/models.py +++ b/authentik/tenants/models.py @@ -8,6 +8,7 @@ from structlog.stdlib import get_logger from authentik.crypto.models import CertificateKeyPair from authentik.flows.models import Flow +from authentik.interfaces.models import Interface from authentik.lib.models import SerializerModel from authentik.lib.utils.time import timedelta_string_validator @@ -51,6 +52,25 @@ class Tenant(SerializerModel): Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_device_code" ) + interface_flow = models.ForeignKey( + Interface, + on_delete=models.SET_NULL, + null=True, + related_name="tenant_flow", + ) + interface_user = models.ForeignKey( + Interface, + on_delete=models.SET_NULL, + null=True, + related_name="tenant_user", + ) + interface_admin = models.ForeignKey( + Interface, + on_delete=models.SET_NULL, + null=True, + related_name="tenant_admin", + ) + event_retention = models.TextField( default="days=365", validators=[timedelta_string_validator], diff --git a/blueprints/default/default-tenant.yaml b/blueprints/default/default-tenant.yaml index c11432081..18fc0468e 100644 --- a/blueprints/default/default-tenant.yaml +++ b/blueprints/default/default-tenant.yaml @@ -2,6 +2,11 @@ metadata: name: Default - Tenant version: 1 entries: +- model: authentik_blueprints.metaapplyblueprint + attrs: + identifiers: + name: System - Interfaces + required: false - model: authentik_blueprints.metaapplyblueprint attrs: identifiers: @@ -21,6 +26,9 @@ entries: flow_authentication: !Find [authentik_flows.flow, [slug, default-authentication-flow]] flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]] flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-flow]] + interface_admin: !Find [authentik_interfaces.Interface, [type, admin]] + interface_user: !Find [authentik_interfaces.Interface, [type, user]] + interface_flow: !Find [authentik_interfaces.Interface, [type, flow]] identifiers: domain: authentik-default default: True