Compare commits

...
This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.

10 commits

Author SHA1 Message Date
Jens Langhammer a1c1c3a27c
fix lint
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:40:20 +03:00
Jens Langhammer c0262f0802
use wrapper for get_tenant, give fallback interfaces
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:37:34 +03:00
Jens Langhammer c6f8290ca1
hmm fallback tenant
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:35:48 +03:00
Jens Langhammer 905ae00e02
more fixes
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:35:48 +03:00
Jens Langhammer 3ec477d58d
fix more tests
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:35:48 +03:00
Jens Langhammer ff996f798f
start fixing tests
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:35:48 +03:00
Jens Langhammer 1889e82309
start fixing tests
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:35:32 +03:00
Jens Langhammer 48a4080699
add api and webui
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:35:32 +03:00
Jens Langhammer 246a6c7384
add tenant migration, migrate default urls and redirects
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:35:32 +03:00
Jens Langhammer e39c460e3a
initial interfaces
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-04-21 14:34:41 +03:00
60 changed files with 1478 additions and 170 deletions

View file

@ -18,6 +18,7 @@ from authentik.core.api.utils import PassiveSerializer
from authentik.lib.utils.reflection import get_env
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
from authentik.tenants.utils import get_tenant
class RuntimeDict(TypedDict):
@ -77,7 +78,7 @@ class SystemSerializer(PassiveSerializer):
def get_tenant(self, request: Request) -> str:
"""Currently active tenant"""
return str(request._request.tenant)
return str(get_tenant(request))
def get_server_time(self, request: Request) -> datetime:
"""Current server time"""

View file

@ -33,6 +33,7 @@ from authentik.flows.api.flows import FlowViewSet
from authentik.flows.api.stages import StageViewSet
from authentik.flows.views.executor import FlowExecutorView
from authentik.flows.views.inspector import FlowInspectorView
from authentik.interfaces.api import InterfaceViewSet
from authentik.outposts.api.outposts import OutpostViewSet
from authentik.outposts.api.service_connections import (
DockerServiceConnectionViewSet,
@ -123,6 +124,8 @@ router.register("core/user_consent", UserConsentViewSet)
router.register("core/tokens", TokenViewSet)
router.register("core/tenants", TenantViewSet)
router.register("interfaces", InterfaceViewSet)
router.register("outposts/instances", OutpostViewSet)
router.register("outposts/service_connections/all", ServiceConnectionViewSet)
router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet)

View file

@ -10,7 +10,6 @@ from django.db.models.functions import ExtractHour
from django.db.models.query import QuerySet
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from django.urls import reverse_lazy
from django.utils.http import urlencode
from django.utils.text import slugify
from django.utils.timezone import now
@ -72,10 +71,12 @@ from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.interfaces.models import InterfaceType
from authentik.interfaces.views import reverse_interface
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_tenant
LOGGER = get_logger()
@ -321,7 +322,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]:
"""Create a recovery link (when the current tenant has a recovery flow set),
that can either be shown to an admin or sent to the user directly"""
tenant: Tenant = self.request._request.tenant
tenant = get_tenant(self.request)
# Check that there is a recovery flow, if not return an error
flow = tenant.flow_recovery
if not flow:
@ -350,8 +351,12 @@ class UserViewSet(UsedByMixin, ModelViewSet):
)
querystring = urlencode({QS_KEY_TOKEN: token.key})
link = self.request.build_absolute_uri(
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
+ f"?{querystring}"
reverse_interface(
self.request,
InterfaceType.FLOW,
flow_slug=flow.slug,
),
+f"?{querystring}",
)
return link, token

View file

@ -33,6 +33,7 @@ from authentik.lib.models import (
)
from authentik.lib.utils.http import get_client_ip
from authentik.policies.models import PolicyBindingModel
from authentik.tenants.utils import get_tenant
LOGGER = get_logger()
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
@ -168,7 +169,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
including the users attributes"""
final_attributes = {}
if request and hasattr(request, "tenant"):
always_merger.merge(final_attributes, request.tenant.attributes)
always_merger.merge(final_attributes, get_tenant(request).attributes)
for group in self.ak_groups.all().order_by("name"):
always_merger.merge(final_attributes, group.attributes)
always_merger.merge(final_attributes, self.attributes)
@ -227,7 +228,7 @@ class User(SerializerModel, GuardianUserMixin, AbstractUser):
except Exception as exc:
LOGGER.warning("Failed to get default locale", exc=exc)
if request:
return request.tenant.locale
return get_tenant(request).default_locale
return ""
@property

View file

@ -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}"
)

View file

@ -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"})
)

View file

@ -3,23 +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 authentik.core.views import apps, impersonate
from authentik.core.views.debug import AccessDeniedView
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
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(
@ -40,31 +47,16 @@ urlpatterns = [
name="impersonate-end",
),
# Interfaces
path(
"if/admin/",
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/admin.html")),
name="if-admin",
),
path(
"if/user/",
ensure_csrf_cookie(InterfaceView.as_view(template_name="if/user.html")),
name="if-user",
),
path(
"if/flow/<slug:flow_slug>/",
ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow",
),
path(
"if/session-end/<slug:application_slug>/",
ensure_csrf_cookie(EndSessionView.as_view()),
name="if-session-end",
),
# Fallback for WS
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
path("ws/outpost/<uuid:pk>/", placeholder_view),
path(
"ws/client/",
InterfaceView.as_view(template_name="if/admin.html"),
placeholder_view,
),
]

View file

@ -20,11 +20,13 @@ 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,
)
from authentik.tenants.utils import get_tenant
class RedirectToAppLaunch(View):
@ -59,7 +61,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):

View file

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

View file

@ -1,36 +0,0 @@
"""Interface views"""
from json import dumps
from typing import Any
from django.shortcuts import get_object_or_404
from django.views.generic.base import TemplateView
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.tenants.api import CurrentTenantSerializer
class InterfaceView(TemplateView):
"""Base interface view"""
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["config_json"] = dumps(ConfigView(request=Request(self.request)).get_config().data)
kwargs["tenant_json"] = dumps(CurrentTenantSerializer(self.request.tenant).data)
kwargs["version_family"] = f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}"
kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
kwargs["build"] = get_build_hash()
return super().get_context_data(**kwargs)
class FlowInterfaceView(InterfaceView):
"""Flow interface"""
template_name = "if/flow.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs)

View file

@ -41,8 +41,7 @@ from authentik.lib.utils.http import get_client_ip, get_http_session
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant
from authentik.tenants.utils import DEFAULT_TENANT
from authentik.tenants.utils import get_fallback_tenant, get_tenant
LOGGER = get_logger()
if TYPE_CHECKING:
@ -57,7 +56,7 @@ def default_event_duration():
def default_tenant():
"""Get a default value for tenant"""
return sanitize_dict(model_to_dict(DEFAULT_TENANT))
return sanitize_dict(model_to_dict(get_fallback_tenant()))
class NotificationTransportError(SentryIgnoredException):
@ -227,7 +226,7 @@ class Event(SerializerModel, ExpiringModel):
wrapped = self.context["http_request"]["args"][QS_QUERY]
self.context["http_request"]["args"] = QueryDict(wrapped)
if hasattr(request, "tenant"):
tenant: Tenant = request.tenant
tenant = get_tenant(request)
# Because self.created only gets set on save, we can't use it's value here
# hence we set self.created to now and then use it
self.created = now()

View file

@ -25,6 +25,8 @@ from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import Flow
from authentik.flows.planner import CACHE_PREFIX, PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
from authentik.flows.views.executor import SESSION_KEY_HISTORY, SESSION_KEY_PLAN
from authentik.interfaces.models import InterfaceType
from authentik.interfaces.views import reverse_interface
from authentik.lib.utils.file import (
FilePathSerializer,
FileUploadSerializer,
@ -294,7 +296,11 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
return Response(
{
"link": request._request.build_absolute_uri(
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
reverse_interface(
request,
InterfaceType.FLOW,
flow_slug=flow.slug,
),
)
}
)

View file

@ -7,6 +7,8 @@ from authentik.core.tests.utils import create_test_flow
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_PLAN
from authentik.interfaces.models import InterfaceType
from authentik.interfaces.tests import reverse_interface
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider
@ -21,7 +23,10 @@ class TestHelperView(TestCase):
response = self.client.get(
reverse("authentik_flows:default-invalidation"),
)
expected_url = reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
expected_url = reverse_interface(
InterfaceType.FLOW,
flow_slug=flow.slug,
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, expected_url)
@ -72,6 +77,9 @@ class TestHelperView(TestCase):
response = self.client.get(
reverse("authentik_flows:default-invalidation"),
)
expected_url = reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
expected_url = reverse_interface(
InterfaceType.FLOW,
flow_slug=flow.slug,
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, expected_url)

View file

@ -53,12 +53,14 @@ 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
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
from authentik.policies.engine import PolicyEngine
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_tenant
LOGGER = get_logger()
# Argument used to redirect user after login
@ -479,7 +481,7 @@ class ToDefaultFlow(View):
def get_flow(self) -> Flow:
"""Get a flow for the selected designation"""
tenant: Tenant = self.request.tenant
tenant = get_tenant(self.request)
flow = None
# First, attempt to get default flow from tenant
if self.designation == FlowDesignation.AUTHENTICATION:
@ -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,
)

View file

View file

@ -0,0 +1,28 @@
"""interfaces API"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.interfaces.models import Interface
class InterfaceSerializer(ModelSerializer):
"""Interface serializer"""
class Meta:
model = Interface
fields = [
"interface_uuid",
"url_name",
"type",
"template",
]
class InterfaceViewSet(UsedByMixin, ModelViewSet):
"""Interface serializer"""
queryset = Interface.objects.all()
serializer_class = InterfaceSerializer
filterset_fields = ["url_name", "type", "template"]
search_fields = ["url_name", "type", "template"]

