Skip to content

Commit 35ff418

Browse files
authored
policies: buffered policy access view for concurrent authorization attempts when unauthenticated (#13629)
* policies: buffered policy access view for concurrent authorization attempts when unauthenticated Signed-off-by: Jens Langhammer <jens@goauthentik.io> * better cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more polish Signed-off-by: Jens Langhammer <jens@goauthentik.io> * more cleanup Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add tests Signed-off-by: Jens Langhammer <jens@goauthentik.io> * fix multiple redirects, add e2e test Signed-off-by: Jens Langhammer <jens@goauthentik.io> * unrelated: add sp initiated post test Signed-off-by: Jens Langhammer <jens@goauthentik.io> * add SAML parallel test Signed-off-by: Jens Langhammer <jens@goauthentik.io> * format Signed-off-by: Jens Langhammer <jens@goauthentik.io> * optimise detection of when authentication is in progress Signed-off-by: Jens Langhammer <jens@goauthentik.io> * better backoff timing Signed-off-by: Jens Langhammer <jens@goauthentik.io> --------- Signed-off-by: Jens Langhammer <jens@goauthentik.io>
1 parent 7826e7a commit 35ff418

File tree

12 files changed

+538
-13
lines changed

12 files changed

+538
-13
lines changed

authentik/flows/views/executor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
SESSION_KEY_GET = "authentik/flows/get"
7070
SESSION_KEY_POST = "authentik/flows/post"
7171
SESSION_KEY_HISTORY = "authentik/flows/history"
72+
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
7273
QS_KEY_TOKEN = "flow_token" # nosec
7374
QS_QUERY = "query"
7475

@@ -453,6 +454,7 @@ def cancel(self):
453454
SESSION_KEY_APPLICATION_PRE,
454455
SESSION_KEY_PLAN,
455456
SESSION_KEY_GET,
457+
SESSION_KEY_AUTH_STARTED,
456458
# We might need the initial POST payloads for later requests
457459
# SESSION_KEY_POST,
458460
# We don't delete the history on purpose, as a user might

authentik/flows/views/interface.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,22 @@
66
from ua_parser.user_agent_parser import Parse
77

88
from authentik.core.views.interface import InterfaceView
9-
from authentik.flows.models import Flow
9+
from authentik.flows.models import Flow, FlowDesignation
10+
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
1011

1112

1213
class FlowInterfaceView(InterfaceView):
1314
"""Flow interface"""
1415

1516
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
16-
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
17+
flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
18+
kwargs["flow"] = flow
19+
if (
20+
not self.request.user.is_authenticated
21+
and flow.designation == FlowDesignation.AUTHENTICATION
22+
):
23+
self.request.session[SESSION_KEY_AUTH_STARTED] = True
24+
self.request.session.save()
1725
kwargs["inspector"] = "inspector" in self.request.GET
1826
return super().get_context_data(**kwargs)
1927

authentik/policies/apps.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
3535
label = "authentik_policies"
3636
verbose_name = "authentik Policies"
3737
default = True
38+
mountpoint = "policy/"
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
{% extends 'login/base_full.html' %}
2+
3+
{% load static %}
4+
{% load i18n %}
5+
6+
{% block head %}
7+
{{ block.super }}
8+
<script>
9+
let redirecting = false;
10+
const checkAuth = async () => {
11+
if (redirecting) return true;
12+
const url = "{{ check_auth_url }}";
13+
console.debug("authentik/policies/buffer: Checking authentication...");
14+
try {
15+
const result = await fetch(url, {
16+
method: "HEAD",
17+
});
18+
if (result.status >= 400) {
19+
return false
20+
}
21+
console.debug("authentik/policies/buffer: Continuing");
22+
redirecting = true;
23+
if ("{{ auth_req_method }}" === "post") {
24+
document.querySelector("form").submit();
25+
} else {
26+
window.location.assign("{{ continue_url|escapejs }}");
27+
}
28+
} catch {
29+
return false;
30+
}
31+
};
32+
let timeout = 100;
33+
let offset = 20;
34+
let attempt = 0;
35+
const main = async () => {
36+
attempt += 1;
37+
await checkAuth();
38+
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
39+
setTimeout(main, timeout);
40+
timeout += (offset * attempt);
41+
if (timeout >= 2000) {
42+
timeout = 2000;
43+
}
44+
}
45+
document.addEventListener("visibilitychange", async () => {
46+
if (document.hidden) return;
47+
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
48+
await checkAuth();
49+
});
50+
main();
51+
</script>
52+
{% endblock %}
53+
54+
{% block title %}
55+
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
56+
{% endblock %}
57+
58+
{% block card_title %}
59+
{% trans 'Waiting for authentication...' %}
60+
{% endblock %}
61+
62+
{% block card %}
63+
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
64+
{% if auth_req_method == "post" %}
65+
{% for key, value in auth_req_body.items %}
66+
<input type="hidden" name="{{ key }}" value="{{ value }}" />
67+
{% endfor %}
68+
{% endif %}
69+
<div class="pf-c-empty-state">
70+
<div class="pf-c-empty-state__content">
71+
<div class="pf-c-empty-state__icon">
72+
<span class="pf-c-spinner pf-m-xl" role="progressbar">
73+
<span class="pf-c-spinner__clipper"></span>
74+
<span class="pf-c-spinner__lead-ball"></span>
75+
<span class="pf-c-spinner__tail-ball"></span>
76+
</span>
77+
</div>
78+
<h1 class="pf-c-title pf-m-lg">
79+
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
80+
</h1>
81+
</div>
82+
</div>
83+
<div class="pf-c-form__group pf-m-action">
84+
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
85+
{% trans "Authenticate in this tab" %}
86+
</a>
87+
</div>
88+
</form>
89+
{% endblock %}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
from django.contrib.auth.models import AnonymousUser
2+
from django.contrib.sessions.middleware import SessionMiddleware
3+
from django.http import HttpResponse
4+
from django.test import RequestFactory, TestCase
5+
from django.urls import reverse
6+
7+
from authentik.core.models import Application, Provider
8+
from authentik.core.tests.utils import create_test_flow, create_test_user
9+
from authentik.flows.models import FlowDesignation
10+
from authentik.flows.planner import FlowPlan
11+
from authentik.flows.views.executor import SESSION_KEY_PLAN
12+
from authentik.lib.generators import generate_id
13+
from authentik.lib.tests.utils import dummy_get_response
14+
from authentik.policies.views import (
15+
QS_BUFFER_ID,
16+
SESSION_KEY_BUFFER,
17+
BufferedPolicyAccessView,
18+
BufferView,
19+
PolicyAccessView,
20+
)
21+
22+
23+
class TestPolicyViews(TestCase):
24+
"""Test PolicyAccessView"""
25+
26+
def setUp(self):
27+
super().setUp()
28+
self.factory = RequestFactory()
29+
self.user = create_test_user()
30+
31+
def test_pav(self):
32+
"""Test simple policy access view"""
33+
provider = Provider.objects.create(
34+
name=generate_id(),
35+
)
36+
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
37+
38+
class TestView(PolicyAccessView):
39+
def resolve_provider_application(self):
40+
self.provider = provider
41+
self.application = app
42+
43+
def get(self, *args, **kwargs):
44+
return HttpResponse("foo")
45+
46+
req = self.factory.get("/")
47+
req.user = self.user
48+
res = TestView.as_view()(req)
49+
self.assertEqual(res.status_code, 200)
50+
self.assertEqual(res.content, b"foo")
51+
52+
def test_pav_buffer(self):
53+
"""Test simple policy access view"""
54+
provider = Provider.objects.create(
55+
name=generate_id(),
56+
)
57+
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
58+
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
59+
60+
class TestView(BufferedPolicyAccessView):
61+
def resolve_provider_application(self):
62+
self.provider = provider
63+
self.application = app
64+
65+
def get(self, *args, **kwargs):
66+
return HttpResponse("foo")
67+
68+
req = self.factory.get("/")
69+
req.user = AnonymousUser()
70+
middleware = SessionMiddleware(dummy_get_response)
71+
middleware.process_request(req)
72+
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
73+
req.session.save()
74+
res = TestView.as_view()(req)
75+
self.assertEqual(res.status_code, 302)
76+
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
77+
78+
def test_pav_buffer_skip(self):
79+
"""Test simple policy access view (skip buffer)"""
80+
provider = Provider.objects.create(
81+
name=generate_id(),
82+
)
83+
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
84+
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
85+
86+
class TestView(BufferedPolicyAccessView):
87+
def resolve_provider_application(self):
88+
self.provider = provider
89+
self.application = app
90+
91+
def get(self, *args, **kwargs):
92+
return HttpResponse("foo")
93+
94+
req = self.factory.get("/?skip_buffer=true")
95+
req.user = AnonymousUser()
96+
middleware = SessionMiddleware(dummy_get_response)
97+
middleware.process_request(req)
98+
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
99+
req.session.save()
100+
res = TestView.as_view()(req)
101+
self.assertEqual(res.status_code, 302)
102+
self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication")))
103+
104+
def test_buffer(self):
105+
"""Test buffer view"""
106+
uid = generate_id()
107+
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
108+
req.user = AnonymousUser()
109+
middleware = SessionMiddleware(dummy_get_response)
110+
middleware.process_request(req)
111+
ts = generate_id()
112+
req.session[SESSION_KEY_BUFFER % uid] = {
113+
"method": "get",
114+
"body": {},
115+
"url": f"/{ts}",
116+
}
117+
req.session.save()
118+
119+
res = BufferView.as_view()(req)
120+
self.assertEqual(res.status_code, 200)
121+
self.assertIn(ts, res.render().content.decode())

authentik/policies/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
"""API URLs"""
22

3+
from django.urls import path
4+
35
from authentik.policies.api.bindings import PolicyBindingViewSet
46
from authentik.policies.api.policies import PolicyViewSet
7+
from authentik.policies.views import BufferView
8+
9+
urlpatterns = [
10+
path("buffer", BufferView.as_view(), name="buffer"),
11+
]
512

613
api_urlpatterns = [
714
("policies/all", PolicyViewSet),

authentik/policies/views.py

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,37 @@
11
"""authentik access helper classes"""
22

33
from typing import Any
4+
from uuid import uuid4
45

56
from django.contrib import messages
67
from django.contrib.auth.mixins import AccessMixin
78
from django.contrib.auth.views import redirect_to_login
8-
from django.http import HttpRequest, HttpResponse
9+
from django.http import HttpRequest, HttpResponse, QueryDict
10+
from django.shortcuts import redirect
11+
from django.urls import reverse
12+
from django.utils.http import urlencode
913
from django.utils.translation import gettext as _
10-
from django.views.generic.base import View
14+
from django.views.generic.base import TemplateView, View
1115
from structlog.stdlib import get_logger
1216

1317
from authentik.core.models import Application, Provider, User
14-
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
18+
from authentik.flows.models import Flow, FlowDesignation
19+
from authentik.flows.planner import FlowPlan
20+
from authentik.flows.views.executor import (
21+
SESSION_KEY_APPLICATION_PRE,
22+
SESSION_KEY_AUTH_STARTED,
23+
SESSION_KEY_PLAN,
24+
SESSION_KEY_POST,
25+
)
1526
from authentik.lib.sentry import SentryIgnoredException
1627
from authentik.policies.denied import AccessDeniedResponse
1728
from authentik.policies.engine import PolicyEngine
1829
from authentik.policies.types import PolicyRequest, PolicyResult
1930

2031
LOGGER = get_logger()
32+
QS_BUFFER_ID = "af_bf_id"
33+
QS_SKIP_BUFFER = "skip_buffer"
34+
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
2135

2236

2337
class RequestValidationError(SentryIgnoredException):
@@ -125,3 +139,65 @@ def user_has_access(self, user: User | None = None) -> PolicyResult:
125139
for message in result.messages:
126140
messages.error(self.request, _(message))
127141
return result
142+
143+
144+
def url_with_qs(url: str, **kwargs):
145+
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
146+
parameters are retained"""
147+
if "?" not in url:
148+
return url + f"?{urlencode(kwargs)}"
149+
url, _, qs = url.partition("?")
150+
qs = QueryDict(qs, mutable=True)
151+
qs.update(kwargs)
152+
return url + f"?{urlencode(qs.items())}"
153+
154+
155+
class BufferView(TemplateView):
156+
"""Buffer view"""
157+
158+
template_name = "policies/buffer.html"
159+
160+
def get_context_data(self, **kwargs):
161+
buf_id = self.request.GET.get(QS_BUFFER_ID)
162+
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
163+
kwargs["auth_req_method"] = buffer["method"]
164+
kwargs["auth_req_body"] = buffer["body"]
165+
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
166+
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
167+
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
168+
return super().get_context_data(**kwargs)
169+
170+
171+
class BufferedPolicyAccessView(PolicyAccessView):
172+
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
173+
174+
def handle_no_permission(self):
175+
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
176+
authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED)
177+
if plan:
178+
flow = Flow.objects.filter(pk=plan.flow_pk).first()
179+
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
180+
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
181+
return super().handle_no_permission()
182+
if not plan and authenticating is None:
183+
LOGGER.debug("Not buffering request, no flow plan active")
184+
return super().handle_no_permission()
185+
if self.request.GET.get(QS_SKIP_BUFFER):
186+
LOGGER.debug("Not buffering request, explicit skip")
187+
return super().handle_no_permission()
188+
buffer_id = str(uuid4())
189+
LOGGER.debug("Buffering access request", bf_id=buffer_id)
190+
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
191+
"body": self.request.POST,
192+
"url": self.request.build_absolute_uri(self.request.get_full_path()),
193+
"method": self.request.method.lower(),
194+
}
195+
return redirect(
196+
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
197+
)
198+
199+
def dispatch(self, request, *args, **kwargs):
200+
response = super().dispatch(request, *args, **kwargs)
201+
if QS_BUFFER_ID in self.request.GET:
202+
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
203+
return response

authentik/providers/oauth2/views/authorize.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from authentik.lib.utils.time import timedelta_from_string
3131
from authentik.lib.views import bad_request_message
3232
from authentik.policies.types import PolicyRequest
33-
from authentik.policies.views import PolicyAccessView, RequestValidationError
33+
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError
3434
from authentik.providers.oauth2.constants import (
3535
PKCE_METHOD_PLAIN,
3636
PKCE_METHOD_S256,
@@ -328,7 +328,7 @@ def create_code(self, request: HttpRequest) -> AuthorizationCode:
328328
return code
329329

330330

331-
class AuthorizationFlowInitView(PolicyAccessView):
331+
class AuthorizationFlowInitView(BufferedPolicyAccessView):
332332
"""OAuth2 Flow initializer, checks access to application and starts flow"""
333333

334334
params: OAuthAuthorizationParams

0 commit comments

Comments
 (0)