flows: revert to sever-side redirects for security, pass querystring from client during flow plan

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-03-23 22:18:24 +01:00
parent 9427942ea8
commit 4137266041
9 changed files with 32 additions and 85 deletions

View File

@ -4,11 +4,13 @@ from typing import Any, Optional
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
from django.http.request import QueryDict
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import View from django.views.generic import View
from drf_yasg2 import openapi
from drf_yasg2.utils import no_body, swagger_auto_schema from drf_yasg2.utils import no_body, swagger_auto_schema
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.views import APIView from rest_framework.views import APIView
@ -101,6 +103,8 @@ class FlowExecutorView(APIView):
# To match behaviour with loading an empty flow plan from cache, # To match behaviour with loading an empty flow plan from cache,
# we don't show an error message here, but rather call _flow_done() # we don't show an error message here, but rather call _flow_done()
return self._flow_done() return self._flow_done()
# Initial flow request, check if we have an upstream query string passed in
request.session[SESSION_KEY_GET] = QueryDict(request.GET.get("query", ""))
# We don't save the Plan after getting the next stage # We don't save the Plan after getting the next stage
# as it hasn't been successfully passed yet # as it hasn't been successfully passed yet
next_stage = self.plan.next(self.request) next_stage = self.plan.next(self.request)
@ -121,8 +125,19 @@ class FlowExecutorView(APIView):
return super().dispatch(request) return super().dispatch(request)
@swagger_auto_schema( @swagger_auto_schema(
responses={200: Challenge()}, responses={
200: Challenge(),
},
request_body=no_body, request_body=no_body,
manual_parameters=[
openapi.Parameter(
"query",
openapi.IN_QUERY,
required=True,
description="Querystring as received",
type=openapi.TYPE_STRING,
)
],
operation_id="flows_executor_get", operation_id="flows_executor_get",
) )
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:

View File

@ -16,7 +16,6 @@ class AuthentikStageEmailConfig(AppConfig):
name = "authentik.stages.email" name = "authentik.stages.email"
label = "authentik_stages_email" label = "authentik_stages_email"
verbose_name = "authentik Stages.Email" verbose_name = "authentik Stages.Email"
mountpoint = "stages/email/"
def ready(self): def ready(self):
import_module("authentik.stages.email.tasks") import_module("authentik.stages.email.tasks")

View File

@ -45,7 +45,7 @@ class EmailStageView(ChallengeStageView):
def get_full_url(self, **kwargs) -> str: def get_full_url(self, **kwargs) -> str:
"""Get full URL to be used in template""" """Get full URL to be used in template"""
base_url = reverse( base_url = reverse(
"authentik_stages_email:from-email", "authentik_core:if-flow",
kwargs={"flow_slug": self.executor.flow.slug}, kwargs={"flow_slug": self.executor.flow.slug},
) )
relative_url = f"{base_url}?{urlencode(kwargs)}" relative_url = f"{base_url}?{urlencode(kwargs)}"
@ -66,7 +66,7 @@ class EmailStageView(ChallengeStageView):
template_name=current_stage.template, template_name=current_stage.template,
to=[pending_user.email], to=[pending_user.email],
template_context={ template_context={
"url": self.get_full_url(**{QS_KEY_TOKEN: token.pk.hex}), "url": self.get_full_url(**{QS_KEY_TOKEN: token.key}),
"user": pending_user, "user": pending_user,
"expires": token.expires, "expires": token.expires,
}, },
@ -77,7 +77,7 @@ class EmailStageView(ChallengeStageView):
# Check if the user came back from the email link to verify # Check if the user came back from the email link to verify
if QS_KEY_TOKEN in request.session.get(SESSION_KEY_GET, {}): if QS_KEY_TOKEN in request.session.get(SESSION_KEY_GET, {}):
token = get_object_or_404( token = get_object_or_404(
Token, pk=request.session[SESSION_KEY_GET][QS_KEY_TOKEN] Token, key=request.session[SESSION_KEY_GET][QS_KEY_TOKEN]
) )
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user
token.delete() token.delete()

View File

