core: add initial app launch url (#2367)
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
c6e9ecdd37
commit
677bcaadd7
|
@ -5,7 +5,6 @@ from django.core.cache import cache
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
from django.http.response import HttpResponseBadRequest
|
from django.http.response import HttpResponseBadRequest
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.functional import SimpleLazyObject
|
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
|
@ -49,18 +48,8 @@ class ApplicationSerializer(ModelSerializer):
|
||||||
|
|
||||||
def get_launch_url(self, app: Application) -> Optional[str]:
|
def get_launch_url(self, app: Application) -> Optional[str]:
|
||||||
"""Allow formatting of launch URL"""
|
"""Allow formatting of launch URL"""
|
||||||
url = app.get_launch_url()
|
|
||||||
if not url:
|
|
||||||
return url
|
|
||||||
user = self.context["request"].user
|
user = self.context["request"].user
|
||||||
if isinstance(user, SimpleLazyObject):
|
return app.get_launch_url(user)
|
||||||
user._setup()
|
|
||||||
user = user._wrapped
|
|
||||||
try:
|
|
||||||
return url % user.__dict__
|
|
||||||
except (ValueError, TypeError) as exc:
|
|
||||||
LOGGER.warning("Failed to format launch url", exc=exc)
|
|
||||||
return url
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ from django.db import models
|
||||||
from django.db.models import Q, QuerySet, options
|
from django.db.models import Q, QuerySet, options
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.templatetags.static import static
|
from django.templatetags.static import static
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import SimpleLazyObject, cached_property
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
@ -284,13 +284,23 @@ class Application(PolicyBindingModel):
|
||||||
return self.meta_icon.name
|
return self.meta_icon.name
|
||||||
return self.meta_icon.url
|
return self.meta_icon.url
|
||||||
|
|
||||||
def get_launch_url(self) -> Optional[str]:
|
def get_launch_url(self, user: Optional["User"] = None) -> Optional[str]:
|
||||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||||
|
url = None
|
||||||
if self.meta_launch_url:
|
if self.meta_launch_url:
|
||||||
return self.meta_launch_url
|
url = self.meta_launch_url
|
||||||
if provider := self.get_provider():
|
if provider := self.get_provider():
|
||||||
return provider.launch_url
|
url = provider.launch_url
|
||||||
return None
|
if user:
|
||||||
|
if isinstance(user, SimpleLazyObject):
|
||||||
|
user._setup()
|
||||||
|
user = user._wrapped
|
||||||
|
try:
|
||||||
|
return url % user.__dict__
|
||||||
|
except (ValueError, TypeError, LookupError) as exc:
|
||||||
|
LOGGER.warning("Failed to format launch url", exc=exc)
|
||||||
|
return url
|
||||||
|
return url
|
||||||
|
|
||||||
def get_provider(self) -> Optional[Provider]:
|
def get_provider(self) -> Optional[Provider]:
|
||||||
"""Get casted provider instance"""
|
"""Get casted provider instance"""
|
||||||
|
|
67
authentik/core/tests/test_applications_views.py
Normal file
67
authentik/core/tests/test_applications_views.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
"""Test Applications API"""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from authentik.core.models import Application
|
||||||
|
from authentik.core.tests.utils import create_test_admin_user, create_test_tenant
|
||||||
|
from authentik.flows.models import Flow, FlowDesignation
|
||||||
|
from authentik.flows.tests import FlowTestCase
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplicationsViews(FlowTestCase):
|
||||||
|
"""Test applications Views"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = create_test_admin_user()
|
||||||
|
self.allowed = Application.objects.create(
|
||||||
|
name="allowed", slug="allowed", meta_launch_url="https://goauthentik.io/%(username)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_check_redirect(self):
|
||||||
|
"""Test redirect"""
|
||||||
|
empty_flow = Flow.objects.create(
|
||||||
|
name="foo",
|
||||||
|
slug="foo",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
tenant: Tenant = create_test_tenant()
|
||||||
|
tenant.flow_authentication = empty_flow
|
||||||
|
tenant.save()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse(
|
||||||
|
"authentik_core:application-launch",
|
||||||
|
kwargs={"application_slug": self.allowed.slug},
|
||||||
|
),
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
with patch(
|
||||||
|
"authentik.flows.stage.StageView.get_pending_user", MagicMock(return_value=self.user)
|
||||||
|
):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": empty_flow.slug})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertStageRedirects(response, f"https://goauthentik.io/{self.user.username}")
|
||||||
|
|
||||||
|
def test_check_redirect_auth(self):
|
||||||
|
"""Test redirect"""
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
empty_flow = Flow.objects.create(
|
||||||
|
name="foo",
|
||||||
|
slug="foo",
|
||||||
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
|
)
|
||||||
|
tenant: Tenant = create_test_tenant()
|
||||||
|
tenant.flow_authentication = empty_flow
|
||||||
|
tenant.save()
|
||||||
|
response = self.client.get(
|
||||||
|
reverse(
|
||||||
|
"authentik_core:application-launch",
|
||||||
|
kwargs={"application_slug": self.allowed.slug},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, f"https://goauthentik.io/{self.user.username}")
|
|
@ -5,7 +5,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
from django.views.generic import RedirectView
|
from django.views.generic import RedirectView
|
||||||
from django.views.generic.base import TemplateView
|
from django.views.generic.base import TemplateView
|
||||||
|
|
||||||
from authentik.core.views import impersonate
|
from authentik.core.views import apps, impersonate
|
||||||
from authentik.core.views.interface import FlowInterfaceView
|
from authentik.core.views.interface import FlowInterfaceView
|
||||||
from authentik.core.views.session import EndSessionView
|
from authentik.core.views.session import EndSessionView
|
||||||
|
|
||||||
|
@ -15,6 +15,12 @@ urlpatterns = [
|
||||||
login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")),
|
login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")),
|
||||||
name="root-redirect",
|
name="root-redirect",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
# We have to use this format since everything else uses applications/o or applications/saml
|
||||||
|
"application/launch/<slug:application_slug>/",
|
||||||
|
apps.RedirectToAppLaunch.as_view(),
|
||||||
|
name="application-launch",
|
||||||
|
),
|
||||||
# Impersonation
|
# Impersonation
|
||||||
path(
|
path(
|
||||||
"-/impersonation/<int:user_id>/",
|
"-/impersonation/<int:user_id>/",
|
||||||
|
|
75
authentik/core/views/apps.py
Normal file
75
authentik/core/views/apps.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
"""app views"""
|
||||||
|
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
from authentik.core.models import Application
|
||||||
|
from authentik.flows.challenge import (
|
||||||
|
ChallengeResponse,
|
||||||
|
ChallengeTypes,
|
||||||
|
HttpChallengeResponse,
|
||||||
|
RedirectChallenge,
|
||||||
|
)
|
||||||
|
from authentik.flows.models import in_memory_stage
|
||||||
|
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, 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.stages.consent.stage import (
|
||||||
|
PLAN_CONTEXT_CONSENT_HEADER,
|
||||||
|
PLAN_CONTEXT_CONSENT_PERMISSIONS,
|
||||||
|
)
|
||||||
|
from authentik.tenants.models import Tenant
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectToAppLaunch(View):
|
||||||
|
"""Application launch view, redirect to the launch URL"""
|
||||||
|
|
||||||
|
def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||||
|
app = get_object_or_404(Application, slug=application_slug)
|
||||||
|
# Check here if the application has any launch URL set, if not 404
|
||||||
|
launch = app.get_launch_url()
|
||||||
|
if not launch:
|
||||||
|
raise Http404
|
||||||
|
# Check if we're authenticated already, saves us the flow run
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
return HttpResponseRedirect(app.get_launch_url(request.user))
|
||||||
|
# otherwise, do a custom flow plan that includes the application that's
|
||||||
|
# being accessed, to improve usability
|
||||||
|
tenant: Tenant = request.tenant
|
||||||
|
flow = tenant.flow_authentication
|
||||||
|
planner = FlowPlanner(flow)
|
||||||
|
planner.allow_empty_flows = True
|
||||||
|
plan = planner.plan(
|
||||||
|
request,
|
||||||
|
{
|
||||||
|
PLAN_CONTEXT_APPLICATION: app,
|
||||||
|
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||||
|
% {"application": app.name},
|
||||||
|
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectToAppStage(ChallengeStageView):
|
||||||
|
"""Final stage to be inserted after the user logs in"""
|
||||||
|
|
||||||
|
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
|
||||||
|
app = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
||||||
|
launch = app.get_launch_url(self.get_pending_user())
|
||||||
|
# sanity check to ensure launch is still set
|
||||||
|
if not launch:
|
||||||
|
raise Http404
|
||||||
|
return RedirectChallenge(
|
||||||
|
instance={
|
||||||
|
"type": ChallengeTypes.REDIRECT.value,
|
||||||
|
"to": launch,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||||
|
return HttpChallengeResponse(self.get_challenge())
|
|
@ -8,7 +8,7 @@ To create a local development setup for authentik, you need the following:
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Python 3.9
|
- Python 3.10
|
||||||
- poetry, which is used to manage dependencies, and can be installed with `pip install poetry`
|
- poetry, which is used to manage dependencies, and can be installed with `pip install poetry`
|
||||||
- Go 1.16
|
- Go 1.16
|
||||||
- PostgreSQL (any recent version will do)
|
- PostgreSQL (any recent version will do)
|
||||||
|
|
|
@ -47,3 +47,7 @@ Applications are shown to users when
|
||||||
To hide applications without modifying policy settings and without removing it, you can simply set the *Launch URL* to `blank://blank`, which will hide the application from users.
|
To hide applications without modifying policy settings and without removing it, you can simply set the *Launch URL* to `blank://blank`, which will hide the application from users.
|
||||||
|
|
||||||
Keep in mind, the users still have access, so they can still authorize access when the login process is started from the application.
|
Keep in mind, the users still have access, so they can still authorize access when the login process is started from the application.
|
||||||
|
|
||||||
|
### Launch URLs (2022.3+)
|
||||||
|
|
||||||
|
To give users direct links to applications, you can now use an URL like `https://authentik.company/application/launch/<slug>/`. This will redirect the user directly if they're already logged in, and otherwise authenticate the user, and then forward them.
|
||||||
|
|
Reference in a new issue