Skip to content

Commit

Permalink
flows: provider invalidation (#5048)
Browse files Browse the repository at this point in the history
* add initial

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

* add web stage for session end

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

* migrate saml and tests

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

* cleanup

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

* group flow settings when providers have multiple flows

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

* adjust name for default provider invalidation

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

* re-make migrations

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

* add invalidation_flow to saml importer

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

* re-do migrations again

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

* update web stuff to get rid of old libraries

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

* make unbind flow for ldap configurable

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

* unrelated: fix flow inspector

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

* handle invalidation_flow as optional, as it should be

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

* also fix ldap outpost

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

* don't generate URL in client

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

* actually make it work???

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

* format

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

* fix migration breaking things...?

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

* start fixing tests

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

* fix fallback

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

* re-migrate

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

* fix tests

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

* fix tests

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

* fix duplicate flow setting

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

* add migration

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

* fix race condition with brand

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

* fix oauth test

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

* fix SAML tests

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

* add to wizard, fix required

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

* update docs

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

* make required, start release notes

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

* fix tests

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

---------

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
  • Loading branch information
BeryJu authored Oct 14, 2024
1 parent 5bbf9ae commit 5b66dbe
Show file tree
Hide file tree
Showing 46 changed files with 871 additions and 248 deletions.
4 changes: 1 addition & 3 deletions authentik/blueprints/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,7 @@ def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):
if version != 1:
return
blueprint_file.seek(0)
instance: BlueprintInstance = (
BlueprintInstance.objects.using(db_alias).filter(path=path).first()
)
instance = BlueprintInstance.objects.using(db_alias).filter(path=path).first()
rel_path = path.relative_to(Path(CONFIG.get("blueprints_dir")))
meta = None
if metadata:
Expand Down
2 changes: 2 additions & 0 deletions authentik/core/api/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class Meta:
"name",
"authentication_flow",
"authorization_flow",
"invalidation_flow",
"property_mappings",
"component",
"assigned_application_slug",
Expand All @@ -50,6 +51,7 @@ class Meta:
]
extra_kwargs = {
"authorization_flow": {"required": True, "allow_null": False},
"invalidation_flow": {"required": True, "allow_null": False},
}


Expand Down
55 changes: 55 additions & 0 deletions authentik/core/migrations/0040_provider_invalidation_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Generated by Django 5.0.9 on 2024-10-02 11:35

import django.db.models.deletion
from django.db import migrations, models

from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor


def migrate_invalidation_flow_default(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.flows.models import FlowDesignation, FlowAuthenticationRequirement

db_alias = schema_editor.connection.alias

Flow = apps.get_model("authentik_flows", "Flow")
Provider = apps.get_model("authentik_core", "Provider")

# So this flow is managed via a blueprint, bue we're in a migration so we don't want to rely on that
# since the blueprint is just an empty flow we can just create it here
# and let it be managed by the blueprint later
flow, _ = Flow.objects.using(db_alias).update_or_create(
slug="default-provider-invalidation-flow",
defaults={
"name": "Logged out of application",
"title": "You've logged out of %(app)s.",
"authentication": FlowAuthenticationRequirement.NONE,
"designation": FlowDesignation.INVALIDATION,
},
)
Provider.objects.using(db_alias).filter(invalidation_flow=None).update(invalidation_flow=flow)


class Migration(migrations.Migration):

dependencies = [
("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
("authentik_flows", "0027_auto_20231028_1424"),
]

operations = [
migrations.AddField(
model_name="provider",
name="invalidation_flow",
field=models.ForeignKey(
default=None,
help_text="Flow used ending the session from a provider.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
related_name="provider_invalidation",
to="authentik_flows.flow",
),
),
migrations.RunPython(migrate_invalidation_flow_default),
]
11 changes: 10 additions & 1 deletion authentik/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,14 +391,23 @@ class Provider(SerializerModel):
),
related_name="provider_authentication",
)

