add tenant migration, migrate default urls and redirects

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens Langhammer 2023-02-21 15:53:33 +01:00
parent e39c460e3a
commit 246a6c7384
No known key found for this signature in database
17 changed files with 191 additions and 31 deletions

View file

@ -25,7 +25,8 @@ from authentik.flows.planner import (
) )
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN 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.lib.views import bad_request_message
from authentik.policies.denied import AccessDeniedResponse from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.utils import delete_none_keys from authentik.policies.utils import delete_none_keys
@ -226,7 +227,7 @@ class SourceFlowManager:
# Ensure redirect is carried through when user was trying to # Ensure redirect is carried through when user was trying to
# authorize application # authorize application
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( 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( kwargs.update(
{ {
@ -253,9 +254,9 @@ class SourceFlowManager:
for stage in stages: for stage in stages:
plan.append_stage(stage) plan.append_stage(stage)
self.request.session[SESSION_KEY_PLAN] = plan self.request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs( return redirect_to_default_interface(
"authentik_core:if-flow", self.request,
self.request.GET, InterfaceType.FLOW,
flow_slug=flow.slug, flow_slug=flow.slug,
) )
@ -299,8 +300,9 @@ class SourceFlowManager:
_("Successfully linked %(source)s!" % {"source": self.source.name}), _("Successfully linked %(source)s!" % {"source": self.source.name}),
) )
return redirect( return redirect(
# Not ideal that we don't directly redirect to the configured user interface
reverse( reverse(
"authentik_core:if-user", "authentik_core:root-redirect",
) )
+ f"#/settings;page-{self.source.slug}" + f"#/settings;page-{self.source.slug}"
) )

View file

@ -59,4 +59,6 @@ class TestImpersonation(TestCase):
self.client.force_login(self.other_user) self.client.force_login(self.other_user)
response = self.client.get(reverse("authentik_core:impersonate-end")) 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"})
)

View file

@ -3,27 +3,30 @@ from channels.auth import AuthMiddleware
from channels.sessions import CookieMiddleware from channels.sessions import CookieMiddleware
from django.conf import settings from django.conf import settings
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.http import HttpRequest, HttpResponse
from django.urls import path from django.urls import path
from django.views.decorators.csrf import ensure_csrf_cookie 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 import apps, impersonate
from authentik.core.views.debug import AccessDeniedView from authentik.core.views.debug import AccessDeniedView
from authentik.core.views.session import EndSessionView 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.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer from authentik.root.messages.consumer import MessageConsumer
def placeholder_view(request: HttpRequest, *args, **kwargs) -> HttpResponse: 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) return HttpResponse(status_code=200)
urlpatterns = [ urlpatterns = [
path( path(
"", "",
login_required( login_required(RedirectToInterface.as_view(type=InterfaceType.USER)),
RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True)
),
name="root-redirect", name="root-redirect",
), ),
path( path(

View file

@ -20,7 +20,8 @@ from authentik.flows.views.executor import (
SESSION_KEY_PLAN, SESSION_KEY_PLAN,
ToDefaultFlow, 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 ( from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER, PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS, PLAN_CONTEXT_CONSENT_PERMISSIONS,
@ -59,7 +60,7 @@ class RedirectToAppLaunch(View):
raise Http404 raise Http404
plan.insert_stage(in_memory_stage(RedirectToAppStage)) plan.insert_stage(in_memory_stage(RedirectToAppStage))
request.session[SESSION_KEY_PLAN] = plan 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): class RedirectToAppStage(ChallengeStageView):

View file

@ -35,7 +35,7 @@ class ImpersonateInitView(View):
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) 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): class ImpersonateEndView(View):
@ -48,7 +48,7 @@ class ImpersonateEndView(View):
or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session
): ):
LOGGER.debug("Can't end impersonation", user=request.user) 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] original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]

View file

@ -53,6 +53,8 @@ from authentik.flows.planner import (
FlowPlanner, FlowPlanner,
) )
from authentik.flows.stage import AccessDeniedChallengeView, StageView 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.sentry import SentryIgnoredException
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path from authentik.lib.utils.reflection import all_subclasses, class_to_path
@ -512,7 +514,7 @@ class ToDefaultFlow(View):
flow_slug=flow.slug, flow_slug=flow.slug,
) )
del self.request.session[SESSION_KEY_PLAN] 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: def to_stage_response(request: HttpRequest, source: HttpResponse) -> HttpResponse:
@ -583,8 +585,8 @@ class ConfigureFlowInitView(LoginRequiredMixin, View):
LOGGER.warning("Flow not applicable to user") LOGGER.warning("Flow not applicable to user")
raise Http404 raise Http404
request.session[SESSION_KEY_PLAN] = plan request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs( return redirect_to_default_interface(
"authentik_core:if-flow", self.request,
self.request.GET, InterfaceType.FLOW,
flow_slug=stage.configure_flow.slug, flow_slug=stage.configure_flow.slug,
) )

View file

