diff --git a/authentik/brands/api.py b/authentik/brands/api.py index 047c1cd3d75f..2f0486a688ed 100644 --- a/authentik/brands/api.py +++ b/authentik/brands/api.py @@ -55,6 +55,7 @@ class Meta: "flow_unenrollment", "flow_user_settings", "flow_device_code", + "default_application", "web_certificate", "attributes", ] diff --git a/authentik/brands/migrations/0007_brand_default_application.py b/authentik/brands/migrations/0007_brand_default_application.py new file mode 100644 index 000000000000..3eaf33a3f57f --- /dev/null +++ b/authentik/brands/migrations/0007_brand_default_application.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.6 on 2024-07-04 20:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_brands", "0006_brand_authentik_b_domain_b9b24a_idx_and_more"), + ("authentik_core", "0035_alter_group_options_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="brand", + name="default_application", + field=models.ForeignKey( + default=None, + help_text="When set, external users will be redirected to this application after authenticating.", + null=True, + on_delete=django.db.models.deletion.SET_DEFAULT, + to="authentik_core.application", + ), + ), + ] diff --git a/authentik/brands/models.py b/authentik/brands/models.py index 0193774935a5..9b4fe8e65d77 100644 --- a/authentik/brands/models.py +++ b/authentik/brands/models.py @@ -51,6 +51,16 @@ class Brand(SerializerModel): Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code" ) + default_application = models.ForeignKey( + "authentik_core.Application", + null=True, + default=None, + on_delete=models.SET_DEFAULT, + help_text=_( + "When set, external users will be redirected to this application after authenticating." + ), + ) + web_certificate = models.ForeignKey( CertificateKeyPair, null=True, diff --git a/authentik/core/management/commands/change_user_type.py b/authentik/core/management/commands/change_user_type.py new file mode 100644 index 000000000000..3df0ed38615f --- /dev/null +++ b/authentik/core/management/commands/change_user_type.py @@ -0,0 +1,28 @@ +"""Change user type""" + +from authentik.core.models import User, UserTypes +from authentik.tenants.management import TenantCommand + + +class Command(TenantCommand): + """Change user type""" + + def add_arguments(self, parser): + parser.add_argument("--type", type=str, required=True) + parser.add_argument("--all", action="store_true") + parser.add_argument("usernames", nargs="+", type=str) + + def handle_per_tenant(self, **options): + new_type = UserTypes(options["type"]) + qs = ( + User.objects.exclude_anonymous() + .exclude(type=UserTypes.SERVICE_ACCOUNT) + .exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT) + ) + if options["usernames"] and options["all"]: + self.stderr.write("--all and usernames specified, only one can be specified") + return + if options["usernames"] and not options["all"]: + qs = qs.filter(username__in=options["usernames"]) + updated = qs.update(type=new_type) + self.stdout.write(f"Updated {updated} users.") diff --git a/authentik/core/urls.py b/authentik/core/urls.py index 8d50bfc65cc2..e0b9badcf912 100644 --- a/authentik/core/urls.py +++ b/authentik/core/urls.py @@ -6,7 +6,6 @@ from django.contrib.auth.decorators import login_required from django.urls import path from django.views.decorators.csrf import ensure_csrf_cookie -from django.views.generic import RedirectView from authentik.core.api.applications import ApplicationViewSet from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet @@ -18,9 +17,13 @@ from authentik.core.api.tokens import TokenViewSet from authentik.core.api.transactional_applications import TransactionalApplicationView from authentik.core.api.users import UserViewSet -from authentik.core.views import apps +from authentik.core.views.apps import RedirectToAppLaunch from authentik.core.views.debug import AccessDeniedView -from authentik.core.views.interface import InterfaceView +from authentik.core.views.interface import ( + BrandDefaultRedirectView, + InterfaceView, + RootRedirectView, +) from authentik.core.views.session import EndSessionView from authentik.flows.views.interface import FlowInterfaceView from authentik.root.asgi_middleware import SessionMiddleware @@ -30,26 +33,24 @@ urlpatterns = [ path( "", - login_required( - RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True) - ), + login_required(RootRedirectView.as_view()), name="root-redirect", ), path( - # We have to use this format since everything else uses applications/o or applications/saml + # We have to use this format since everything else uses application/o or application/saml "application/launch//", - apps.RedirectToAppLaunch.as_view(), + RedirectToAppLaunch.as_view(), name="application-launch", ), # Interfaces path( "if/admin/", - ensure_csrf_cookie(InterfaceView.as_view(template_name="if/admin.html")), + ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/admin.html")), name="if-admin", ), path( "if/user/", - ensure_csrf_cookie(InterfaceView.as_view(template_name="if/user.html")), + ensure_csrf_cookie(BrandDefaultRedirectView.as_view(template_name="if/user.html")), name="if-user", ), path( diff --git a/authentik/core/views/interface.py b/authentik/core/views/interface.py index ba8b7c3c2a37..4a7d06b7722d 100644 --- a/authentik/core/views/interface.py +++ b/authentik/core/views/interface.py @@ -3,13 +3,42 @@ from json import dumps from typing import Any -from django.views.generic.base import TemplateView +from django.http import HttpRequest +from django.http.response import HttpResponse +from django.shortcuts import redirect +from django.utils.translation import gettext as _ +from django.views.generic.base import RedirectView, TemplateView from rest_framework.request import Request from authentik import get_build_hash from authentik.admin.tasks import LOCAL_VERSION from authentik.api.v3.config import ConfigView from authentik.brands.api import CurrentBrandSerializer +from authentik.brands.models import Brand +from authentik.core.models import UserTypes +from authentik.policies.denied import AccessDeniedResponse + + +class RootRedirectView(RedirectView): + """Root redirect view, redirect to brand's default application if set""" + + pattern_name = "authentik_core:if-user" + query_string = True + + def redirect_to_app(self, request: HttpRequest): + if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL: + brand: Brand = request.brand + if brand.default_application: + return redirect( + "authentik_core:application-launch", + application_slug=brand.default_application.slug, + ) + return None + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + if redirect_response := RootRedirectView().redirect_to_app(request): + return redirect_response + return super().dispatch(request, *args, **kwargs) class InterfaceView(TemplateView): @@ -23,3 +52,20 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: kwargs["build"] = get_build_hash() kwargs["url_kwargs"] = self.kwargs return super().get_context_data(**kwargs) + + +class BrandDefaultRedirectView(InterfaceView): + """By default redirect to default app""" + + def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + if request.user.is_authenticated and request.user.type == UserTypes.EXTERNAL: + brand: Brand = request.brand + if brand.default_application: + return redirect( + "authentik_core:application-launch", + application_slug=brand.default_application.slug, + ) + response = AccessDeniedResponse(self.request) + response.error_message = _("Interface can only be accessed by internal users.") + return response + return super().dispatch(request, *args, **kwargs) diff --git a/authentik/providers/oauth2/apps.py b/authentik/providers/oauth2/apps.py index 1a33aba7981e..f763cddd98cf 100644 --- a/authentik/providers/oauth2/apps.py +++ b/authentik/providers/oauth2/apps.py @@ -1,9 +1,9 @@ """authentik oauth provider app config""" -from django.apps import AppConfig +from authentik.blueprints.apps import ManagedAppConfig -class AuthentikProviderOAuth2Config(AppConfig): +class AuthentikProviderOAuth2Config(ManagedAppConfig): """authentik oauth provider app config""" name = "authentik.providers.oauth2" @@ -13,3 +13,4 @@ class AuthentikProviderOAuth2Config(AppConfig): "authentik.providers.oauth2.urls_root": "", "authentik.providers.oauth2.urls": "application/o/", } + default = True diff --git a/authentik/providers/oauth2/signals.py b/authentik/providers/oauth2/signals.py new file mode 100644 index 000000000000..60336b18b641 --- /dev/null +++ b/authentik/providers/oauth2/signals.py @@ -0,0 +1,15 @@ +from hashlib import sha256 + +from django.contrib.auth.signals import user_logged_out +from django.dispatch import receiver +from django.http import HttpRequest + +from authentik.core.models import User +from authentik.providers.oauth2.models import AccessToken + + +@receiver(user_logged_out) +def user_logged_out_oauth_access_token(sender, request: HttpRequest, user: User, **_): + """Revoke access tokens upon user logout""" + hashed_session_key = sha256(request.session.session_key.encode("ascii")).hexdigest() + AccessToken.objects.filter(user=user, session_id=hashed_session_key).delete() diff --git a/authentik/stages/email/stage.py b/authentik/stages/email/stage.py index c8a7f7c02bdf..062dae62ef43 100644 --- a/authentik/stages/email/stage.py +++ b/authentik/stages/email/stage.py @@ -118,6 +118,7 @@ def send_email(self): "url": self.get_full_url(**{QS_KEY_TOKEN: token.key}), "user": pending_user, "expires": token.expires, + "token": token.key, }, ) send_mails(current_stage, message) diff --git a/blueprints/schema.json b/blueprints/schema.json index 9ba6b558fad7..aa7c34afa35d 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -8046,6 +8046,11 @@ "format": "uuid", "title": "Flow device code" }, + "default_application": { + "type": "integer", + "title": "Default application", + "description": "When set, external users will be redirected to this application after authenticating." + }, "web_certificate": { "type": "string", "format": "uuid", diff --git a/schema.yml b/schema.yml index 42091ca42e93..d2c2de5e2ffc 100644 --- a/schema.yml +++ b/schema.yml @@ -34285,6 +34285,12 @@ components: type: string format: uuid nullable: true + default_application: + type: string + format: uuid + nullable: true + description: When set, external users will be redirected to this application + after authenticating. web_certificate: type: string format: uuid @@ -34338,6 +34344,12 @@ components: type: string format: uuid nullable: true + default_application: + type: string + format: uuid + nullable: true + description: When set, external users will be redirected to this application + after authenticating. web_certificate: type: string format: uuid @@ -41601,6 +41613,12 @@ components: type: string format: uuid nullable: true + default_application: + type: string + format: uuid + nullable: true + description: When set, external users will be redirected to this application + after authenticating. web_certificate: type: string format: uuid diff --git a/tests/e2e/test_flows_enroll.py b/tests/e2e/test_flows_enroll.py index 95ae6a839622..1e90495e4809 100644 --- a/tests/e2e/test_flows_enroll.py +++ b/tests/e2e/test_flows_enroll.py @@ -37,14 +37,7 @@ def test_enroll_2_step(self): self.driver.get(self.live_server_url) self.initial_stages() - - interface_user = self.get_shadow_root("ak-interface-user") - wait = WebDriverWait(interface_user, self.wait_timeout) - - wait.until( - ec.presence_of_element_located((By.CSS_SELECTOR, "ak-interface-user-presentation")) - ) - self.driver.get(self.if_user_url("/settings")) + sleep(2) user = User.objects.get(username="foo") self.assertEqual(user.username, "foo") @@ -93,13 +86,6 @@ def test_enroll_email(self): self.driver.switch_to.window(self.driver.window_handles[0]) sleep(2) - # We're now logged in - wait = WebDriverWait(self.get_shadow_root("ak-interface-user"), self.wait_timeout) - - wait.until( - ec.presence_of_element_located((By.CSS_SELECTOR, "ak-interface-user-presentation")) - ) - self.driver.get(self.if_user_url("/settings")) self.assert_user(User.objects.get(username="foo")) diff --git a/tests/e2e/test_flows_recovery.py b/tests/e2e/test_flows_recovery.py index ae867c2fa9a0..f5ccc3b20062 100644 --- a/tests/e2e/test_flows_recovery.py +++ b/tests/e2e/test_flows_recovery.py @@ -97,15 +97,9 @@ def test_recover_email(self): new_password ) prompt_stage.find_element(By.CSS_SELECTOR, ".pf-c-button").click() + sleep(2) # We're now logged in - wait = WebDriverWait(self.get_shadow_root("ak-interface-user"), self.wait_timeout) - - wait.until( - ec.presence_of_element_located((By.CSS_SELECTOR, "ak-interface-user-presentation")) - ) - self.driver.get(self.if_user_url("/settings")) - self.assert_user(user) user.refresh_from_db() self.assertTrue(user.check_password(new_password)) diff --git a/tests/e2e/test_source_oauth_oauth1.py b/tests/e2e/test_source_oauth_oauth1.py index 1da92a4c3ee0..cbeb66edfd00 100644 --- a/tests/e2e/test_source_oauth_oauth1.py +++ b/tests/e2e/test_source_oauth_oauth1.py @@ -136,7 +136,6 @@ def test_oauth_enroll(self): # Wait until we've loaded the user info page sleep(2) # Wait until we've logged in - self.wait_for_url(self.if_user_url("/library")) - self.driver.get(self.if_user_url("/settings")) + self.wait_for_url(self.if_user_url()) self.assert_user(User(username="example-user", name="test name", email="foo@example.com")) diff --git a/tests/e2e/test_source_oauth_oauth2.py b/tests/e2e/test_source_oauth_oauth2.py index e6eea24c6be0..55b363feba4d 100644 --- a/tests/e2e/test_source_oauth_oauth2.py +++ b/tests/e2e/test_source_oauth_oauth2.py @@ -155,8 +155,7 @@ def test_oauth_enroll(self): prompt_stage.find_element(By.CSS_SELECTOR, "input[name=username]").send_keys(Keys.ENTER) # Wait until we've logged in - self.wait_for_url(self.if_user_url("/library")) - self.driver.get(self.if_user_url("/settings")) + self.wait_for_url(self.if_user_url()) self.assert_user(User(username="foo", name="admin", email="admin@example.com")) @@ -191,8 +190,7 @@ def test_oauth_enroll_auth(self): self.driver.find_element(By.CSS_SELECTOR, "button[type=submit]").click() # Wait until we've logged in - self.wait_for_url(self.if_user_url("/library")) - self.driver.get(self.if_user_url("/settings")) + self.wait_for_url(self.if_user_url()) self.assert_user(User(username="foo", name="admin", email="admin@example.com")) diff --git a/tests/e2e/test_source_saml.py b/tests/e2e/test_source_saml.py index 1011dd7ec09d..4f7c26019717 100644 --- a/tests/e2e/test_source_saml.py +++ b/tests/e2e/test_source_saml.py @@ -168,8 +168,7 @@ def test_idp_redirect(self): self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) # Wait until we're logged in - self.wait_for_url(self.if_user_url("/library")) - self.driver.get(self.if_user_url("/settings")) + self.wait_for_url(self.if_user_url()) self.assert_user( User.objects.exclude(username="akadmin") @@ -251,8 +250,7 @@ def test_idp_post(self): self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) # Wait until we're logged in - self.wait_for_url(self.if_user_url("/library")) - self.driver.get(self.if_user_url("/settings")) + self.wait_for_url(self.if_user_url()) self.assert_user( User.objects.exclude(username="akadmin") @@ -321,8 +319,7 @@ def test_idp_post_auto(self): self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) # Wait until we're logged in - self.wait_for_url(self.if_user_url("/library")) - self.driver.get(self.if_user_url("/settings")) + self.wait_for_url(self.if_user_url()) self.assert_user( User.objects.exclude(username="akadmin") @@ -391,8 +388,7 @@ def test_idp_post_auto_enroll_auth(self): self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) # Wait until we're logged in - self.wait_for_url(self.if_user_url("/library")) - self.driver.get(self.if_user_url("/settings")) + self.wait_for_url(self.if_user_url()) self.assert_user( User.objects.exclude(username="akadmin") @@ -426,8 +422,7 @@ def test_idp_post_auto_enroll_auth(self): self.driver.find_element(By.ID, "password").send_keys(Keys.ENTER) # Wait until we're logged in - self.wait_for_url(self.if_user_url("/library")) - self.driver.get(self.if_user_url("/settings")) + self.wait_for_url(self.if_user_url()) # sleep(999999) self.assert_user( diff --git a/tests/e2e/utils.py b/tests/e2e/utils.py index 616ed95a607b..ee715f80157b 100644 --- a/tests/e2e/utils.py +++ b/tests/e2e/utils.py @@ -176,9 +176,12 @@ def url(self, view, **kwargs) -> str: """reverse `view` with `**kwargs` into full URL using live_server_url""" return self.live_server_url + reverse(view, kwargs=kwargs) - def if_user_url(self, view) -> str: + def if_user_url(self, path: str | None = None) -> str: """same as self.url() but show URL in shell""" - return f"{self.live_server_url}/if/user/#{view}" + url = self.url("authentik_core:if-user") + if path: + return f"{url}#{path}" + return url def get_shadow_root( self, selector: str, container: WebElement | WebDriver | None = None diff --git a/web/src/admin/brands/BrandForm.ts b/web/src/admin/brands/BrandForm.ts index 99de631eacb7..6ba00dd6cae7 100644 --- a/web/src/admin/brands/BrandForm.ts +++ b/web/src/admin/brands/BrandForm.ts @@ -15,7 +15,13 @@ import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { Brand, CoreApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api"; +import { + Application, + Brand, + CoreApi, + CoreApplicationsListRequest, + FlowsInstancesListDesignationEnum, +} from "@goauthentik/api"; @customElement("ak-brand-form") export class BrandForm extends ModelForm { @@ -137,6 +143,52 @@ export class BrandForm extends ModelForm { + + + ${msg("External user settings")} +
+ + => { + const args: CoreApplicationsListRequest = { + ordering: "name", + superuserFullList: true, + }; + if (query !== undefined) { + args.search = query; + } + const users = await new CoreApi( + DEFAULT_CONFIG, + ).coreApplicationsList(args); + return users.results; + }} + .renderElement=${(item: Application): string => { + return item.name; + }} + .renderDescription=${(item: Application): TemplateResult => { + return html`${item.slug}`; + }} + .value=${(item: Application | undefined): string | undefined => { + return item?.pk; + }} + .selected=${(item: Application): boolean => { + return item.pk === this.instance?.defaultApplication; + }} + > + +

+ ${msg( + "When configured, external users will automatically be redirected to this application when not attempting to access a different application", + )} +

+
+
+
+ ${msg("Default flows")}
@@ -174,11 +226,6 @@ export class BrandForm extends ModelForm { flowType=${FlowsInstancesListDesignationEnum.Recovery} .currentFlow=${this.instance?.flowRecovery} > -

- ${msg( - "Recovery flow. If left empty, the first applicable flow sorted by the slug is used.", - )} -

Bulk changing the user typeIn the container, run the command `ak change_user_type --all --type internal` to change all users to Internal. Instead of using `--all` you can also pass usernames to the command to only change individual users to internal. + +- **Changed HTTP healthcheck endpoints status code** For increased compatibility, the `/-/health/live/` and `/-/health/ready/` endpoints return 200 HTTP Status codes for successful checks. Previously these endpoints returned 204, which means in most cases no changes are required.