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.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 New Issue
	
	Block a user
	 Jens L
					Jens L