@ -1,3 +1,4 @@
"""interfaces API"""
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
@ -5,6 +6,8 @@ from authentik.interfaces.models import Interface
class InterfaceSerializer(ModelSerializer): class InterfaceSerializer(ModelSerializer):
"""Interface serializer"""
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
@ -16,5 +19,8 @@ class InterfaceSerializer(ModelSerializer):
class InterfaceViewSet(ModelViewSet): class InterfaceViewSet(ModelViewSet):
"""Interface serializer"""
queryset = Interface.objects.all() queryset = Interface.objects.all()
serializer_class = InterfaceSerializer serializer_class = InterfaceSerializer
filterset_fields = ["url_name", "type", "template"]

View file

@ -20,7 +20,7 @@ class Migration(migrations.Migration):
default=uuid.uuid4, editable=False, primary_key=True, serialize=False default=uuid.uuid4, editable=False, primary_key=True, serialize=False
), ),
), ),
("url_name", models.SlugField()), ("url_name", models.SlugField(unique=True)),
( (
"type", "type",
models.TextField( models.TextField(

View file

@ -21,7 +21,7 @@ class Interface(SerializerModel):
interface_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4) 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) type = models.TextField(choices=InterfaceType.choices)
template = models.TextField() template = models.TextField()

View file

@ -10,7 +10,5 @@ urlpatterns = [
kwargs={"flow_slug": None}, kwargs={"flow_slug": None},
name="if", name="if",
), ),
path( path("<slug:if_name>/<slug:flow_slug>/", InterfaceView.as_view(), name="if"),
"<slug:if_name>/<slug:flow_slug>/", InterfaceView.as_view(), name="if"
),
] ]

View file

@ -1,23 +1,25 @@
"""Interface views""" """Interface views"""
from json import dumps from json import dumps
from typing import Any from typing import Any, Optional
from django.http import Http404, HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.template import Template, TemplateSyntaxError, engines from django.template import Template, TemplateSyntaxError, engines
from django.template.response import TemplateResponse 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.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 django.views.decorators.csrf import ensure_csrf_cookie
from rest_framework.request import Request
from authentik import get_build_hash from authentik import get_build_hash
from authentik.admin.tasks import LOCAL_VERSION from authentik.admin.tasks import LOCAL_VERSION
from authentik.api.v3.config import ConfigView from authentik.api.v3.config import ConfigView
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.interfaces.models import Interface, InterfaceType 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.api import CurrentTenantSerializer
from authentik.tenants.models import Tenant
def template_from_string(template_string: str) -> Template: 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) 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(ensure_csrf_cookie, name="dispatch")
@method_decorator(cache_page(60 * 10), name="dispatch") @method_decorator(cache_page(60 * 10), name="dispatch")
class InterfaceView(View): class InterfaceView(View):

View file

@ -22,4 +22,4 @@ class UseTokenView(View):
login(request, token.user, backend=BACKEND_INBUILT) login(request, token.user, backend=BACKEND_INBUILT)
token.delete() token.delete()
messages.warning(request, _("Used recovery-link to authenticate.")) messages.warning(request, _("Used recovery-link to authenticate."))
return redirect("authentik_core:if-user") return redirect("authentik_core:root-redirect")

View file

@ -72,7 +72,7 @@ class InitiateView(View):
# Ensure redirect is carried through when user was trying to # Ensure redirect is carried through when user was trying to
# authorize application # authorize application
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( 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( kwargs.update(
{ {

View file

@ -54,6 +54,9 @@ class TenantSerializer(ModelSerializer):
"flow_unenrollment", "flow_unenrollment",
"flow_user_settings", "flow_user_settings",
"flow_device_code", "flow_device_code",
"interface_admin",
"interface_user",
"interface_flow",
"event_retention", "event_retention",
"web_certificate", "web_certificate",
"attributes", "attributes",
@ -120,6 +123,9 @@ class TenantViewSet(UsedByMixin, ModelViewSet):
"flow_unenrollment", "flow_unenrollment",
"flow_user_settings", "flow_user_settings",
"flow_device_code", "flow_device_code",
"interface_admin",
"interface_user",
"interface_flow",
"event_retention", "event_retention",
"web_certificate", "web_certificate",
] ]

View file

@ -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),
]

View file

@ -8,6 +8,7 @@ from structlog.stdlib import get_logger
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.interfaces.models import Interface
from authentik.lib.models import SerializerModel from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator 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" 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( event_retention = models.TextField(
default="days=365", default="days=365",
validators=[timedelta_string_validator], validators=[timedelta_string_validator],

View file

@ -2,6 +2,11 @@ metadata:
name: Default - Tenant name: Default - Tenant
version: 1 version: 1
entries: entries:
- model: authentik_blueprints.metaapplyblueprint
attrs:
identifiers:
name: System - Interfaces
required: false
- model: authentik_blueprints.metaapplyblueprint - model: authentik_blueprints.metaapplyblueprint
attrs: attrs:
identifiers: identifiers:
@ -21,6 +26,9 @@ entries:
flow_authentication: !Find [authentik_flows.flow, [slug, default-authentication-flow]] flow_authentication: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]] flow_invalidation: !Find [authentik_flows.flow, [slug, default-invalidation-flow]]
flow_user_settings: !Find [authentik_flows.flow, [slug, default-user-settings-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: identifiers:
domain: authentik-default domain: authentik-default
default: True default: True