@ -10,7 +10,7 @@ from authentik.core.models import Token, User
from authentik.flows.markers import StageMarker from authentik.flows.markers import StageMarker
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.models import Flow, 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.views import SESSION_KEY_PLAN from authentik.flows.views import SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.stages.email.stage import QS_KEY_TOKEN from authentik.stages.email.stage import QS_KEY_TOKEN
@ -104,17 +104,11 @@ class TestEmailStage(TestCase):
) )
session = self.client.session session = self.client.session
session[SESSION_KEY_PLAN] = plan session[SESSION_KEY_PLAN] = plan
token: Token = Token.objects.get(user=self.user)
session[SESSION_KEY_GET] = {QS_KEY_TOKEN: token.key}
session.save() session.save()
with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()): with patch("authentik.flows.views.FlowExecutorView.cancel", MagicMock()):
# Call the executor shell to preseed the session
url = reverse(
"authentik_stages_email:from-email",
kwargs={"flow_slug": self.flow.slug},
)
token = Token.objects.get(user=self.user)
url += f"?{QS_KEY_TOKEN}={token.pk.hex}"
self.client.get(url)
# Call the actual executor to get the JSON Response # Call the actual executor to get the JSON Response
response = self.client.get( response = self.client.get(
reverse( reverse(

View File

@ -1,8 +0,0 @@
"""Email stage url patterns"""
from django.urls import path
from authentik.stages.email.views import FromEmailView
urlpatterns = [
path("from-email/<slug:flow_slug>/", FromEmailView.as_view(), name="from-email"),
]

View File

@ -1,31 +0,0 @@
"""Email stage views"""
from django.http.request import HttpRequest
from django.http.response import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from structlog.stdlib import get_logger
from authentik.core.models import Token
from authentik.flows.views import SESSION_KEY_GET
from authentik.stages.email.stage import QS_KEY_TOKEN
LOGGER = get_logger()
class FromEmailView(View):
"""FromEmailView, this view is linked in the email confirmation link.
It is required because the flow executor does not pass query args to the API,
so this view gets called, checks for a Querystring and updates the plan
if everything is valid."""
def get(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
"""Check for ?token param and validate it."""
if QS_KEY_TOKEN not in request.GET:
LOGGER.debug("No token set")
return HttpResponseBadRequest()
# Lookup token here to quickly fail for invalid input
get_object_or_404(Token, pk=request.GET[QS_KEY_TOKEN])
if SESSION_KEY_GET not in request.session:
request.session[SESSION_KEY_GET] = {}
request.session[SESSION_KEY_GET][QS_KEY_TOKEN] = request.GET[QS_KEY_TOKEN]
return redirect("authentik_core:if-flow", flow_slug=flow_slug)

View File

@ -2744,7 +2744,12 @@ paths:
get: get:
operationId: flows_executor_get operationId: flows_executor_get
description: Get the next pending challenge from the currently active flow. description: Get the next pending challenge from the currently active flow.
parameters: [] parameters:
- name: query
in: query
description: Querystring as received
required: true
type: string
responses: responses:
'200': '200':
description: Challenge that gets sent to the client based on which stage description: Challenge that gets sent to the client based on which stage

View File

@ -40,11 +40,8 @@ import { ifDefined } from "lit-html/directives/if-defined";
import { until } from "lit-html/directives/until"; import { until } from "lit-html/directives/until";
import { TITLE_SUFFIX } from "../elements/router/RouterOutlet"; import { TITLE_SUFFIX } from "../elements/router/RouterOutlet";
import { AccessDeniedChallenge } from "./access_denied/FlowAccessDenied"; import { AccessDeniedChallenge } from "./access_denied/FlowAccessDenied";
import { getQueryVariables } from "./utils";
import { SpinnerSize } from "../elements/Spinner"; import { SpinnerSize } from "../elements/Spinner";
export const NEXT_ARG = "next";
@customElement("ak-flow-executor") @customElement("ak-flow-executor")
export class FlowExecutor extends LitElement implements StageHost { export class FlowExecutor extends LitElement implements StageHost {
@ -129,7 +126,8 @@ export class FlowExecutor extends LitElement implements StageHost {
}); });
this.loading = true; this.loading = true;
new FlowsApi(DEFAULT_CONFIG).flowsExecutorGetRaw({ new FlowsApi(DEFAULT_CONFIG).flowsExecutorGetRaw({
flowSlug: this.flowSlug flowSlug: this.flowSlug,
query: window.location.search.substring(1),
}).then((challengeRaw) => { }).then((challengeRaw) => {
return challengeRaw.raw.json(); return challengeRaw.raw.json();
}).then((challenge) => { }).then((challenge) => {
@ -168,29 +166,14 @@ export class FlowExecutor extends LitElement implements StageHost {
</div>`; </div>`;
} }
private redirect(challenge: RedirectChallenge): void {
// Check if there is a ?next arg and save it
// this is used for deep linking, if a user tries to access an application,
// but needs to authenticate first
const queryVars = getQueryVariables();
localStorage.clear();
if (NEXT_ARG in queryVars) {
const next = queryVars[NEXT_ARG];
console.debug("authentik/flows: redirecting to saved url", next);
window.location.assign(next);
return;
}
console.debug("authentik/flows: redirecting to url from server", challenge.to);
window.location.assign(challenge.to);
}
renderChallenge(): TemplateResult { renderChallenge(): TemplateResult {
if (!this.challenge) { if (!this.challenge) {
return html``; return html``;
} }
switch (this.challenge.type) { switch (this.challenge.type) {
case ChallengeTypeEnum.Redirect: case ChallengeTypeEnum.Redirect:
this.redirect(this.challenge as RedirectChallenge); console.debug("authentik/flows: redirecting to url from server", (this.challenge as RedirectChallenge).to);
window.location.assign((this.challenge as RedirectChallenge).to);
return html`<ak-empty-state return html`<ak-empty-state
?loading=${true} ?loading=${true}
header=${gettext("Loading")}> header=${gettext("Loading")}>

View File

@ -1,10 +0,0 @@
export function getQueryVariables(): Record<string, string> {
const query = window.location.search.substring(1);
const vars = query.split("&");
const entries: Record<string, string> = {};
for (let i = 0; i < vars.length; i++) {
const pair = vars[i].split("=");
entries[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1]);
}
return entries;
}