View file

@ -0,0 +1,12 @@
"""authentik interfaces app config"""
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikInterfacesConfig(ManagedAppConfig):
"""authentik interfaces app config"""
name = "authentik.interfaces"
label = "authentik_interfaces"
verbose_name = "authentik Interfaces"
mountpoint = "if/"
default = True

View file

@ -0,0 +1,36 @@
# Generated by Django 4.1.7 on 2023-02-16 11:01
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Interface",
fields=[
(
"interface_uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("url_name", models.SlugField(unique=True)),
(
"type",
models.TextField(
choices=[("user", "User"), ("admin", "Admin"), ("flow", "Flow")]
),
),
("template", models.TextField()),
],
options={
"abstract": False,
},
),
]

View file

@ -0,0 +1,33 @@
"""Interface models"""
from typing import Type
from uuid import uuid4
from django.db import models
from rest_framework.serializers import BaseSerializer
from authentik.lib.models import SerializerModel
class InterfaceType(models.TextChoices):
"""Interface types"""
USER = "user"
ADMIN = "admin"
FLOW = "flow"
class Interface(SerializerModel):
"""Interface"""
interface_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
url_name = models.SlugField(unique=True)
type = models.TextField(choices=InterfaceType.choices)
template = models.TextField()
@property
def serializer(self) -> Type[BaseSerializer]:
from authentik.interfaces.api import InterfaceSerializer
return InterfaceSerializer

View file

@ -0,0 +1,12 @@
"""Interface tests"""
from django.test import RequestFactory
from authentik.interfaces.models import InterfaceType
from authentik.interfaces.views import reverse_interface as full_reverse_interface
def reverse_interface(interface_type: InterfaceType, **kwargs):
"""reverse_interface wrapper for tests"""
factory = RequestFactory()
request = factory.get("/")
return full_reverse_interface(request, interface_type, **kwargs)

View file

@ -0,0 +1,14 @@
"""Interface urls"""
from django.urls import path
from authentik.interfaces.views import InterfaceView
urlpatterns = [
path(
"<slug:if_name>/",
InterfaceView.as_view(),
kwargs={"flow_slug": None},
name="if",
),
path("<slug:if_name>/<slug:flow_slug>/", InterfaceView.as_view(), name="if"),
]

View file

