Skip to content

Commit

Permalink
core: b2c improvements p1 (#9257)
Browse files Browse the repository at this point in the history
* add default app and restrict

Signed-off-by: Jens Langhammer <[email protected]>

* also pass raw email token for custom email templates

Signed-off-by: Jens Langhammer <[email protected]>

* revoke access token when user logs out

Signed-off-by: Jens Langhammer <[email protected]>

* remigrate

Signed-off-by: Jens Langhammer <[email protected]>

* fix tests

Signed-off-by: Jens Langhammer <[email protected]>

* add command to change user types

Signed-off-by: Jens Langhammer <[email protected]>

* add some docs

Signed-off-by: Jens Langhammer <[email protected]>

* blankable

Signed-off-by: Jens Langhammer <[email protected]>

* actually fix tests

Signed-off-by: Jens Langhammer <[email protected]>

* update docs

Signed-off-by: Jens Langhammer <[email protected]>

---------

Signed-off-by: Jens Langhammer <[email protected]>
  • Loading branch information
BeryJu authored Jul 23, 2024
1 parent 3f30ccf commit 5a8d580
Show file tree
Hide file tree
Showing 20 changed files with 250 additions and 60 deletions.
1 change: 1 addition & 0 deletions authentik/brands/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class Meta:
"flow_unenrollment",
"flow_user_settings",
"flow_device_code",
"default_application",
"web_certificate",
"attributes",
]
Expand Down
26 changes: 26 additions & 0 deletions authentik/brands/migrations/0007_brand_default_application.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
]
10 changes: 10 additions & 0 deletions authentik/brands/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions authentik/core/management/commands/change_user_type.py
Original file line number Diff line number Diff line change
@@ -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.")
21 changes: 11 additions & 10 deletions authentik/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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/<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(
Expand Down
48 changes: 47 additions & 1 deletion authentik/core/views/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
5 changes: 3 additions & 2 deletions authentik/providers/oauth2/apps.py
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -13,3 +13,4 @@ class AuthentikProviderOAuth2Config(AppConfig):
"authentik.providers.oauth2.urls_root": "",
"authentik.providers.oauth2.urls": "application/o/",
}
default = True
15 changes: 15 additions & 0 deletions authentik/providers/oauth2/signals.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions authentik/stages/email/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions blueprints/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 18 additions & 0 deletions schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 1 addition & 15 deletions tests/e2e/test_flows_enroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"))

Expand Down
8 changes: 1 addition & 7 deletions tests/e2e/test_flows_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
3 changes: 1 addition & 2 deletions tests/e2e/test_source_oauth_oauth1.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]"))
6 changes: 2 additions & 4 deletions tests/e2e/test_source_oauth_oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]"))

Expand Down Expand Up @@ -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="[email protected]"))

Expand Down
Loading

0 comments on commit 5a8d580

Please sign in to comment.