flows: add warning message for expired password reset links (#21395)

* flows: add warning message for expired password reset links

Fixes #21306

* Replace token expiry check with REQUIRE_TOKEN authentication requirement

Incorporate review comments to move expired/invalid token handling from executor-level check to flow planner authentication requirement. This avoids disclosing whether a token ever existed and handles already-cleaned-up tokens.

* The fix was changing gettext_lazy to gettext

* remove unneeded migration

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

* update form

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Bapuji Koraganti
2026-04-22 09:09:05 -04:00
committed by GitHub
parent 9d55b9a9b0
commit 24edee3e78
11 changed files with 127 additions and 4 deletions

View File

@@ -11,6 +11,10 @@ class FlowNonApplicableException(SentryIgnoredException):
policy_result: PolicyResult | None = None policy_result: PolicyResult | None = None
def __init__(self, policy_result: PolicyResult | None = None, *args):
super().__init__(*args)
self.policy_result = policy_result
@property @property
def messages(self) -> str: def messages(self) -> str:
"""Get messages from policy result, fallback to generic reason""" """Get messages from policy result, fallback to generic reason"""

View File

@@ -42,6 +42,7 @@ class Migration(migrations.Migration):
("require_superuser", "Require Superuser"), ("require_superuser", "Require Superuser"),
("require_redirect", "Require Redirect"), ("require_redirect", "Require Redirect"),
("require_outpost", "Require Outpost"), ("require_outpost", "Require Outpost"),
("require_token", "Require Token"),
], ],
default="none", default="none",
help_text="Required level of authentication and authorization to access a flow.", help_text="Required level of authentication and authorization to access a flow.",

View File

@@ -40,6 +40,7 @@ class FlowAuthenticationRequirement(models.TextChoices):
REQUIRE_SUPERUSER = "require_superuser" REQUIRE_SUPERUSER = "require_superuser"
REQUIRE_REDIRECT = "require_redirect" REQUIRE_REDIRECT = "require_redirect"
REQUIRE_OUTPOST = "require_outpost" REQUIRE_OUTPOST = "require_outpost"
REQUIRE_TOKEN = "require_token"
class NotConfiguredAction(models.TextChoices): class NotConfiguredAction(models.TextChoices):

View File

@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Any
from django.core.cache import cache from django.core.cache import cache
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from sentry_sdk import start_span from sentry_sdk import start_span
from sentry_sdk.tracing import Span from sentry_sdk.tracing import Span
from structlog.stdlib import BoundLogger, get_logger from structlog.stdlib import BoundLogger, get_logger
@@ -26,6 +27,7 @@ from authentik.lib.config import CONFIG
from authentik.lib.utils.urls import redirect_with_qs from authentik.lib.utils.urls import redirect_with_qs
from authentik.outposts.models import Outpost from authentik.outposts.models import Outpost
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyResult
from authentik.root.middleware import ClientIPMiddleware from authentik.root.middleware import ClientIPMiddleware
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -226,6 +228,15 @@ class FlowPlanner:
and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None and context.get(PLAN_CONTEXT_IS_REDIRECTED) is None
): ):
raise FlowNonApplicableException() raise FlowNonApplicableException()
if (
self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_TOKEN
and context.get(PLAN_CONTEXT_IS_RESTORED) is None
):
raise FlowNonApplicableException(
PolicyResult(
False, _("This link is invalid or has expired. Please request a new one.")
)
)
outpost_user = ClientIPMiddleware.get_outpost_user(request) outpost_user = ClientIPMiddleware.get_outpost_user(request)
if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST: if self.flow.authentication == FlowAuthenticationRequirement.REQUIRE_OUTPOST:
if not outpost_user: if not outpost_user:
@@ -273,9 +284,7 @@ class FlowPlanner:
engine.build() engine.build()
result = engine.result result = engine.result
if not result.passing: if not result.passing:
exc = FlowNonApplicableException() raise FlowNonApplicableException(result)
exc.policy_result = result
raise exc
# User is passing so far, check if we have a cached plan # User is passing so far, check if we have a cached plan
cached_plan_key = cache_key(self.flow, user) cached_plan_key = cache_key(self.flow, user)
cached_plan = cache.get(cached_plan_key, None) cached_plan = cache.get(cached_plan_key, None)

View File

