core: add FlowToken which saves the pickled flow plan, replace standard token in email stage to allow finishing flows in different sessions

closes #1801

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-12-05 15:09:06 +01:00
parent ada2a16412
commit 317e9ec605
6 changed files with 131 additions and 21 deletions

View File

@ -25,7 +25,6 @@ from structlog.stdlib import get_logger
from authentik.core.exceptions import PropertyMappingExpressionException from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.signals import password_changed from authentik.core.signals import password_changed
from authentik.core.types import UILoginButton, UserSettingSerializer from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel
@ -203,7 +202,7 @@ class Provider(SerializerModel):
name = models.TextField() name = models.TextField()
authorization_flow = models.ForeignKey( authorization_flow = models.ForeignKey(
Flow, "authentik_flows.Flow",
on_delete=models.CASCADE, on_delete=models.CASCADE,
help_text=_("Flow used when authorizing this provider."), help_text=_("Flow used when authorizing this provider."),
related_name="provider_authorization", related_name="provider_authorization",
@ -324,7 +323,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
authentication_flow = models.ForeignKey( authentication_flow = models.ForeignKey(
Flow, "authentik_flows.Flow",
blank=True, blank=True,
null=True, null=True,
default=None, default=None,
@ -333,7 +332,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
related_name="source_authentication", related_name="source_authentication",
) )
enrollment_flow = models.ForeignKey( enrollment_flow = models.ForeignKey(
Flow, "authentik_flows.Flow",
blank=True, blank=True,
null=True, null=True,
default=None, default=None,

View File

@ -0,0 +1,46 @@
# Generated by Django 3.2.9 on 2021-12-05 13:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0018_auto_20210330_1345_squashed_0028_alter_token_intent"),
(
"authentik_flows",
"0019_alter_flow_background_squashed_0024_alter_flow_compatibility_mode",
),
]
operations = [
migrations.CreateModel(
name="FlowToken",
fields=[
(
"token_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.token",
),
),
("_plan", models.TextField()),
(
"flow",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_flows.flow"
),
),
],
options={
"verbose_name": "Flow Token",
"verbose_name_plural": "Flow Tokens",
},
bases=("authentik_core.token",),
),
]

View File

@ -1,4 +1,6 @@
"""Flow models""" """Flow models"""
from base64 import b64decode, b64encode
from pickle import dumps, loads # nosec
from typing import TYPE_CHECKING, Optional, Type from typing import TYPE_CHECKING, Optional, Type
from uuid import uuid4 from uuid import uuid4
@ -9,11 +11,13 @@ from model_utils.managers import InheritanceManager
from rest_framework.serializers import BaseSerializer from rest_framework.serializers import BaseSerializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Token
from authentik.core.types import UserSettingSerializer from authentik.core.types import UserSettingSerializer
from authentik.lib.models import InheritanceForeignKey, SerializerModel from authentik.lib.models import InheritanceForeignKey, SerializerModel
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
if TYPE_CHECKING: if TYPE_CHECKING:
from authentik.flows.planner import FlowPlan
from authentik.flows.stage import StageView from authentik.flows.stage import StageView
LOGGER = get_logger() LOGGER = get_logger()
@ -260,3 +264,30 @@ class ConfigurableStage(models.Model):
class Meta: class Meta:
abstract = True abstract = True
class FlowToken(Token):
"""Subclass of a standard Token, stores the currently active flow plan upon creation.
Can be used to later resume a flow."""
flow = models.ForeignKey(Flow, on_delete=models.CASCADE)
_plan = models.TextField()
@staticmethod
def pickle(plan) -> str:
"""Pickle into string"""
data = dumps(plan)
return b64encode(data).decode()
@property
def plan(self) -> "FlowPlan":
"""Load Flow plan from pickled version"""
return loads(b64decode(self._plan.encode())) # nosec
def __str__(self) -> str:
return f"Flow Token {super.__str__()}"
class Meta:
verbose_name = _("Flow Token")
verbose_name_plural = _("Flow Tokens")

View File

@ -24,6 +24,9 @@ PLAN_CONTEXT_SSO = "is_sso"
PLAN_CONTEXT_REDIRECT = "redirect" PLAN_CONTEXT_REDIRECT = "redirect"
PLAN_CONTEXT_APPLICATION = "application" PLAN_CONTEXT_APPLICATION = "application"
PLAN_CONTEXT_SOURCE = "source" PLAN_CONTEXT_SOURCE = "source"
# Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan
# was restored.
PLAN_CONTEXT_IS_RESTORED = "is_restored"
GAUGE_FLOWS_CACHED = UpdatingGauge( GAUGE_FLOWS_CACHED = UpdatingGauge(
"authentik_flows_cached", "authentik_flows_cached",
"Cached flows", "Cached flows",

View File

@ -34,8 +34,16 @@ from authentik.flows.challenge import (
WithUserInfoChallenge, WithUserInfoChallenge,
) )
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
from authentik.flows.models import ConfigurableStage, Flow, FlowDesignation, FlowStageBinding, Stage from authentik.flows.models import (
ConfigurableStage,
Flow,
FlowDesignation,
FlowStageBinding,
FlowToken,
Stage,
)
from authentik.flows.planner import ( from authentik.flows.planner import (
PLAN_CONTEXT_IS_RESTORED,
PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_PENDING_USER,
PLAN_CONTEXT_REDIRECT, PLAN_CONTEXT_REDIRECT,
FlowPlan, FlowPlan,
@ -55,6 +63,7 @@ SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre"
SESSION_KEY_GET = "authentik_flows_get" SESSION_KEY_GET = "authentik_flows_get"
SESSION_KEY_POST = "authentik_flows_post" SESSION_KEY_POST = "authentik_flows_post"
SESSION_KEY_HISTORY = "authentik_flows_history" SESSION_KEY_HISTORY = "authentik_flows_history"
QS_KEY_TOKEN = "flow_token" # nosec
def challenge_types(): def challenge_types():
@ -127,8 +136,31 @@ class FlowExecutorView(APIView):
message = exc.__doc__ if exc.__doc__ else str(exc) message = exc.__doc__ if exc.__doc__ else str(exc)
return self.stage_invalid(error_message=message) return self.stage_invalid(error_message=message)
def _check_flow_token(self, get_params: QueryDict):
"""Check if the user is using a flow token to restore a plan"""
tokens = FlowToken.filter_not_expired(key=get_params[QS_KEY_TOKEN])
if not tokens.exists():
return False
token: FlowToken = tokens.first()
try:
plan = token.plan
except (AttributeError, EOFError, ImportError, IndexError) as exc:
LOGGER.warning("f(exec): Failed to restore token plan", exc=exc)
finally:
token.delete()
if not isinstance(plan, FlowPlan):
return None
plan.context[PLAN_CONTEXT_IS_RESTORED] = True
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
return plan
# pylint: disable=unused-argument, too-many-return-statements # pylint: disable=unused-argument, too-many-return-statements
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
get_params = QueryDict(request.GET.get("query", ""))
if QS_KEY_TOKEN in get_params:
plan = self._check_flow_token(get_params)
if plan:
self.request.session[SESSION_KEY_PLAN] = plan
# Early check if there's an active Plan for the current session # Early check if there's an active Plan for the current session
if SESSION_KEY_PLAN in self.request.session: if SESSION_KEY_PLAN in self.request.session:
self.plan = self.request.session[SESSION_KEY_PLAN] self.plan = self.request.session[SESSION_KEY_PLAN]
@ -156,7 +188,7 @@ class FlowExecutorView(APIView):
# 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 # Initial flow request, check if we have an upstream query string passed in
request.session[SESSION_KEY_GET] = QueryDict(request.GET.get("query", "")) request.session[SESSION_KEY_GET] = get_params
# 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
try: try:

View File

@ -12,17 +12,16 @@ from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Token
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import SESSION_KEY_GET from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_GET
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger() LOGGER = get_logger()
QS_KEY_TOKEN = "etoken" # nosec
PLAN_CONTEXT_EMAIL_SENT = "email_sent" PLAN_CONTEXT_EMAIL_SENT = "email_sent"
@ -56,7 +55,7 @@ class EmailStageView(ChallengeStageView):
relative_url = f"{base_url}?{urlencode(kwargs)}" relative_url = f"{base_url}?{urlencode(kwargs)}"
return self.request.build_absolute_uri(relative_url) return self.request.build_absolute_uri(relative_url)
def get_token(self) -> Token: def get_token(self) -> FlowToken:
"""Get token""" """Get token"""
pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] pending_user = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
current_stage: EmailStage = self.executor.current_stage current_stage: EmailStage = self.executor.current_stage
@ -65,10 +64,14 @@ class EmailStageView(ChallengeStageView):
) # + 1 because django timesince always rounds down ) # + 1 because django timesince always rounds down
identifier = slugify(f"ak-email-stage-{current_stage.name}-{pending_user}") identifier = slugify(f"ak-email-stage-{current_stage.name}-{pending_user}")
# Don't check for validity here, we only care if the token exists # Don't check for validity here, we only care if the token exists
tokens = Token.objects.filter(identifier=identifier) tokens = FlowToken.objects.filter(identifier=identifier)
if not tokens.exists(): if not tokens.exists():
return Token.objects.create( return FlowToken.objects.create(
expires=now() + valid_delta, user=pending_user, identifier=identifier expires=now() + valid_delta,
user=pending_user,
identifier=identifier,
flow=self.executor.flow,
_plan=FlowToken.pickle(self.executor.plan),
) )
token = tokens.first() token = tokens.first()
# Check if token is expired and rotate key if so # Check if token is expired and rotate key if so
@ -97,13 +100,9 @@ class EmailStageView(ChallengeStageView):
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
# 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(
tokens = Token.filter_not_expired(key=request.session[SESSION_KEY_GET][QS_KEY_TOKEN]) SESSION_KEY_GET, {}
if not tokens.exists(): ) and self.executor.plan.context.get(PLAN_CONTEXT_IS_RESTORED, False):
return self.executor.stage_invalid(_("Invalid token"))
token = tokens.first()
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = token.user
token.delete()
messages.success(request, _("Successfully verified Email.")) messages.success(request, _("Successfully verified Email."))
if self.executor.current_stage.activate_user_on_success: if self.executor.current_stage.activate_user_on_success:
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER].is_active = True self.executor.plan.context[PLAN_CONTEXT_PENDING_USER].is_active = True