authorization_flow = models.ForeignKey(
"authentik_flows.Flow",
# Set to cascade even though null is allowed, since most providers
# still require an authorization flow set
on_delete=models.CASCADE,
null=True,
help_text=_("Flow used when authorizing this provider."),
related_name="provider_authorization",
)
invalidation_flow = models.ForeignKey(
"authentik_flows.Flow",
on_delete=models.SET_DEFAULT,
default=None,
null=True,
help_text=_("Flow used ending the session from a provider."),
related_name="provider_invalidation",
)

property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)

Expand Down
43 changes: 0 additions & 43 deletions authentik/core/templates/if/end_session.html

This file was deleted.

2 changes: 2 additions & 0 deletions authentik/core/tests/test_applications_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ def test_list(self):
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form",
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
Expand Down Expand Up @@ -186,6 +187,7 @@ def test_list_superuser_full_list(self):
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
"component": "ak-provider-oauth2-form",
"meta_model_name": "authentik_providers_oauth2.oauth2provider",
Expand Down
12 changes: 9 additions & 3 deletions authentik/core/tests/test_transactional_applications_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ def test_create_transactional(self):
"""Test transactional Application + provider creation"""
self.client.force_login(self.user)
uid = generate_id()
authorization_flow = create_test_flow()
response = self.client.put(
reverse("authentik_api:core-transactional-application"),
data={
Expand All @@ -30,7 +29,8 @@ def test_create_transactional(self):
"provider_model": "authentik_providers_oauth2.oauth2provider",
"provider": {
"name": uid,
"authorization_flow": str(authorization_flow.pk),
"authorization_flow": str(create_test_flow().pk),
"invalidation_flow": str(create_test_flow().pk),
},
},
)
Expand All @@ -56,10 +56,16 @@ def test_create_transactional_invalid(self):
"provider": {
"name": uid,
"authorization_flow": "",
"invalidation_flow": "",
},
},
)
self.assertJSONEqual(
response.content.decode(),
{"provider": {"authorization_flow": ["This field may not be null."]}},
{
"provider": {
"authorization_flow": ["This field may not be null."],
"invalidation_flow": ["This field may not be null."],
}
},
)
6 changes: 0 additions & 6 deletions authentik/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
InterfaceView,
RootRedirectView,
)
from authentik.core.views.session import EndSessionView
from authentik.flows.views.interface import FlowInterfaceView
from authentik.root.asgi_middleware import SessionMiddleware
from authentik.root.messages.consumer import MessageConsumer
Expand Down Expand Up @@ -60,11 +59,6 @@
ensure_csrf_cookie(FlowInterfaceView.as_view()),
name="if-flow",
),
path(
"if/session-end/<slug:application_slug>/",
ensure_csrf_cookie(EndSessionView.as_view()),
name="if-session-end",
),
# Fallback for WS
path("ws/outpost/<uuid:pk>/", InterfaceView.as_view(template_name="if/admin.html")),
path(
Expand Down
23 changes: 0 additions & 23 deletions authentik/core/views/session.py

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def test_list(self):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"connection_expiry": "hours=8",
"delete_token_on_disconnect": False,
Expand Down Expand Up @@ -120,6 +121,7 @@ def test_list_superuser_full_list(self):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
Expand Down Expand Up @@ -149,6 +151,7 @@ def test_list_superuser_full_list(self):
"name": self.provider.name,
"authentication_flow": None,
"authorization_flow": None,
"invalidation_flow": None,
"property_mappings": [],
"component": "ak-provider-rac-form",
"assigned_application_slug": self.app.slug,
Expand Down
15 changes: 14 additions & 1 deletion authentik/flows/challenge.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,22 @@ def __init__(self, request: Request | None = None, error: Exception | None = Non
class AccessDeniedChallenge(WithUserInfoChallenge):
"""Challenge when a flow's active stage calls `stage_invalid()`."""

error_message = CharField(required=False)
component = CharField(default="ak-stage-access-denied")

error_message = CharField(required=False)


class SessionEndChallenge(WithUserInfoChallenge):
"""Challenge for ending a session"""

component = CharField(default="ak-stage-session-end")

application_name = CharField(required=False)
application_launch_url = CharField(required=False)

invalidation_flow_url = CharField(required=False)
brand_name = CharField(required=True)


class PermissionDict(TypedDict):
"""Consent Permission"""
Expand Down
14 changes: 6 additions & 8 deletions authentik/flows/migrations/0027_auto_20231028_1424.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,18 @@


def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from guardian.shortcuts import get_anonymous_user
from guardian.conf import settings as guardian_settings

Flow = apps.get_model("authentik_flows", "Flow")
User = apps.get_model("authentik_core", "User")

db_alias = schema_editor.connection.alias

users = User.objects.using(db_alias).exclude(username="akadmin")
try:
users = users.exclude(pk=get_anonymous_user().pk)

except Exception: # nosec
pass

users = (
User.objects.using(db_alias)
.exclude(username="akadmin")
.exclude(username=guardian_settings.ANONYMOUS_USER_NAME)
)
if users.exists():
Flow.objects.using(db_alias).filter(slug="initial-setup").update(
authentication="require_superuser"
Expand Down
4 changes: 3 additions & 1 deletion authentik/flows/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,9 @@ def __str__(self):


def in_memory_stage(view: type["StageView"], **kwargs) -> Stage:
"""Creates an in-memory stage instance, based on a `view` as view."""
"""Creates an in-memory stage instance, based on a `view` as view.
Any key-word arguments are set as attributes on the stage object,
accessible via `self.executor.current_stage`."""
stage = Stage()
# Because we can't pickle a locally generated function,
# we set the view as a separate property and reference a generic function
Expand Down
33 changes: 31 additions & 2 deletions authentik/flows/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
from sentry_sdk import start_span
from structlog.stdlib import BoundLogger, get_logger

from authentik.core.models import User
from authentik.core.models import Application, User
from authentik.flows.challenge import (
AccessDeniedChallenge,
Challenge,
ChallengeResponse,
ContextualFlowInfo,
HttpChallengeResponse,
RedirectChallenge,
SessionEndChallenge,
WithUserInfoChallenge,
)
from authentik.flows.exceptions import StageInvalidException
Expand Down Expand Up @@ -230,7 +231,7 @@ def challenge_invalid(self, response: ChallengeResponse) -> HttpResponse:
return HttpChallengeResponse(challenge_response)


class AccessDeniedChallengeView(ChallengeStageView):
class AccessDeniedStage(ChallengeStageView):
"""Used internally by FlowExecutor's stage_invalid()"""

error_message: str | None
Expand Down Expand Up @@ -268,3 +269,31 @@ def get_challenge(self, *args, **kwargs) -> RedirectChallenge:

def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return HttpChallengeResponse(self.get_challenge())


class SessionEndStage(ChallengeStageView):
"""Stage inserted when a flow is used as invalidation flow. By default shows actions
that the user is likely to take after signing out of a provider."""

def get_challenge(self, *args, **kwargs) -> Challenge:
application: Application | None = self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION)
data = {
"component": "ak-stage-session-end",
"brand_name": self.request.brand.branding_title,
}
if application:
data["application_name"] = application.name
data["application_launch_url"] = application.get_launch_url(self.get_pending_user())
if self.request.brand.flow_invalidation:
data["invalidation_flow_url"] = reverse(
"authentik_core:if-flow",
kwargs={
"flow_slug": self.request.brand.flow_invalidation.slug,
},
)
return SessionEndChallenge(data=data)

# This can never be reached since this challenge is created on demand and only the
# .get() method is called
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: # pragma: no cover
return self.executor.cancel()
Loading

0 comments on commit 5b66dbe

Please sign in to comment.