diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index 9506b25fe..3ac5196b0 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -5,7 +5,6 @@ from django.core.cache import cache 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 @@ -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: diff --git a/authentik/core/models.py b/authentik/core/models.py index 481c0c266..baa09fdf2 100644 --- a/authentik/core/models.py +++ b/authentik/core/models.py @@ -14,7 +14,7 @@ from django.db import models 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 _ @@ -284,13 +284,23 @@ class Application(PolicyBindingModel): 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""" diff --git a/authentik/core/tests/test_applications_views.py b/authentik/core/tests/test_applications_views.py new file mode 100644 index 000000000..6a8e69949 --- /dev/null +++ b/authentik/core/tests/test_applications_views.py @@ -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}") diff --git a/authentik/core/urls.py b/authentik/core/urls.py index 8c10b43c3..e56dbd79a 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -5,7 +5,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie 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 @@ -15,6 +15,12 @@ urlpatterns = [ 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//", + apps.RedirectToAppLaunch.as_view(), + name="application-launch", + ), # Impersonation path( "-/impersonation//", diff --git a/authentik/core/views/apps.py b/authentik/core/views/apps.py new file mode 100644 index 000000000..e558009cf --- /dev/null +++ b/authentik/core/views/apps.py @@ -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()) diff --git a/website/developer-docs/setup/full-dev-environment.md b/website/developer-docs/setup/full-dev-environment.md index a5455e484..9f8f16353 100644 --- a/website/developer-docs/setup/full-dev-environment.md +++ b/website/developer-docs/setup/full-dev-environment.md @@ -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) diff --git a/website/docs/core/applications.md b/website/docs/core/applications.md index 895c6a76e..b73cf3a90 100644 --- a/website/docs/core/applications.md +++ b/website/docs/core/applications.md @@ -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. 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//`. This will redirect the user directly if they're already logged in, and otherwise authenticate the user, and then forward them.