@@ -1,5 +1,6 @@
"""flow views tests""" """flow views tests"""
from datetime import timedelta
from unittest.mock import MagicMock, PropertyMock, patch from unittest.mock import MagicMock, PropertyMock, patch
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -7,6 +8,7 @@ from django.http import HttpRequest, HttpResponse
from django.test import override_settings from django.test import override_settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now
from rest_framework.exceptions import ParseError from rest_framework.exceptions import ParseError
from authentik.core.models import Group, User from authentik.core.models import Group, User
@@ -17,6 +19,7 @@ from authentik.flows.models import (
FlowDeniedAction, FlowDeniedAction,
FlowDesignation, FlowDesignation,
FlowStageBinding, FlowStageBinding,
FlowToken,
InvalidResponseAction, InvalidResponseAction,
) )
from authentik.flows.planner import FlowPlan, FlowPlanner from authentik.flows.planner import FlowPlan, FlowPlanner
@@ -24,6 +27,7 @@ from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageVie
from authentik.flows.tests import FlowTestCase from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import ( from authentik.flows.views.executor import (
NEXT_ARG_NAME, NEXT_ARG_NAME,
QS_KEY_TOKEN,
QS_QUERY, QS_QUERY,
SESSION_KEY_PLAN, SESSION_KEY_PLAN,
FlowExecutorView, FlowExecutorView,
@@ -740,3 +744,77 @@ class TestFlowExecutor(FlowTestCase):
"title": flow.title, "title": flow.title,
}, },
) )
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_expired_flow_token(self):
"""Test that an expired flow token shows an appropriate error message"""
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
)
user = create_test_user()
plan = FlowPlan(flow_pk=flow.pk.hex, bindings=[], markers=[])
token = FlowToken.objects.create(
user=user,
identifier=generate_id(),
flow=flow,
_plan=FlowToken.pickle(plan),
expires=now() - timedelta(hours=1),
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(
url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: token.key})})}"
)
self.assertStageResponse(
response,
flow,
component="ak-stage-access-denied",
error_message="This link is invalid or has expired. Please request a new one.",
)
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_invalid_flow_token_require_token(self):
"""Test that an invalid/nonexistent token on a REQUIRE_TOKEN flow shows error"""
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(
url + f"?{urlencode({QS_QUERY: urlencode({QS_KEY_TOKEN: 'invalid-token'})})}"
)
self.assertStageResponse(
response,
flow,
component="ak-stage-access-denied",
error_message="This link is invalid or has expired. Please request a new one.",
)
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_no_token_require_token(self):
"""Test that accessing a REQUIRE_TOKEN flow without any token shows error"""
flow = create_test_flow(
FlowDesignation.RECOVERY,
authentication=FlowAuthenticationRequirement.REQUIRE_TOKEN,
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
response = self.client.get(url)
self.assertStageResponse(
response,
flow,
component="ak-stage-access-denied",
error_message="This link is invalid or has expired. Please request a new one.",
)

View File

@@ -26,6 +26,7 @@ from authentik.flows.models import (
) )
from authentik.flows.planner import ( from authentik.flows.planner import (
PLAN_CONTEXT_IS_REDIRECTED, PLAN_CONTEXT_IS_REDIRECTED,
PLAN_CONTEXT_IS_RESTORED,
PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_PENDING_USER,
FlowPlanner, FlowPlanner,
cache_key, cache_key,
@@ -129,6 +130,22 @@ class TestFlowPlanner(TestCase):
planner.allow_empty_flows = True planner.allow_empty_flows = True
planner.plan(request) planner.plan(request)
def test_authentication_require_token(self):
"""Test flow authentication (require_token)"""
flow = create_test_flow()
flow.authentication = FlowAuthenticationRequirement.REQUIRE_TOKEN
request = self.request_factory.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
planner = FlowPlanner(flow)
planner.allow_empty_flows = True
with self.assertRaises(FlowNonApplicableException):
planner.plan(request)
context = {PLAN_CONTEXT_IS_RESTORED: True}
planner.plan(request, context)
@patch( @patch(
"authentik.policies.engine.PolicyEngine.result", "authentik.policies.engine.PolicyEngine.result",
POLICY_RETURN_FALSE, POLICY_RETURN_FALSE,

View File

@@ -62,6 +62,7 @@ from authentik.policies.engine import PolicyEngine
LOGGER = get_logger() LOGGER = get_logger()
# Argument used to redirect user after login # Argument used to redirect user after login
NEXT_ARG_NAME = "next" NEXT_ARG_NAME = "next"
SESSION_KEY_PLAN = "authentik/flows/plan" SESSION_KEY_PLAN = "authentik/flows/plan"
SESSION_KEY_GET = "authentik/flows/get" SESSION_KEY_GET = "authentik/flows/get"
SESSION_KEY_POST = "authentik/flows/post" SESSION_KEY_POST = "authentik/flows/post"

View File

@@ -8430,7 +8430,8 @@
"require_unauthenticated", "require_unauthenticated",
"require_superuser", "require_superuser",
"require_redirect", "require_redirect",
"require_outpost" "require_outpost",
"require_token"
], ],
"title": "Authentication", "title": "Authentication",
"description": "Required level of authentication and authorization to access a flow." "description": "Required level of authentication and authorization to access a flow."

View File

@@ -23,6 +23,7 @@ export const AuthenticationEnum = {
RequireSuperuser: "require_superuser", RequireSuperuser: "require_superuser",
RequireRedirect: "require_redirect", RequireRedirect: "require_redirect",
RequireOutpost: "require_outpost", RequireOutpost: "require_outpost",
RequireToken: "require_token",
UnknownDefaultOpenApi: "11184809", UnknownDefaultOpenApi: "11184809",
} as const; } as const;
export type AuthenticationEnum = (typeof AuthenticationEnum)[keyof typeof AuthenticationEnum]; export type AuthenticationEnum = (typeof AuthenticationEnum)[keyof typeof AuthenticationEnum];

View File

@@ -34355,6 +34355,7 @@ components:
- require_superuser - require_superuser
- require_redirect - require_redirect
- require_outpost - require_outpost
- require_token
type: string type: string
AuthenticatorAttachmentEnum: AuthenticatorAttachmentEnum:
enum: enum:

View File

@@ -218,6 +218,15 @@ export class FlowForm extends WithCapabilitiesConfig(ModelForm<Flow, string>) {
> >
${msg("Require Outpost (flow can only be executed from an outpost)")} ${msg("Require Outpost (flow can only be executed from an outpost)")}
</option> </option>
<option
value=${AuthenticationEnum.RequireToken}
?selected=${this.instance?.authentication ===
AuthenticationEnum.RequireToken}
>
${msg(
"Require Flow token (flow can only be executed from a generated recovery link)",
)}
</option>
</select> </select>
<p class="pf-c-form__helper-text"> <p class="pf-c-form__helper-text">
${msg("Required authentication level for this flow.")} ${msg("Required authentication level for this flow.")}