core: add initial app launch url (#2367)
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		| @ -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: | ||||
|  | ||||
|  | ||||
| @ -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""" | ||||
|  | ||||
							
								
								
									
										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.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/<slug:application_slug>/", | ||||
|         apps.RedirectToAppLaunch.as_view(), | ||||
|         name="application-launch", | ||||
|     ), | ||||
|     # Impersonation | ||||
|     path( | ||||
|         "-/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 | ||||
|  | ||||
| - 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) | ||||
|  | ||||
| @ -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/<slug>/`. This will redirect the user directly if they're already logged in, and otherwise authenticate the user, and then forward them. | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens L
					Jens L