flows: planner error handling (#4812)

* handle FlowNonApplicableException everywhere

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

* make flow planner check authentication when no pending user is in planning context

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

* add mailhog to e2e test services, remove local docker requirement

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2023-02-28 15:18:29 +01:00 committed by GitHub
parent 6f2f4f4aa3
commit 20e971f5ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 109 additions and 108 deletions

View File

@ -68,6 +68,7 @@ from authentik.core.models import (
User, User,
) )
from authentik.events.models import EventAction from authentik.events.models import EventAction
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import FlowToken from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN from authentik.flows.views.executor import QS_KEY_TOKEN
@ -326,12 +327,16 @@ class UserViewSet(UsedByMixin, ModelViewSet):
user: User = self.get_object() user: User = self.get_object()
planner = FlowPlanner(flow) planner = FlowPlanner(flow)
planner.allow_empty_flows = True planner.allow_empty_flows = True
try:
plan = planner.plan( plan = planner.plan(
self.request._request, self.request._request,
{ {
PLAN_CONTEXT_PENDING_USER: user, PLAN_CONTEXT_PENDING_USER: user,
}, },
) )
except FlowNonApplicableException:
LOGGER.warning("Recovery flow not applicable to user")
return None, None
token, __ = FlowToken.objects.update_or_create( token, __ = FlowToken.objects.update_or_create(
identifier=f"{user.uid}-password-reset", identifier=f"{user.uid}-password-reset",
defaults={ defaults={

View File

@ -11,6 +11,7 @@ from authentik.flows.challenge import (
HttpChallengeResponse, HttpChallengeResponse,
RedirectChallenge, RedirectChallenge,
) )
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
@ -41,6 +42,7 @@ class RedirectToAppLaunch(View):
flow = tenant.flow_authentication flow = tenant.flow_authentication
planner = FlowPlanner(flow) planner = FlowPlanner(flow)
planner.allow_empty_flows = True planner.allow_empty_flows = True
try:
plan = planner.plan( plan = planner.plan(
request, request,
{ {
@ -50,6 +52,8 @@ class RedirectToAppLaunch(View):
PLAN_CONTEXT_CONSENT_PERMISSIONS: [], PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
}, },
) )
except FlowNonApplicableException:
raise Http404
plan.insert_stage(in_memory_stage(RedirectToAppStage)) plan.insert_stage(in_memory_stage(RedirectToAppStage))
request.session[SESSION_KEY_PLAN] = plan request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug) return redirect_with_qs("authentik_core:if-flow", request.GET, flow_slug=flow.slug)

View File

@ -147,7 +147,6 @@ class FlowPlanner:
) -> FlowPlan: ) -> FlowPlan:
"""Check each of the flows' policies, check policies for each stage with PolicyBinding """Check each of the flows' policies, check policies for each stage with PolicyBinding
and return ordered list""" and return ordered list"""
self._check_authentication(request)
with Hub.current.start_span( with Hub.current.start_span(
op="authentik.flow.planner.plan", description=self.flow.slug op="authentik.flow.planner.plan", description=self.flow.slug
) as span: ) as span:
@ -165,6 +164,12 @@ class FlowPlanner:
user = default_context[PLAN_CONTEXT_PENDING_USER] user = default_context[PLAN_CONTEXT_PENDING_USER]
else: else:
user = request.user user = request.user
# We only need to check the flow authentication if it's planned without a user
# in the context, as a user in the context can only be set via the explicit code API
# or if a flow is restarted due to `invalid_response_action` being set to
# `restart_with_context`, which can only happen if the user was already authorized
# to use the flow
self._check_authentication(request)
# First off, check the flow's direct policy bindings # First off, check the flow's direct policy bindings
# to make sure the user even has access to the flow # to make sure the user even has access to the flow
engine = PolicyEngine(self.flow, user, request) engine = PolicyEngine(self.flow, user, request)

View File