@ -0,0 +1,113 @@
"""Interface views"""
from json import dumps
from typing import Any, Optional
from urllib.parse import urlencode
from django.http import Http404, HttpRequest, HttpResponse, QueryDict
from django.shortcuts import get_object_or_404, redirect
from django.template import Template, TemplateSyntaxError, engines
from django.template.response import TemplateResponse
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 structlog.stdlib import get_logger
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 reverse_with_qs
from authentik.tenants.api import CurrentTenantSerializer
from authentik.tenants.utils import get_tenant
LOGGER = get_logger()
def template_from_string(template_string: str) -> Template:
"""Render template from string"""
chain = []
engine_list = engines.all()
for engine in engine_list:
try:
return engine.from_string(template_string)
except TemplateSyntaxError as exc:
chain.append(exc)
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)
def reverse_interface(
request: HttpRequest, interface_type: InterfaceType, query: Optional[QueryDict] = None, **kwargs
):
"""Reverse URL to configured default interface"""
tenant = get_tenant(request)
interface: Interface = None
if interface_type == InterfaceType.USER:
interface = tenant.interface_user
if interface_type == InterfaceType.ADMIN:
interface = tenant.interface_admin
if interface_type == InterfaceType.FLOW:
interface = tenant.interface_flow
if not interface:
LOGGER.warning("No interface found", type=interface_type, tenant=tenant)
raise Http404()
kwargs["if_name"] = interface.url_name
return reverse_with_qs(
"authentik_interfaces:if",
query=query or request.GET,
kwargs=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:
target = reverse_interface(request, self.type, **kwargs)
if self.request.GET:
target += "?" + urlencode(self.request.GET.items())
return redirect(target)
@method_decorator(ensure_csrf_cookie, name="dispatch")
@method_decorator(cache_page(60 * 10), name="dispatch")
class InterfaceView(View):
"""General interface view"""
def get_context_data(self) -> dict[str, Any]:
"""Get template context"""
return {
"config_json": dumps(ConfigView(request=Request(self.request)).get_config().data),
"tenant_json": dumps(CurrentTenantSerializer(get_tenant(self.request)).data),
"version_family": f"{LOCAL_VERSION.major}.{LOCAL_VERSION.minor}",
"version_subdomain": f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}",
"build": get_build_hash(),
}
def type_flow(self, context: dict[str, Any]):
"""Special handling for flow interfaces"""
if self.kwargs.get("flow_slug", None) is None:
raise Http404()
context["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
context["inspector"] = "inspector" in self.request.GET
def dispatch(self, request: HttpRequest, if_name: str, **kwargs: Any) -> HttpResponse:
context = self.get_context_data()
# TODO: Cache
interface: Interface = get_object_or_404(Interface, url_name=if_name)
if interface.type == InterfaceType.FLOW:
self.type_flow(context)
template = template_from_string(interface.template)
return TemplateResponse(request, template, context)

View file

@ -12,6 +12,7 @@ from authentik.lib.utils.http import get_http_session
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.tenants.utils import get_tenant
LOGGER = get_logger()
RE_LOWER = re.compile("[a-z]")
@ -143,7 +144,8 @@ class PasswordPolicy(Policy):
user_inputs.append(request.user.name)
user_inputs.append(request.user.email)
if request.http_request:
user_inputs.append(request.http_request.tenant.branding_title)
tenant = get_tenant(request.http_request)
user_inputs.append(tenant.branding_title)
# Only calculate result for the first 100 characters, as with over 100 char
# long passwords we can be reasonably sure that they'll surpass the score anyways
# See https://github.com/dropbox/zxcvbn#runtime-latency

View file

@ -39,8 +39,9 @@ class TesOAuth2DeviceInit(OAuthTestCase):
self.assertEqual(
res.url,
reverse(
"authentik_core:if-flow",
"authentik_interfaces:if",
kwargs={
"if_name": "flow",
"flow_slug": self.device_flow.slug,
},
),
@ -68,8 +69,9 @@ class TesOAuth2DeviceInit(OAuthTestCase):
self.assertEqual(
res.url,
reverse(
"authentik_core:if-flow",
"authentik_interfaces:if",
kwargs={
"if_name": "flow",
"flow_slug": self.provider.authorization_flow.slug,
},
)

View file

@ -29,8 +29,9 @@ from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.stage import StageView
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.interfaces.models import InterfaceType
from authentik.interfaces.views import redirect_to_default_interface
from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.utils.urls import redirect_with_qs
from authentik.lib.views import bad_request_message
from authentik.policies.types import PolicyRequest
from authentik.policies.views import PolicyAccessView, RequestValidationError
@ -404,9 +405,9 @@ class AuthorizationFlowInitView(PolicyAccessView):
plan.append_stage(in_memory_stage(OAuthFulfillmentStage))
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=self.provider.authorization_flow.slug,
)

View file

@ -15,7 +15,8 @@ from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import 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.providers.oauth2.models import DeviceToken, OAuth2Provider
from authentik.providers.oauth2.views.device_finish import (
PLAN_CONTEXT_DEVICE,
@ -26,7 +27,7 @@ from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS,
)
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_tenant
LOGGER = get_logger()
QS_KEY_CODE = "code" # nosec
@ -77,9 +78,9 @@ def validate_code(code: int, request: HttpRequest) -> Optional[HttpResponse]:
return None
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
request.GET,
return redirect_to_default_interface(
request,
InterfaceType.FLOW,
flow_slug=token.provider.authorization_flow.slug,
)
@ -88,7 +89,7 @@ class DeviceEntryView(View):
"""View used to initiate the device-code flow, url entered by endusers"""
def dispatch(self, request: HttpRequest) -> HttpResponse:
tenant: Tenant = request.tenant
tenant = get_tenant(request)
device_flow = tenant.flow_device_code
if not device_flow:
LOGGER.info("Tenant has no device code flow configured", tenant=tenant)
@ -110,9 +111,9 @@ class DeviceEntryView(View):
plan.append_stage(in_memory_stage(OAuthDeviceCodeStage))
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=device_flow.slug,
)

View file

@ -9,6 +9,7 @@ from django.views.decorators.csrf import csrf_exempt
from authentik.providers.oauth2.constants import SCOPE_GITHUB_ORG_READ, SCOPE_GITHUB_USER_EMAIL
from authentik.providers.oauth2.models import RefreshToken
from authentik.providers.oauth2.utils import protected_resource_view
from authentik.tenants.utils import get_tenant
@method_decorator(csrf_exempt, name="dispatch")
@ -76,6 +77,7 @@ class GitHubUserTeamsView(View):
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
"""Emulate GitHub's /user/teams API Endpoint"""
user = token.user
tenant = get_tenant(request)
orgs_response = []
for org in user.ak_groups.all():
@ -97,7 +99,7 @@ class GitHubUserTeamsView(View):
"created_at": "",
"updated_at": "",
"organization": {
"login": slugify(request.tenant.branding_title),
"login": slugify(tenant.branding_title),
"id": 1,
"node_id": "",
"url": "",
@ -109,7 +111,7 @@ class GitHubUserTeamsView(View):
"public_members_url": "",
"avatar_url": "",
"description": "",
"name": request.tenant.branding_title,
"name": tenant.branding_title,
"company": "",
"blog": "",
"location": "",

View file

@ -15,7 +15,8 @@ from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.views.executor import SESSION_KEY_PLAN, SESSION_KEY_POST
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.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion
@ -76,9 +77,9 @@ class SAMLSSOView(PolicyAccessView):
raise Http404
plan.append_stage(in_memory_stage(SAMLFlowFinalView))
request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
request.GET,
return redirect_to_default_interface(
request,
InterfaceType.FLOW,
flow_slug=self.provider.authorization_flow.slug,
)

View file

@ -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")

View file

@ -65,6 +65,7 @@ INSTALLED_APPS = [
"authentik.admin",
"authentik.api",
"authentik.crypto",
"authentik.interfaces",
"authentik.events",
"authentik.flows",
"authentik.lib",

View file

@ -32,7 +32,8 @@ from authentik.flows.planner import (
)
from authentik.flows.stage import ChallengeStageView
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.providers.saml.utils.encoding import nice64
from authentik.sources.saml.exceptions import MissingSAMLResponse, UnsupportedNameIDFormat
@ -72,7 +73,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(
{
@ -91,9 +92,9 @@ class InitiateView(View):
for stage in stages_to_append:
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=source.pre_authentication_flow.slug,
)

View file

@ -17,6 +17,7 @@ from authentik.flows.challenge import (
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
from authentik.stages.authenticator_totp.settings import OTP_TOTP_ISSUER
from authentik.tenants.utils import get_tenant
SESSION_TOTP_DEVICE = "totp_device"
@ -57,7 +58,7 @@ class AuthenticatorTOTPStageView(ChallengeStageView):
data={
"type": ChallengeTypes.NATIVE.value,
"config_url": device.config_url.replace(
OTP_TOTP_ISSUER, quote(self.request.tenant.branding_title)
OTP_TOTP_ISSUER, quote(get_tenant(self.request).branding_title)
),
}
)

View file

@ -33,6 +33,7 @@ from authentik.stages.authenticator_validate.models import AuthenticatorValidate
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
from authentik.tenants.utils import get_tenant
LOGGER = get_logger()
@ -187,7 +188,7 @@ def validate_challenge_duo(device_pk: int, stage_view: StageView, user: User) ->
type=__(
"%(brand_name)s Login request"
% {
"brand_name": stage_view.request.tenant.branding_title,
"brand_name": get_tenant(stage_view.request).branding_title,
}
),
display_username=user.username,

View file

@ -19,7 +19,7 @@ from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, Duo
from authentik.stages.authenticator_validate.challenge import validate_challenge_duo
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.user_login.models import UserLoginStage
from authentik.tenants.utils import get_tenant_for_request
from authentik.tenants.utils import lookup_tenant_for_request
class AuthenticatorValidateStageDuoTests(FlowTestCase):
@ -36,7 +36,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase):
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session.save()
setattr(request, "tenant", get_tenant_for_request(request))
setattr(request, "tenant", lookup_tenant_for_request(request))
stage = AuthenticatorDuoStage.objects.create(
name=generate_id(),

View file

@ -29,6 +29,7 @@ from authentik.flows.challenge import (
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_webauthn.models import AuthenticateWebAuthnStage, WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
from authentik.tenants.utils import get_tenant
SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge"
@ -92,7 +93,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
registration_options: PublicKeyCredentialCreationOptions = generate_registration_options(
rp_id=get_rp_id(self.request),
rp_name=self.request.tenant.branding_title,
rp_name=get_tenant(self.request).branding_title,
user_id=user.uid,
user_name=user.username,
user_display_name=user.name,

View file

@ -3,7 +3,6 @@ from datetime import timedelta
from django.contrib import messages
from django.http import HttpRequest, HttpResponse
from django.urls import reverse
from django.utils.http import urlencode
from django.utils.text import slugify
from django.utils.timezone import now
@ -16,6 +15,8 @@ from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.interfaces.models import InterfaceType
from authentik.interfaces.views import reverse_interface
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
@ -47,9 +48,10 @@ class EmailStageView(ChallengeStageView):
def get_full_url(self, **kwargs) -> str:
"""Get full URL to be used in template"""
base_url = reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": self.executor.flow.slug},
base_url = reverse_interface(
self.request,
InterfaceType.FLOW,
flow_slug=self.executor.flow.slug,
)
relative_url = f"{base_url}?{urlencode(kwargs)}"
return self.request.build_absolute_uri(relative_url)

View file

@ -7,6 +7,7 @@ from django.core.mail.backends.locmem import EmailBackend
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.blueprints.tests import apply_blueprint
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.models import Event, EventAction
from authentik.flows.markers import StageMarker
@ -29,6 +30,7 @@ class TestEmailStageSending(APITestCase):
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
@apply_blueprint("system/interfaces.yaml")
def test_pending_user(self):
"""Test with pending user"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
@ -54,6 +56,7 @@ class TestEmailStageSending(APITestCase):
self.assertEqual(event.context["to_email"], [self.user.email])
self.assertEqual(event.context["from_email"], "system@authentik.local")
@apply_blueprint("system/interfaces.yaml")
def test_send_error(self):
"""Test error during sending (sending will be retried)"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])

View file

@ -7,6 +7,7 @@ from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
from django.urls import reverse
from django.utils.http import urlencode
from authentik.blueprints.tests import apply_blueprint
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
@ -74,6 +75,7 @@ class TestEmailStage(FlowTestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
@apply_blueprint("system/interfaces.yaml")
@patch(
"authentik.stages.email.models.EmailStage.backend_class",
PropertyMock(return_value=EmailBackend),
@ -123,6 +125,7 @@ class TestEmailStage(FlowTestCase):
with self.settings(EMAIL_HOST=host):
self.assertEqual(EmailStage(use_global_settings=True).backend.host, host)
@apply_blueprint("system/interfaces.yaml")
def test_token(self):
"""Test with token"""
# Make sure token exists

View file

@ -26,8 +26,9 @@ from authentik.flows.models import FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_GET
from authentik.interfaces.models import InterfaceType
from authentik.interfaces.views import reverse_interface
from authentik.lib.utils.http import get_client_ip
from authentik.lib.utils.urls import reverse_with_qs
from authentik.sources.oauth.types.apple import AppleLoginChallenge
from authentik.sources.plex.models import PlexAuthenticationChallenge
from authentik.stages.identification.models import IdentificationStage
@ -205,22 +206,25 @@ class IdentificationStageView(ChallengeStageView):
get_qs = self.request.session.get(SESSION_KEY_GET, self.request.GET)
# Check for related enrollment and recovery flow, add URL to view
if current_stage.enrollment_flow:
challenge.initial_data["enroll_url"] = reverse_with_qs(
"authentik_core:if-flow",
challenge.initial_data["enroll_url"] = reverse_interface(
self.request,
InterfaceType.FLOW,
query=get_qs,
kwargs={"flow_slug": current_stage.enrollment_flow.slug},
flow_slug=current_stage.enrollment_flow.slug,
)
if current_stage.recovery_flow:
challenge.initial_data["recovery_url"] = reverse_with_qs(
"authentik_core:if-flow",
challenge.initial_data["recovery_url"] = reverse_interface(
self.request,
InterfaceType.FLOW,
query=get_qs,
kwargs={"flow_slug": current_stage.recovery_flow.slug},
flow_slug=current_stage.recovery_flow.slug,
)
if current_stage.passwordless_flow:
challenge.initial_data["passwordless_url"] = reverse_with_qs(
"authentik_core:if-flow",
challenge.initial_data["passwordless_url"] = reverse_interface(
self.request,
InterfaceType.FLOW,
query=get_qs,
kwargs={"flow_slug": current_stage.passwordless_flow.slug},
flow_slug=current_stage.passwordless_flow.slug,
)
# Check all enabled source, add them if they have a UI Login button.

View file

@ -5,6 +5,8 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.challenge import ChallengeTypes
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.tests import FlowTestCase
from authentik.interfaces.models import InterfaceType
from authentik.interfaces.tests import reverse_interface
from authentik.sources.oauth.models import OAuthSource
from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.password import BACKEND_INBUILT
@ -166,9 +168,9 @@ class TestIdentificationStage(FlowTestCase):
component="ak-stage-identification",
user_fields=["email"],
password_fields=False,
enroll_url=reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": flow.slug},
enroll_url=reverse_interface(
InterfaceType.FLOW,
flow_slug=flow.slug,
),
show_source_labels=False,
primary_action="Log in",
@ -204,9 +206,9 @@ class TestIdentificationStage(FlowTestCase):
component="ak-stage-identification",
user_fields=["email"],
password_fields=False,
recovery_url=reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": flow.slug},
recovery_url=reverse_interface(
InterfaceType.FLOW,
flow_slug=flow.slug,
),
show_source_labels=False,
primary_action="Log in",

View file

@ -5,7 +5,6 @@ from django.contrib.auth import _clean_credentials
from django.contrib.auth.backends import BaseBackend
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest, HttpResponse
from django.urls import reverse
from django.utils.translation import gettext as _
from rest_framework.exceptions import ErrorDetail, ValidationError
from rest_framework.fields import CharField
@ -23,6 +22,8 @@ from authentik.flows.challenge import (
from authentik.flows.models import Flow, FlowDesignation, Stage
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
from authentik.interfaces.models import InterfaceType
from authentik.interfaces.views import reverse_interface
from authentik.lib.utils.reflection import path_to_class
from authentik.stages.password.models import PasswordStage
@ -95,11 +96,12 @@ class PasswordStageView(ChallengeStageView):
"type": ChallengeTypes.NATIVE.value,
}
)
recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY)
if recovery_flow.exists():
recover_url = reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": recovery_flow.first().slug},
recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY).first()
if recovery_flow:
recover_url = reverse_interface(
self.request,
InterfaceType.FLOW,
flow_slug=recovery_flow.slug,
)
challenge.initial_data["recovery_url"] = self.request.build_absolute_uri(recover_url)
return challenge

View file

@ -18,6 +18,7 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
from authentik.lib.config import CONFIG
from authentik.tenants.models import Tenant
from authentik.tenants.utils import get_tenant
class FooterLinkSerializer(PassiveSerializer):
@ -54,6 +55,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 +124,9 @@ class TenantViewSet(UsedByMixin, ModelViewSet):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"interface_admin",
"interface_user",
"interface_flow",
"event_retention",
"web_certificate",
]
@ -133,5 +140,4 @@ class TenantViewSet(UsedByMixin, ModelViewSet):
@action(methods=["GET"], detail=False, permission_classes=[AllowAny])
def current(self, request: Request) -> Response:
"""Get current tenant"""
tenant: Tenant = request._request.tenant
return Response(CurrentTenantSerializer(tenant).data)
return Response(CurrentTenantSerializer(get_tenant(request)).data)

View file

@ -6,7 +6,7 @@ from django.http.response import HttpResponse
from django.utils.translation import activate
from sentry_sdk.api import set_tag
from authentik.tenants.utils import get_tenant_for_request
from authentik.tenants.utils import lookup_tenant_for_request
class TenantMiddleware:
@ -19,7 +19,7 @@ class TenantMiddleware:
def __call__(self, request: HttpRequest) -> HttpResponse:
if not hasattr(request, "tenant"):
tenant = get_tenant_for_request(request)
tenant = lookup_tenant_for_request(request)
setattr(request, "tenant", tenant)
set_tag("authentik.tenant_uuid", tenant.tenant_uuid.hex)
set_tag("authentik.tenant_domain", tenant.domain)

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_discovery
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_discovery.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

@ -7,7 +7,6 @@ from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
@ -33,22 +32,59 @@ class Tenant(SerializerModel):
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
flow_authentication = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_authentication"
"authentik_flows.Flow",
null=True,
on_delete=models.SET_NULL,
related_name="tenant_authentication",
)
flow_invalidation = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_invalidation"
"authentik_flows.Flow",
null=True,
on_delete=models.SET_NULL,
related_name="tenant_invalidation",
)
flow_recovery = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_recovery"
"authentik_flows.Flow", null=True, on_delete=models.SET_NULL, related_name="tenant_recovery"
)
flow_unenrollment = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_unenrollment"
"authentik_flows.Flow",
null=True,
on_delete=models.SET_NULL,
related_name="tenant_unenrollment",
)
flow_user_settings = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_user_settings"
"authentik_flows.Flow",
null=True,
on_delete=models.SET_NULL,
related_name="tenant_user_settings",
)
flow_device_code = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="tenant_device_code"
"authentik_flows.Flow",
null=True,
on_delete=models.SET_NULL,
related_name="tenant_device_code",
)
interface_flow = models.ForeignKey(
"authentik_interfaces.Interface",
default=None,
on_delete=models.SET_NULL,
null=True,
related_name="tenant_flow",
)
interface_user = models.ForeignKey(
"authentik_interfaces.Interface",
default=None,
on_delete=models.SET_NULL,
null=True,
related_name="tenant_user",
)
interface_admin = models.ForeignKey(
"authentik_interfaces.Interface",
default=None,
on_delete=models.SET_NULL,
null=True,
related_name="tenant_admin",
)
event_retention = models.TextField(

View file

@ -75,7 +75,7 @@ class TestTenants(APITestCase):
)
factory = RequestFactory()
request = factory.get("/")
request.tenant = tenant
setattr(request, "tenant", tenant)
event = Event.new(action=EventAction.SYSTEM_EXCEPTION, message="test").from_http(request)
self.assertEqual(event.expires.day, (event.created + timedelta_from_string("weeks=3")).day)
self.assertEqual(

View file

@ -4,17 +4,41 @@ from typing import Any
from django.db.models import F, Q
from django.db.models import Value as V
from django.http.request import HttpRequest
from rest_framework.request import Request
from sentry_sdk.hub import Hub
from authentik import get_full_version
from authentik.interfaces.models import Interface, InterfaceType
from authentik.lib.config import CONFIG
from authentik.tenants.models import Tenant
_q_default = Q(default=True)
DEFAULT_TENANT = Tenant(domain="fallback")
def get_tenant_for_request(request: HttpRequest) -> Tenant:
def get_fallback_tenant():
"""Get fallback tenant"""
fallback_interface = Interface(
url_name="fallback",
type=InterfaceType.FLOW,
template="Fallback interface",
)
return Tenant(
domain="fallback",
interface_flow=fallback_interface,
interface_user=fallback_interface,
interface_admin=fallback_interface,
)
def get_tenant(request: HttpRequest | Request) -> "Tenant":
"""Get the request's tenant, falls back to a fallback tenant object"""
if isinstance(request, Request):
request = request._request
return getattr(request, "tenant", get_fallback_tenant())
def lookup_tenant_for_request(request: HttpRequest) -> "Tenant":
"""Get tenant object for current request"""
db_tenants = (
Tenant.objects.annotate(host_domain=V(request.get_host()))
@ -23,13 +47,13 @@ def get_tenant_for_request(request: HttpRequest) -> Tenant:
)
tenants = list(db_tenants.all())
if len(tenants) < 1:
return DEFAULT_TENANT
return get_fallback_tenant()
return tenants[0]
def context_processor(request: HttpRequest) -> dict[str, Any]:
"""Context Processor that injects tenant object into every template"""
tenant = getattr(request, "tenant", DEFAULT_TENANT)
tenant = getattr(request, "tenant", get_fallback_tenant())
trace = ""
span = Hub.current.scope.span
if span:

View file

@ -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

View file

@ -61,6 +61,7 @@
"authentik_events.notificationwebhookmapping",
"authentik_flows.flow",
"authentik_flows.flowstagebinding",
"authentik_interfaces.interface",
"authentik_outposts.dockerserviceconnection",
"authentik_outposts.kubernetesserviceconnection",
"authentik_outposts.outpost",

View file

@ -0,0 +1,139 @@
version: 1
metadata:
labels:
blueprints.goauthentik.io/system: "true"
name: System - Interfaces
entries:
- model: authentik_interfaces.interface
identifiers:
url_name: user
type: user
attrs:
template: |
{% extends "base/skeleton.html" %}
{% load static %}
{% load i18n %}
{% block head %}
<script src="{% static 'dist/user/UserInterface.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#151515" media="(prefers-color-scheme: dark)">
<link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
{% include "base/header_js.html" %}
{% endblock %}
{% block body %}
<ak-message-container></ak-message-container>
<ak-interface-user>
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
<div class="pf-c-empty-state" style="height: 100vh;">
<div class="pf-c-empty-state__content">
<span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
<h1 class="pf-c-title pf-m-lg">
{% trans "Loading..." %}
</h1>
</div>
</div>
</section>
</ak-interface-user>
{% endblock %}
- model: authentik_interfaces.interface
identifiers:
url_name: admin
type: admin
attrs:
template: |
{% extends "base/skeleton.html" %}
{% load static %}
{% load i18n %}
{% block head %}
<script src="{% static 'dist/admin/AdminInterface.js' %}?version={{ version }}" type="module"></script>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
<link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
{% include "base/header_js.html" %}
{% endblock %}
{% block body %}
<ak-message-container></ak-message-container>
<ak-interface-admin>
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
<div class="pf-c-empty-state" style="height: 100vh;">
<div class="pf-c-empty-state__content">
<span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
<h1 class="pf-c-title pf-m-lg">
{% trans "Loading..." %}
</h1>
</div>
</div>
</section>
</ak-interface-admin>
{% endblock %}
- model: authentik_interfaces.interface
identifiers:
url_name: flow
type: flow
attrs:
template: |
{% extends "base/skeleton.html" %}
{% load static %}
{% load i18n %}
{% block head_before %}
{{ block.super }}
<link rel="prefetch" href="{{ flow.background_url }}" />
<link rel="icon" href="{{ tenant.branding_favicon }}">
<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
{% if flow.compatibility_mode and not inspector %}
<script>ShadyDOM = { force: !navigator.webdriver };</script>
{% endif %}
{% include "base/header_js.html" %}
<script>
window.authentik.flow = {
"layout": "{{ flow.layout }}",
};
</script>
{% endblock %}
{% block head %}
<script src="{% static 'dist/flow/FlowInterface.js' %}?version={{ version }}" type="module"></script>
<style>
:root {
--ak-flow-background: url("{{ flow.background_url }}");
}
</style>
{% endblock %}
{% block body %}
<ak-message-container></ak-message-container>
<ak-flow-executor>
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
<div class="pf-c-empty-state" style="height: 100vh;">
<div class="pf-c-empty-state__content">
<span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
<h1 class="pf-c-title pf-m-lg">
{% trans "Loading..." %}
</h1>
</div>
</div>
</section>
</ak-flow-executor>
{% endblock %}

View file

@ -3676,6 +3676,24 @@ paths:
description: flow_user_settings
schema:
type: string
- name: interface_admin
required: false
in: query
description: interface_admin
schema:
type: string
- name: interface_flow
required: false
in: query
description: interface_flow
schema:
type: string
- name: interface_user
required: false
in: query
description: interface_user
schema:
type: string
- name: ordering
required: false
in: query
@ -7731,6 +7749,295 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/interfaces/:
get:
operationId: interfaces_list
description: Interface serializer
parameters:
- name: ordering
required: false
in: query
description: Which field to use when ordering the results.
schema:
type: string
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- name: search
required: false
in: query
description: A search term.
schema:
type: string
- in: query
name: template
schema:
type: string
- in: query
name: type
schema:
type: string
enum:
- admin
- flow
- user
description: |-
* `user` - User
* `admin` - Admin
* `flow` - Flow
* `user` - User
* `admin` - Admin
* `flow` - Flow
- in: query
name: url_name
schema:
type: string
tags:
- interfaces
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedInterfaceList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
post:
operationId: interfaces_create
description: Interface serializer
tags:
- interfaces
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InterfaceRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/Interface'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/interfaces/{interface_uuid}/:
get:
operationId: interfaces_retrieve
description: Interface serializer
parameters:
- in: path
name: interface_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this interface.
required: true
tags:
- interfaces
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Interface'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
put:
operationId: interfaces_update
description: Interface serializer
parameters:
- in: path
name: interface_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this interface.
required: true
tags:
- interfaces
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InterfaceRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Interface'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
patch:
operationId: interfaces_partial_update
description: Interface serializer
parameters:
- in: path
name: interface_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this interface.
required: true
tags:
- interfaces
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedInterfaceRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Interface'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
delete:
operationId: interfaces_destroy
description: Interface serializer
parameters:
- in: path
name: interface_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this interface.
required: true
tags:
- interfaces
security:
- authentik: []
responses:
'204':
description: No response body
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/interfaces/{interface_uuid}/used_by/:
get:
operationId: interfaces_used_by_list
description: Get a list of all objects that use this object
parameters:
- in: path
name: interface_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this interface.
required: true
tags:
- interfaces
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UsedBy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/managed/blueprints/:
get:
operationId: managed_blueprints_list
@ -26307,6 +26614,7 @@ components:
- authentik.admin
- authentik.api
- authentik.crypto
- authentik.interfaces
- authentik.events
- authentik.flows
- authentik.lib
@ -26357,6 +26665,7 @@ components:
* `authentik.admin` - authentik Admin
* `authentik.api` - authentik API
* `authentik.crypto` - authentik Crypto
* `authentik.interfaces` - authentik Interfaces
* `authentik.events` - authentik Events
* `authentik.flows` - authentik Flows
* `authentik.lib` - authentik lib
@ -29074,6 +29383,7 @@ components:
* `authentik.admin` - authentik Admin
* `authentik.api` - authentik API
* `authentik.crypto` - authentik Crypto
* `authentik.interfaces` - authentik Interfaces
* `authentik.events` - authentik Events
* `authentik.flows` - authentik Flows
* `authentik.lib` - authentik lib
@ -29184,6 +29494,7 @@ components:
* `authentik.admin` - authentik Admin
* `authentik.api` - authentik API
* `authentik.crypto` - authentik Crypto
* `authentik.interfaces` - authentik Interfaces
* `authentik.events` - authentik Events
* `authentik.flows` - authentik Flows
* `authentik.lib` - authentik lib
@ -30297,6 +30608,55 @@ components:
* `api` - Intent Api
* `recovery` - Intent Recovery
* `app_password` - Intent App Password
Interface:
type: object
description: Interface serializer
properties:
interface_uuid:
type: string
format: uuid
readOnly: true
url_name:
type: string
maxLength: 50
pattern: ^[-a-zA-Z0-9_]+$
type:
$ref: '#/components/schemas/InterfaceTypeEnum'
template:
type: string
required:
- interface_uuid
- template
- type
- url_name
InterfaceRequest:
type: object
description: Interface serializer
properties:
url_name:
type: string
minLength: 1
maxLength: 50
pattern: ^[-a-zA-Z0-9_]+$
type:
$ref: '#/components/schemas/InterfaceTypeEnum'
template:
type: string
minLength: 1
required:
- template
- type
- url_name
InterfaceTypeEnum:
enum:
- user
- admin
- flow
type: string
description: |-
* `user` - User
* `admin` - Admin
* `flow` - Flow
InvalidResponseActionEnum:
enum:
- retry
@ -33051,6 +33411,41 @@ components:
required:
- pagination
- results
PaginatedInterfaceList:
type: object
properties:
pagination:
type: object
properties:
next:
type: number
previous:
type: number
count:
type: number
current:
type: number
total_pages:
type: number
start_index:
type: number
end_index:
type: number
required:
- next
- previous
- count
- current
- total_pages
- start_index
- end_index
results:
type: array
items:
$ref: '#/components/schemas/Interface'
required:
- pagination
- results
PaginatedInvitationList:
type: object
properties:
@ -35870,6 +36265,7 @@ components:
* `authentik.admin` - authentik Admin
* `authentik.api` - authentik API
* `authentik.crypto` - authentik Crypto
* `authentik.interfaces` - authentik Interfaces
* `authentik.events` - authentik Events
* `authentik.flows` - authentik Flows
* `authentik.lib` - authentik lib
@ -36121,6 +36517,20 @@ components:
description: Specify which sources should be shown.
show_source_labels:
type: boolean
PatchedInterfaceRequest:
type: object
description: Interface serializer
properties:
url_name:
type: string
minLength: 1
maxLength: 50
pattern: ^[-a-zA-Z0-9_]+$
type:
$ref: '#/components/schemas/InterfaceTypeEnum'
template:
type: string
minLength: 1
PatchedInvitationRequest:
type: object
description: Invitation Serializer
@ -37405,6 +37815,18 @@ components:
type: string
format: uuid
nullable: true
interface_admin:
type: string
format: uuid
nullable: true
interface_user:
type: string
format: uuid
nullable: true
interface_flow:
type: string
format: uuid
nullable: true
event_retention:
type: string
minLength: 1
@ -40576,6 +40998,18 @@ components:
type: string
format: uuid
nullable: true
interface_admin:
type: string
format: uuid
nullable: true
interface_user:
type: string
format: uuid
nullable: true
interface_flow:
type: string
format: uuid
nullable: true
event_retention:
type: string
description: 'Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).'
@ -40634,6 +41068,18 @@ components:
type: string
format: uuid
nullable: true
interface_admin:
type: string
format: uuid
nullable: true
interface_user:
type: string
format: uuid
nullable: true
interface_flow:
type: string
format: uuid
nullable: true
event_retention:
type: string
minLength: 1

View file

@ -33,7 +33,7 @@ class TestFlowsAuthenticator(SeleniumTestCase):
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
self.driver.get(self.url("authentik_core:if-flow", flow_slug=flow.slug))
self.driver.get(self.url("authentik_interfaces:if", if_name="flow", flow_slug=flow.slug))
self.login()
# Get expected token
@ -57,7 +57,7 @@ class TestFlowsAuthenticator(SeleniumTestCase):
"""test TOTP Setup stage"""
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
self.driver.get(self.url("authentik_core:if-flow", flow_slug=flow.slug))
self.driver.get(self.url("authentik_interfaces:if", if_name="flow", flow_slug=flow.slug))
self.login()
self.wait_for_url(self.if_user_url("/library"))
@ -103,7 +103,7 @@ class TestFlowsAuthenticator(SeleniumTestCase):
"""test Static OTP Setup stage"""
flow: Flow = Flow.objects.get(slug="default-authentication-flow")
self.driver.get(self.url("authentik_core:if-flow", flow_slug=flow.slug))
self.driver.get(self.url("authentik_interfaces:if", if_name="flow", flow_slug=flow.slug))
self.login()
self.wait_for_url(self.if_user_url("/library"))

View file

@ -15,7 +15,8 @@ class TestFlowsLogin(SeleniumTestCase):
"""test default login flow"""
self.driver.get(
self.url(
"authentik_core:if-flow",
"authentik_interfaces:if",
if_name="flow",
flow_slug="default-authentication-flow",
)
)

View file

@ -35,7 +35,8 @@ class TestFlowsStageSetup(SeleniumTestCase):
self.driver.get(
self.url(
"authentik_core:if-flow",
"authentik_interfaces:if",
if_name="flow",
flow_slug="default-authentication-flow",
)
)

View file

@ -299,6 +299,9 @@ export class AdminInterface extends Interface {
<ak-sidebar-item path="/core/tenants">
<span slot="label">${t`Tenants`}</span>
</ak-sidebar-item>
<ak-sidebar-item path="/interfaces">
<span slot="label">${t`Interfaces`}</span>
</ak-sidebar-item>
<ak-sidebar-item path="/crypto/certificates">
<span slot="label">${t`Certificates`}</span>
</ak-sidebar-item>

View file

@ -132,6 +132,10 @@ export const ROUTES: Route[] = [
await import("@goauthentik/admin/blueprints/BlueprintListPage");
return html`<ak-blueprint-list></ak-blueprint-list>`;
}),
new Route(new RegExp("^/interfaces$"), async () => {
await import("@goauthentik/admin/interfaces/InterfaceListPage");
return html`<ak-interface-list></ak-interface-list>`;
}),
new Route(new RegExp("^/debug$"), async () => {
await import("@goauthentik/admin/DebugPage");
return html`<ak-admin-debug-page></ak-admin-debug-page>`;

View file

@ -0,0 +1,90 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/Radio";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { Interface, InterfaceTypeEnum, InterfacesApi } from "@goauthentik/api";
@customElement("ak-interface-form")
export class InterfaceForm extends ModelForm<Interface, string> {
loadInstance(pk: string): Promise<Interface> {
return new InterfacesApi(DEFAULT_CONFIG).interfacesRetrieve({
interfaceUuid: pk,
});
}
getSuccessMessage(): string {
if (this.instance) {
return t`Successfully updated interface.`;
} else {
return t`Successfully created interface.`;
}
}
send = (data: Interface): Promise<Interface> => {
if (this.instance?.interfaceUuid) {
return new InterfacesApi(DEFAULT_CONFIG).interfacesUpdate({
interfaceUuid: this.instance.interfaceUuid,
interfaceRequest: data,
});
} else {
return new InterfacesApi(DEFAULT_CONFIG).interfacesCreate({
interfaceRequest: data,
});
}
};
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`URL Name`} ?required=${true} name="urlName">
<input
type="text"
value="${first(this.instance?.urlName, "")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${t`Name used in the URL when accessing this interface.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Type`} ?required=${true} name="type">
<ak-radio
.options=${[
{
label: t`Enduser interface`,
value: InterfaceTypeEnum.User,
default: true,
},
{
label: t`Flow interface`,
value: InterfaceTypeEnum.Flow,
},
{
label: t`Admin interface`,
value: InterfaceTypeEnum.Admin,
},
]}
.value=${this.instance?.type}
>
</ak-radio>
<p class="pf-c-form__helper-text">
${t`Configure how authentik will use this interface.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Template`} ?required=${true} name="template"
><ak-codemirror
mode="html"
value="${ifDefined(this.instance?.template)}"
></ak-codemirror>
</ak-form-element-horizontal>
</form>`;
}
}

View file

@ -0,0 +1,101 @@
import "@goauthentik/admin/interfaces/InterfaceForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config";
import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm";
import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage";
import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Interface, InterfacesApi } from "@goauthentik/api";
@customElement("ak-interface-list")
export class InterfaceListPage extends TablePage<Interface> {
searchEnabled(): boolean {
return true;
}
pageTitle(): string {
return t`Interfaces`;
}
pageDescription(): string {
return t`Manage custom interfaces for authentik`;
}
pageIcon(): string {
return "fa fa-home";
}
checkbox = true;
@property()
order = "url_name";
async apiEndpoint(page: number): Promise<PaginatedResponse<Interface>> {
return new InterfacesApi(DEFAULT_CONFIG).interfacesList({
ordering: this.order,
page: page,
pageSize: (await uiConfig()).pagination.perPage,
search: this.search || "",
});
}
columns(): TableColumn[] {
return [new TableColumn(t`URL Name`, "url_name"), new TableColumn(t`Actions`)];
}
renderToolbarSelected(): TemplateResult {
const disabled = this.selectedElements.length < 1;
return html`<ak-forms-delete-bulk
objectLabel=${t`Interface(s)`}
.objects=${this.selectedElements}
.metadata=${(item: Interface) => {
return [{ key: t`Domain`, value: item.urlName }];
}}
.usedBy=${(item: Interface) => {
return new InterfacesApi(DEFAULT_CONFIG).interfacesUsedByList({
interfaceUuid: item.interfaceUuid,
});
}}
.delete=${(item: Interface) => {
return new InterfacesApi(DEFAULT_CONFIG).interfacesDestroy({
interfaceUuid: item.interfaceUuid,
});
}}
>
<button ?disabled=${disabled} slot="trigger" class="pf-c-button pf-m-danger">
${t`Delete`}
</button>
</ak-forms-delete-bulk>`;
}
row(item: Interface): TemplateResult[] {
return [
html`${item.urlName}`,
html`<ak-forms-modal>
<span slot="submit"> ${t`Update`} </span>
<span slot="header"> ${t`Update Interface`} </span>
<ak-interface-form slot="form" .instancePk=${item.interfaceUuid}>
</ak-interface-form>
<button slot="trigger" class="pf-c-button pf-m-plain">
<i class="fas fa-edit"></i>
</button>
</ak-forms-modal>`,
];
}
renderObjectCreate(): TemplateResult {
return html`
<ak-forms-modal>
<span slot="submit"> ${t`Create`} </span>
<span slot="header"> ${t`Create Interface`} </span>
<ak-interface-form slot="form"> </ak-interface-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${t`Create`}</button>
</ak-forms-modal>
`;
}
}

View file

@ -23,6 +23,10 @@ import {
FlowsApi,
FlowsInstancesListDesignationEnum,
FlowsInstancesListRequest,
Interface,
InterfacesApi,
InterfacesListRequest,
InterfacesListTypeEnum,
Tenant,
} from "@goauthentik/api";
@ -368,6 +372,107 @@ export class TenantForm extends ModelForm<Tenant, string> {
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${t`Interfaces`} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal label=${t`User Interface`} name="interfaceUser">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Interface[]> => {
const args: InterfacesListRequest = {
ordering: "url_name",
type: InterfacesListTypeEnum.User,
};
if (query !== undefined) {
args.search = query;
}
const flows = await new InterfacesApi(
DEFAULT_CONFIG,
).interfacesList(args);
return flows.results;
}}
.renderElement=${(iface: Interface): string => {
return iface.urlName;
}}
.renderDescription=${(iface: Interface): TemplateResult => {
return html`${iface.type}`;
}}
.value=${(iface: Interface | undefined): string | undefined => {
return iface?.interfaceUuid;
}}
.selected=${(iface: Interface): boolean => {
return this.instance?.interfaceUser === iface.interfaceUuid;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">${t`.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Flow Interface`} name="interfaceFlow">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Interface[]> => {
const args: InterfacesListRequest = {
ordering: "url_name",
type: InterfacesListTypeEnum.Flow,
};
if (query !== undefined) {
args.search = query;
}
const flows = await new InterfacesApi(
DEFAULT_CONFIG,
).interfacesList(args);
return flows.results;
}}
.renderElement=${(iface: Interface): string => {
return iface.urlName;
}}
.renderDescription=${(iface: Interface): TemplateResult => {
return html`${iface.type}`;
}}
.value=${(iface: Interface | undefined): string | undefined => {
return iface?.interfaceUuid;
}}
.selected=${(iface: Interface): boolean => {
return this.instance?.interfaceFlow === iface.interfaceUuid;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">${t`.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Admin Interface`} name="interfaceAdmin">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Interface[]> => {
const args: InterfacesListRequest = {
ordering: "url_name",
type: InterfacesListTypeEnum.Admin,
};
if (query !== undefined) {
args.search = query;
}
const flows = await new InterfacesApi(
DEFAULT_CONFIG,
).interfacesList(args);
return flows.results;
}}
.renderElement=${(iface: Interface): string => {
return iface.urlName;
}}
.renderDescription=${(iface: Interface): TemplateResult => {
return html`${iface.type}`;
}}
.value=${(iface: Interface | undefined): string | undefined => {
return iface?.interfaceUuid;
}}
.selected=${(iface: Interface): boolean => {
return this.instance?.interfaceAdmin === iface.interfaceUuid;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">${t`.`}</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${t`Other global settings`} </span>
<div slot="body" class="pf-c-form">