core: add initial app launch url (#2367)

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens L 2022-02-23 22:48:55 +01:00 committed by GitHub
parent c6e9ecdd37
commit 677bcaadd7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 170 additions and 19 deletions

View file

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

View file

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

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

View file

@ -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>/",

View 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())

View file

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

View file

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