@ -561,9 +561,13 @@ class ConfigureFlowInitView(LoginRequiredMixin, View):
LOGGER.debug("Stage has no configure_flow set", stage=stage) LOGGER.debug("Stage has no configure_flow set", stage=stage)
raise Http404 raise Http404
try:
plan = FlowPlanner(stage.configure_flow).plan( plan = FlowPlanner(stage.configure_flow).plan(
request, {PLAN_CONTEXT_PENDING_USER: request.user} request, {PLAN_CONTEXT_PENDING_USER: request.user}
) )
except FlowNonApplicableException:
LOGGER.warning("Flow not applicable to user")
raise Http404
request.session[SESSION_KEY_PLAN] = plan request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs( return redirect_with_qs(
"authentik_core:if-flow", "authentik_core:if-flow",

View File

@ -24,6 +24,7 @@ from authentik.flows.challenge import (
ChallengeTypes, ChallengeTypes,
HttpChallengeResponse, HttpChallengeResponse,
) )
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
@ -373,6 +374,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
# Regardless, we start the planner and return to it # Regardless, we start the planner and return to it
planner = FlowPlanner(self.provider.authorization_flow) planner = FlowPlanner(self.provider.authorization_flow)
planner.allow_empty_flows = True planner.allow_empty_flows = True
try:
plan = planner.plan( plan = planner.plan(
self.request, self.request,
{ {
@ -386,6 +388,8 @@ class AuthorizationFlowInitView(PolicyAccessView):
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions, PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
}, },
) )
except FlowNonApplicableException:
return self.handle_no_permission_authenticated()
# OpenID clients can specify a `prompt` parameter, and if its set to consent we # OpenID clients can specify a `prompt` parameter, and if its set to consent we
# need to inject a consent stage # need to inject a consent stage
if PROMPT_CONSENT in self.params.prompt: if PROMPT_CONSENT in self.params.prompt:

View File

@ -10,6 +10,7 @@ from structlog.stdlib import get_logger
from authentik.core.models import Application from authentik.core.models import Application
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
@ -57,6 +58,7 @@ def validate_code(code: int, request: HttpRequest) -> Optional[HttpResponse]:
scope_descriptions = UserInfoView().get_scope_descriptions(token.scope) scope_descriptions = UserInfoView().get_scope_descriptions(token.scope)
planner = FlowPlanner(token.provider.authorization_flow) planner = FlowPlanner(token.provider.authorization_flow)
planner.allow_empty_flows = True planner.allow_empty_flows = True
try:
plan = planner.plan( plan = planner.plan(
request, request,
{ {
@ -70,6 +72,9 @@ def validate_code(code: int, request: HttpRequest) -> Optional[HttpResponse]:
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions, PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
}, },
) )
except FlowNonApplicableException:
LOGGER.warning("Flow not applicable to user")
return None
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage)) plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
request.session[SESSION_KEY_PLAN] = plan request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs( return redirect_with_qs(
@ -97,7 +102,11 @@ class DeviceEntryView(View):
# Regardless, we start the planner and return to it # Regardless, we start the planner and return to it
planner = FlowPlanner(device_flow) planner = FlowPlanner(device_flow)
planner.allow_empty_flows = True planner.allow_empty_flows = True
try:
plan = planner.plan(self.request) plan = planner.plan(self.request)
except FlowNonApplicableException:
LOGGER.warning("Flow not applicable to user")
return HttpResponse(status=404)
plan.append_stage(in_memory_stage(OAuthDeviceCodeStage)) plan.append_stage(in_memory_stage(OAuthDeviceCodeStage))
self.request.session[SESSION_KEY_PLAN] = plan self.request.session[SESSION_KEY_PLAN] = plan

View File

@ -1,7 +1,7 @@
"""authentik SAML IDP Views""" """authentik SAML IDP Views"""
from typing import Optional from typing import Optional
from django.http import HttpRequest, HttpResponse from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -11,6 +11,7 @@ from structlog.stdlib import get_logger
from authentik.core.models import Application from authentik.core.models import Application
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.views.executor import SESSION_KEY_PLAN, SESSION_KEY_POST from authentik.flows.views.executor import SESSION_KEY_PLAN, SESSION_KEY_POST
@ -60,6 +61,7 @@ class SAMLSSOView(PolicyAccessView):
# Regardless, we start the planner and return to it # Regardless, we start the planner and return to it
planner = FlowPlanner(self.provider.authorization_flow) planner = FlowPlanner(self.provider.authorization_flow)
planner.allow_empty_flows = True planner.allow_empty_flows = True
try:
plan = planner.plan( plan = planner.plan(
request, request,
{ {
@ -70,6 +72,8 @@ class SAMLSSOView(PolicyAccessView):
PLAN_CONTEXT_CONSENT_PERMISSIONS: [], PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
}, },
) )
except FlowNonApplicableException:
raise Http404
plan.append_stage(in_memory_stage(SAMLFlowFinalView)) plan.append_stage(in_memory_stage(SAMLFlowFinalView))
request.session[SESSION_KEY_PLAN] = plan request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs( return redirect_with_qs(

View File

@ -22,6 +22,7 @@ from authentik.flows.challenge import (
ChallengeResponse, ChallengeResponse,
ChallengeTypes, ChallengeTypes,
) )
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage from authentik.flows.models import in_memory_stage
from authentik.flows.planner import ( from authentik.flows.planner import (
PLAN_CONTEXT_REDIRECT, PLAN_CONTEXT_REDIRECT,
@ -87,7 +88,10 @@ class InitiateView(View):
# We run the Flow planner here so we can pass the Pending user in the context # We run the Flow planner here so we can pass the Pending user in the context
planner = FlowPlanner(source.pre_authentication_flow) planner = FlowPlanner(source.pre_authentication_flow)
planner.allow_empty_flows = True planner.allow_empty_flows = True
try:
plan = planner.plan(self.request, kwargs) plan = planner.plan(self.request, kwargs)
except FlowNonApplicableException:
raise Http404
for stage in stages_to_append: for stage in stages_to_append:
plan.append_stage(stage) plan.append_stage(stage)
self.request.session[SESSION_KEY_PLAN] = plan self.request.session[SESSION_KEY_PLAN] = plan

View File

@ -13,9 +13,9 @@ from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, QS_KEY_TOKEN from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE
class TestEmailStage(FlowTestCase): class TestEmailStage(FlowTestCase):

View File

@ -2,8 +2,17 @@ version: '3.7'
services: services:
chrome: chrome:
image: selenium/standalone-chrome:103.0-chromedriver-103.0 image: selenium/standalone-chrome:110.0
volumes: volumes:
- /dev/shm:/dev/shm - /dev/shm:/dev/shm
network_mode: host network_mode: host
restart: always restart: always
mailhog:
image: mailhog/mailhog:v1.0.1
ports:
- 1025:1025
- 8025:8025
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:8025"]
interval: 5s
start_period: 1s

View File

@ -1,8 +1,6 @@
"""test flow with otp stages""" """test flow with otp stages"""
from base64 import b32decode from base64 import b32decode
from sys import platform
from time import sleep from time import sleep
from unittest.case import skipUnless
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
from django_otp.oath import TOTP from django_otp.oath import TOTP
@ -20,7 +18,6 @@ from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage
from tests.e2e.utils import SeleniumTestCase, retry from tests.e2e.utils import SeleniumTestCase, retry
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsAuthenticator(SeleniumTestCase): class TestFlowsAuthenticator(SeleniumTestCase):
"""test flow with otp stages""" """test flow with otp stages"""

View File

@ -1,11 +1,7 @@
"""Test Enroll flow""" """Test Enroll flow"""
from sys import platform
from time import sleep from time import sleep
from typing import Any, Optional
from unittest.case import skipUnless
from django.test import override_settings from django.test import override_settings
from docker.types import Healthcheck
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support.wait import WebDriverWait
@ -17,23 +13,9 @@ from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry from tests.e2e.utils import SeleniumTestCase, retry
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsEnroll(SeleniumTestCase): class TestFlowsEnroll(SeleniumTestCase):
"""Test Enroll flow""" """Test Enroll flow"""
def get_container_specs(self) -> Optional[dict[str, Any]]:
return {
"image": "mailhog/mailhog:v1.0.1",
"detach": True,
"network_mode": "host",
"auto_remove": True,
"healthcheck": Healthcheck(
test=["CMD", "wget", "--spider", "http://localhost:8025"],
interval=5 * 100 * 1000000,
start_period=1 * 100 * 1000000,
),
}
@retry() @retry()
@apply_blueprint( @apply_blueprint(
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",

View File

@ -1,12 +1,8 @@
"""test default login flow""" """test default login flow"""
from sys import platform
from unittest.case import skipUnless
from authentik.blueprints.tests import apply_blueprint from authentik.blueprints.tests import apply_blueprint
from tests.e2e.utils import SeleniumTestCase, retry from tests.e2e.utils import SeleniumTestCase, retry
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsLogin(SeleniumTestCase): class TestFlowsLogin(SeleniumTestCase):
"""test default login flow""" """test default login flow"""

View File

@ -1,11 +1,7 @@
"""Test recovery flow""" """Test recovery flow"""
from sys import platform
from time import sleep from time import sleep
from typing import Any, Optional
from unittest.case import skipUnless
from django.test import override_settings from django.test import override_settings
from docker.types import Healthcheck
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait from selenium.webdriver.support.wait import WebDriverWait
@ -19,23 +15,9 @@ from authentik.stages.identification.models import IdentificationStage
from tests.e2e.utils import SeleniumTestCase, retry from tests.e2e.utils import SeleniumTestCase, retry
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsRecovery(SeleniumTestCase): class TestFlowsRecovery(SeleniumTestCase):
"""Test Recovery flow""" """Test Recovery flow"""
def get_container_specs(self) -> Optional[dict[str, Any]]:
return {
"image": "mailhog/mailhog:v1.0.1",
"detach": True,
"network_mode": "host",
"auto_remove": True,
"healthcheck": Healthcheck(
test=["CMD", "wget", "--spider", "http://localhost:8025"],
interval=5 * 100 * 1000000,
start_period=1 * 100 * 1000000,
),
}
def initial_stages(self, user: User): def initial_stages(self, user: User):
"""Fill out initial stages""" """Fill out initial stages"""
# Identification stage, click recovery # Identification stage, click recovery

View File

@ -1,7 +1,4 @@
"""test stage setup flows (password change)""" """test stage setup flows (password change)"""
from sys import platform
from unittest.case import skipUnless
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
@ -13,7 +10,6 @@ from authentik.stages.password.models import PasswordStage
from tests.e2e.utils import SeleniumTestCase, retry from tests.e2e.utils import SeleniumTestCase, retry
@skipUnless(platform.startswith("linux"), "requires local docker")
class TestFlowsStageSetup(SeleniumTestCase): class TestFlowsStageSetup(SeleniumTestCase):
"""test stage setup flows""" """test stage setup flows"""