core: b2c improvements p1 (#9257)

* add default app and restrict

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* also pass raw email token for custom email templates

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* revoke access token when user logs out

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remigrate

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add command to change user types

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add some docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* blankable

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* actually fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update docs

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L.
2024-07-23 11:10:38 +02:00
committed by GitHub
parent 3f30ccf910
commit 5a8d580c86
20 changed files with 250 additions and 60 deletions

View File

@ -55,6 +55,7 @@ class BrandSerializer(ModelSerializer):
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"default_application",
"web_certificate",
"attributes",
]

View File

@ -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",
),
),
]

View File

@ -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,

View File

@ -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.")

View File

@ -6,7 +6,6 @@ from django.conf import settings
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.sources import SourceViewSet, UserSourceConnectionViewSe
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 @@ from authentik.root.middleware import ChannelsLoggingMiddleware
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/<slug:application_slug>/",
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(

View File

@ -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 @@ class InterfaceView(TemplateView):
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)

View File

@ -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

View File

@ -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()

View File

@ -118,6 +118,7 @@ class EmailStageView(ChallengeStageView):
"url": self.get_full_url(**{QS_KEY_TOKEN: token.key}),
"user": pending_user,
"expires": token.expires,
"token": token.key,
},
)
send_mails(current_stage, message)

View File

@ -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",

View File

@ -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

View File

@ -37,14 +37,7 @@ class TestFlowsEnroll(SeleniumTestCase):
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 @@ class TestFlowsEnroll(SeleniumTestCase):
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"))

View File

@ -97,15 +97,9 @@ class TestFlowsRecovery(SeleniumTestCase):
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))

View File

@ -136,7 +136,6 @@ class TestSourceOAuth1(SeleniumTestCase):
# 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"))

View File

@ -155,8 +155,7 @@ class TestSourceOAuth2(SeleniumTestCase):
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 @@ class TestSourceOAuth2(SeleniumTestCase):
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"))

View File

@ -168,8 +168,7 @@ class TestSourceSAML(SeleniumTestCase):
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 @@ class TestSourceSAML(SeleniumTestCase):
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 @@ class TestSourceSAML(SeleniumTestCase):
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 @@ class TestSourceSAML(SeleniumTestCase):
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 @@ class TestSourceSAML(SeleniumTestCase):
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(

View File

@ -176,9 +176,12 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
"""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

View File

@ -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<Brand, string> {
@ -137,6 +143,52 @@ export class BrandForm extends ModelForm<Brand, string> {
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("External user settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Default application")}
name="defaultApplication"
>
<ak-search-select
blankable
.fetchObjects=${async (query?: string): Promise<Application[]> => {
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;
}}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg(
"When configured, external users will automatically be redirected to this application when not attempting to access a different application",
)}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
<ak-form-group>
<span slot="header"> ${msg("Default flows")} </span>
<div slot="body" class="pf-c-form">
@ -174,11 +226,6 @@ export class BrandForm extends ModelForm<Brand, string> {
flowType=${FlowsInstancesListDesignationEnum.Recovery}
.currentFlow=${this.instance?.flowRecovery}
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg(
"Recovery flow. If left empty, the first applicable flow sorted by the slug is used.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Unenrollment flow")}

View File

@ -20,3 +20,7 @@ This means that if you want to select a default flow based on policy, you can le
## Branding
The brand configuration controls the branding title (shown in website document title and several other places), the sidebar/header logo that appears in the upper left of the product interface, and the favicon on a browser tab.
## External user settings
The **Default application** configuration can be used to redirect external users to an application when they successfully authenticate without being sent from a specific application.

View File

@ -11,7 +11,19 @@ To try out the release candidate, replace your Docker image tag with the latest
## Breaking changes
- **Changed HTTP Healthcheck endpoints status code**
### Manual action may be required
- **Changes to the external user type**
Since the introduction of user types with [2023.8](../2023/v2023.8.md), the main difference between internal and external users has mostly been relevant when using the [Enterprise](../../enterprise/index.md) version of authentik.
With this release, authentik improves support for B2C use-cases, which external users are intended for. It is now possible to configure a default application. External users _not_ attempting to access a specific application will always be redirected to this default application.
As part of this, external users will no longer have access to the User and Admin interfaces. If you're using the open-source version and you require this workflow, you can change users to be Internal, which will have no side-effects. For enterprise customers, please reach out to us with any questions.
<details><summary>Bulk changing the user type</summary>In 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.</details>
- **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.