Skip to content

Commit

Permalink
core: add initial app launch url
Browse files Browse the repository at this point in the history
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
  • Loading branch information
BeryJu committed Feb 23, 2022
1 parent c6e9ecd commit 40ecdb5
Show file tree
Hide file tree
Showing 6 changed files with 166 additions and 19 deletions.
13 changes: 1 addition & 12 deletions authentik/core/api/applications.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from django.db.models import QuerySet
from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404
from django.utils.functional import SimpleLazyObject
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user
Expand Down Expand Up @@ -49,18 +48,8 @@ class ApplicationSerializer(ModelSerializer):

def get_launch_url(self, app: Application) -> Optional[str]:
"""Allow formatting of launch URL"""
url = app.get_launch_url()
if not url:
return url
user = self.context["request"].user
if isinstance(user, SimpleLazyObject):
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
return app.get_launch_url(user)

class Meta:

Expand Down
20 changes: 15 additions & 5 deletions authentik/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from django.db.models import Q, QuerySet, options
from django.http import HttpRequest
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.timezone import now
from django.utils.translation import gettext_lazy as _
Expand Down Expand Up @@ -284,13 +284,23 @@ def get_meta_icon(self) -> Optional[str]:
return self.meta_icon.name
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."""
url = None
if self.meta_launch_url:
return self.meta_launch_url
url = self.meta_launch_url
if provider := self.get_provider():
return provider.launch_url
return None
url = provider.launch_url
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]:
"""Get casted provider instance"""
Expand Down
67 changes: 67 additions & 0 deletions authentik/core/tests/test_applications_views.py
Original file line number Diff line number Diff line change
@@ -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}")
8 changes: 7 additions & 1 deletion authentik/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.views.generic import RedirectView
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.session import EndSessionView

Expand All @@ -15,6 +15,12 @@
login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")),
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
path(
"-/impersonation/<int:user_id>/",
Expand Down
75 changes: 75 additions & 0 deletions authentik/core/views/apps.py
Original file line number Diff line number Diff line change
@@ -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())
2 changes: 1 addition & 1 deletion website/developer-docs/setup/full-dev-environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ To create a local development setup for authentik, you need the following:

### Requirements

- Python 3.9
- Python 3.10
- poetry, which is used to manage dependencies, and can be installed with `pip install poetry`
- Go 1.16
- PostgreSQL (any recent version will do)
Expand Down

0 comments on commit 40ecdb5

Please sign in to comment.