def test_many_polymorphic_serializer_extend_schema(no_warnings, explicit): if explicit: proxy_serializer = serializers.ListSerializer( child=PolymorphicProxySerializer(**PROXY_SERIALIZER_PARAMS)) else: proxy_serializer = PolymorphicProxySerializer(** PROXY_SERIALIZER_PARAMS, many=True) @extend_schema(request=proxy_serializer, responses=proxy_serializer) @api_view(['POST']) def view_func(request, format=None): pass # pragma: no cover schema = generate_schema('/x/', view_function=view_func) assert 'MetaPerson' in schema['components']['schemas'] op = schema['paths']['/x/']['post'] assert get_response_schema(op) == { 'type': 'array', 'items': { '$ref': '#/components/schemas/MetaPerson' } } assert get_request_schema(op) == { 'type': 'array', 'items': { '$ref': '#/components/schemas/MetaPerson' } }
def test_polymorphic_proxy_serializer_misusage(no_warnings): with pytest.raises(AssertionError): PolymorphicProxySerializer(**PROXY_SERIALIZER_PARAMS).data with pytest.raises(AssertionError): PolymorphicProxySerializer( **PROXY_SERIALIZER_PARAMS).to_representation(None) with pytest.raises(AssertionError): PolymorphicProxySerializer( **PROXY_SERIALIZER_PARAMS).to_internal_value(None)
class PersonViewSet(viewsets.GenericViewSet): @extend_schema( request=PolymorphicProxySerializer( component_name='MetaPerson', serializers=[LegalPersonSerializer, NaturalPersonSerializer], resource_type_field_name='type', ), responses=PolymorphicProxySerializer( component_name='MetaPerson', serializers=[LegalPersonSerializer, NaturalPersonSerializer], resource_type_field_name='type', )) def create(self, request, *args, **kwargs): return Response({})
def test_stripped_down_polymorphic_serializer(no_warnings): @extend_schema_field( PolymorphicProxySerializer( component_name='MetaPerson', serializers=[LegalPersonSerializer, NaturalPersonSerializer], resource_type_field_name=None, )) class XField(serializers.DictField): pass # pragma: no cover class XSerializer(serializers.Serializer): field = XField() @extend_schema(request=XSerializer, responses=XSerializer) @api_view(['GET']) def view_func(request, format=None): pass # pragma: no cover schema = generate_schema('x', view_function=view_func) assert schema['components']['schemas']['MetaPerson'] == { 'oneOf': [{ '$ref': '#/components/schemas/LegalPerson' }, { '$ref': '#/components/schemas/NaturalPerson' }] }
class XAPIView(APIView): @extend_schema(responses=PolymorphicProxySerializer( component_name='Broken', serializers=[IncompleteSerializer], resource_type_field_name='type', )) def get(self, request): pass # pragma: no cover
class XSerializer(serializers.Serializer): field = serializers.SerializerMethodField() @extend_schema_field( PolymorphicProxySerializer( component_name='MetaPerson', serializers=[LegalPersonSerializer, NaturalPersonSerializer], resource_type_field_name='type', )) def get_field(self, request): pass # pragma: no cover
def test_many_polymorphic_proxy_serializer_extend_schema_field( no_warnings, explicit): if explicit: proxy_serializer = serializers.ListField( child=PolymorphicProxySerializer(**PROXY_SERIALIZER_PARAMS)) else: proxy_serializer = PolymorphicProxySerializer(** PROXY_SERIALIZER_PARAMS, many=True) @extend_schema_field(proxy_serializer) class XField(serializers.DictField): pass # pragma: no cover class XSerializer(serializers.Serializer): field = XField() @extend_schema(request=XSerializer, responses=XSerializer) @api_view(['POST']) def view_func(request, format=None): pass # pragma: no cover schema = generate_schema('/x/', view_function=view_func) assert 'MetaPerson' in schema['components']['schemas'] assert schema['components']['schemas']['X'] == { 'type': 'object', 'properties': { 'field': { 'type': 'array', 'items': { '$ref': '#/components/schemas/MetaPerson' } } }, 'required': ['field'] } op = schema['paths']['/x/']['post'] assert get_request_schema(op) == {'$ref': '#/components/schemas/X'} assert get_response_schema(op) == {'$ref': '#/components/schemas/X'}
def test_polymorphic_serializer_as_field_via_extend_schema_field(no_warnings): @extend_schema_field( PolymorphicProxySerializer( component_name='MetaPerson', serializers=[LegalPersonSerializer, NaturalPersonSerializer], resource_type_field_name='type', )) class XField(serializers.DictField): pass # pragma: no cover class XSerializer(serializers.Serializer): field = XField() @extend_schema(request=XSerializer, responses=XSerializer) @api_view(['GET']) def view_func(request, format=None): pass # pragma: no cover schema = generate_schema('x', view_function=view_func) assert 'MetaPerson' in schema['components']['schemas'] assert 'MetaPerson' in schema['components']['schemas']['X']['properties'][ 'field']['$ref']
class FlowExecutorView(APIView): """Stage 1 Flow executor, passing requests to Stage Views""" permission_classes = [AllowAny] flow: Flow plan: Optional[FlowPlan] = None current_binding: FlowStageBinding current_stage: Stage current_stage_view: View _logger: BoundLogger def setup(self, request: HttpRequest, flow_slug: str): super().setup(request, flow_slug=flow_slug) self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) self._logger = get_logger().bind(flow_slug=flow_slug) set_tag("authentik.flow", self.flow.slug) def handle_invalid_flow(self, exc: BaseException) -> HttpResponse: """When a flow is non-applicable check if user is on the correct domain""" if NEXT_ARG_NAME in self.request.GET: if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)): self._logger.debug("f(exec): Redirecting to next on fail") return redirect(self.request.GET.get(NEXT_ARG_NAME)) message = exc.__doc__ if exc.__doc__ else str(exc) 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 def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: with Hub.current.start_span(op="authentik.flow.executor.dispatch", description=self.flow.slug) as span: span.set_data("authentik Flow", self.flow.slug) 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 if SESSION_KEY_PLAN in self.request.session: self.plan = self.request.session[SESSION_KEY_PLAN] if self.plan.flow_pk != self.flow.pk.hex: self._logger.warning( "f(exec): Found existing plan for other flow, deleting plan", ) # Existing plan is deleted from session and instance self.plan = None self.cancel() self._logger.debug("f(exec): Continuing existing plan") # Don't check session again as we've either already loaded the plan or we need to plan if not self.plan: request.session[SESSION_KEY_HISTORY] = [] self._logger.debug( "f(exec): No active Plan found, initiating planner") try: self.plan = self._initiate_plan() except FlowNonApplicableException as exc: self._logger.warning( "f(exec): Flow not applicable to current user", exc=exc) return to_stage_response(self.request, self.handle_invalid_flow(exc)) except EmptyFlowException as exc: self._logger.warning("f(exec): Flow is empty", exc=exc) # To match behaviour with loading an empty flow plan from cache, # we don't show an error message here, but rather call _flow_done() return self._flow_done() # Initial flow request, check if we have an upstream query string passed in request.session[SESSION_KEY_GET] = get_params # We don't save the Plan after getting the next stage # as it hasn't been successfully passed yet try: # This is the first time we actually access any attribute on the selected plan # if the cached plan is from an older version, it might have different attributes # in which case we just delete the plan and invalidate everything next_binding = self.plan.next(self.request) except Exception as exc: # pylint: disable=broad-except self._logger.warning( "f(exec): found incompatible flow plan, invalidating run", exc=exc) keys = cache.keys("flow_*") cache.delete_many(keys) return self.stage_invalid() if not next_binding: self._logger.debug("f(exec): no more stages, flow is done.") return self._flow_done() self.current_binding = next_binding self.current_stage = next_binding.stage self._logger.debug( "f(exec): Current stage", current_stage=self.current_stage, flow_slug=self.flow.slug, ) try: stage_cls = self.current_stage.type except NotImplementedError as exc: self._logger.debug("Error getting stage type", exc=exc) return self.stage_invalid() self.current_stage_view = stage_cls(self) self.current_stage_view.args = self.args self.current_stage_view.kwargs = self.kwargs self.current_stage_view.request = request try: return super().dispatch(request) except InvalidStageError as exc: return self.stage_invalid(str(exc)) def handle_exception(self, exc: Exception) -> HttpResponse: """Handle exception in stage execution""" if settings.DEBUG or settings.TEST: raise exc capture_exception(exc) self._logger.warning(exc) Event.new( action=EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exc), ).from_http(self.request) return to_stage_response(self.request, FlowErrorResponse(self.request, exc)) @extend_schema( responses={ 200: PolymorphicProxySerializer( component_name="ChallengeTypes", serializers=challenge_types(), resource_type_field_name="component", ), }, request=OpenApiTypes.NONE, parameters=[ OpenApiParameter( name="query", location=OpenApiParameter.QUERY, required=True, description="Querystring as received", type=OpenApiTypes.STR, ) ], operation_id="flows_executor_get", ) def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Get the next pending challenge from the currently active flow.""" self._logger.debug( "f(exec): Passing GET", view_class=class_to_path(self.current_stage_view.__class__), stage=self.current_stage, ) try: with Hub.current.start_span( op="authentik.flow.executor.stage", description=class_to_path( self.current_stage_view.__class__), ) as span: span.set_data("Method", "GET") span.set_data("authentik Stage", self.current_stage_view) span.set_data("authentik Flow", self.flow.slug) stage_response = self.current_stage_view.get( request, *args, **kwargs) return to_stage_response(request, stage_response) except Exception as exc: # pylint: disable=broad-except return self.handle_exception(exc) @extend_schema( responses={ 200: PolymorphicProxySerializer( component_name="ChallengeTypes", serializers=challenge_types(), resource_type_field_name="component", ), }, request=PolymorphicProxySerializer( component_name="FlowChallengeResponse", serializers=challenge_response_types(), resource_type_field_name="component", ), parameters=[ OpenApiParameter( name="query", location=OpenApiParameter.QUERY, required=True, description="Querystring as received", type=OpenApiTypes.STR, ) ], operation_id="flows_executor_solve", ) def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Solve the previously retrieved challenge and advanced to the next stage.""" self._logger.debug( "f(exec): Passing POST", view_class=class_to_path(self.current_stage_view.__class__), stage=self.current_stage, ) try: with Hub.current.start_span( op="authentik.flow.executor.stage", description=class_to_path( self.current_stage_view.__class__), ) as span: span.set_data("Method", "POST") span.set_data("authentik Stage", self.current_stage_view) span.set_data("authentik Flow", self.flow.slug) stage_response = self.current_stage_view.post( request, *args, **kwargs) return to_stage_response(request, stage_response) except Exception as exc: # pylint: disable=broad-except return self.handle_exception(exc) def _initiate_plan(self) -> FlowPlan: planner = FlowPlanner(self.flow) plan = planner.plan(self.request) self.request.session[SESSION_KEY_PLAN] = plan try: # Call the has_stages getter to check that # there are no issues with the class we might've gotten # from the cache. If there are errors, just delete all cached flows _ = plan.has_stages except Exception: # pylint: disable=broad-except keys = cache.keys("flow_*") cache.delete_many(keys) return self._initiate_plan() return plan def restart_flow(self, keep_context=False) -> HttpResponse: """Restart the currently active flow, optionally keeping the current context""" planner = FlowPlanner(self.flow) default_context = None if keep_context: default_context = self.plan.context plan = planner.plan(self.request, default_context) self.request.session[SESSION_KEY_PLAN] = plan kwargs = self.kwargs kwargs.update({"flow_slug": self.flow.slug}) return redirect_with_qs("authentik_api:flow-executor", self.request.GET, **kwargs) def _flow_done(self) -> HttpResponse: """User Successfully passed all stages""" # Since this is wrapped by the ExecutorShell, the next argument is saved in the session # extract the next param before cancel as that cleans it if self.plan and PLAN_CONTEXT_REDIRECT in self.plan.context: # The context `redirect` variable can only be set by # an expression policy or authentik itself, so we don't # check if its an absolute URL or a relative one self.cancel() return redirect(self.plan.context.get(PLAN_CONTEXT_REDIRECT)) next_param = self.request.session.get(SESSION_KEY_GET, {}).get( NEXT_ARG_NAME, "authentik_core:root-redirect") self.cancel() return to_stage_response(self.request, redirect_with_qs(next_param)) def stage_ok(self) -> HttpResponse: """Callback called by stages upon successful completion. Persists updated plan and context to session.""" self._logger.debug( "f(exec): Stage ok", stage_class=class_to_path(self.current_stage_view.__class__), ) self.request.session.get(SESSION_KEY_HISTORY, []).append(deepcopy(self.plan)) self.plan.pop() self.request.session[SESSION_KEY_PLAN] = self.plan if self.plan.bindings: self._logger.debug( "f(exec): Continuing with next stage", remaining=len(self.plan.bindings), ) kwargs = self.kwargs kwargs.update({"flow_slug": self.flow.slug}) return redirect_with_qs("authentik_api:flow-executor", self.request.GET, **kwargs) # User passed all stages self._logger.debug( "f(exec): User passed all stages", context=cleanse_dict(self.plan.context), ) return self._flow_done() def stage_invalid(self, error_message: Optional[str] = None) -> HttpResponse: """Callback used stage when data is correct but a policy denies access or the user account is disabled. Optionally, an exception can be passed, which will be shown if the current user is a superuser.""" self._logger.debug("f(exec): Stage invalid") self.cancel() challenge_view = AccessDeniedChallengeView(self, error_message) challenge_view.request = self.request return to_stage_response(self.request, challenge_view.get(self.request)) def cancel(self): """Cancel current execution and return a redirect""" keys_to_delete = [ SESSION_KEY_APPLICATION_PRE, SESSION_KEY_PLAN, SESSION_KEY_GET, # We don't delete the history on purpose, as a user might # still be inspecting it. # It's only deleted on a fresh executions # SESSION_KEY_HISTORY, ] for key in keys_to_delete: if key in self.request.session: del self.request.session[key]
class NaturalPersonSerializer(serializers.ModelSerializer): type = serializers.SerializerMethodField() class Meta: model = NaturalPerson2 fields = ('id', 'first_name', 'last_name', 'type') def get_type(self, obj) -> str: return 'natural' with mock.patch('rest_framework.settings.api_settings.DEFAULT_SCHEMA_CLASS', AutoSchema): implicit_poly_proxy = PolymorphicProxySerializer( component_name='MetaPerson', serializers=[LegalPersonSerializer, NaturalPersonSerializer], resource_type_field_name='type', ) class ImplicitPersonViewSet(viewsets.GenericViewSet): @extend_schema(request=implicit_poly_proxy, responses=implicit_poly_proxy) def create(self, request, *args, **kwargs): return Response({}) # pragma: no cover @extend_schema( request=implicit_poly_proxy, responses=implicit_poly_proxy, parameters=[OpenApiParameter('id', int, OpenApiParameter.PATH)], ) def partial_update(self, request, *args, **kwargs):
from authentik.sources.oauth.types.apple import AppleLoginChallenge from authentik.sources.plex.models import PlexAuthenticationChallenge from authentik.stages.identification.models import IdentificationStage from authentik.stages.identification.signals import identification_failed from authentik.stages.password.stage import authenticate LOGGER = get_logger() @extend_schema_field( PolymorphicProxySerializer( component_name="LoginChallengeTypes", serializers={ RedirectChallenge().fields["component"].default: RedirectChallenge, PlexAuthenticationChallenge().fields["component"].default: PlexAuthenticationChallenge, AppleLoginChallenge().fields["component"].default: AppleLoginChallenge, }, resource_type_field_name="component", )) class ChallengeDictWrapper(DictField): """Wrapper around DictField that annotates itself as challenge proxy""" class LoginSourceSerializer(PassiveSerializer): """Serializer for Login buttons of sources""" name = CharField() icon_url = CharField(required=False, allow_null=True)
class FlowExecutorView(APIView): """Stage 1 Flow executor, passing requests to Stage Views""" permission_classes = [AllowAny] flow: Flow plan: Optional[FlowPlan] = None current_stage: Stage current_stage_view: View _logger: BoundLogger def setup(self, request: HttpRequest, flow_slug: str): super().setup(request, flow_slug=flow_slug) self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug) self._logger = get_logger().bind(flow_slug=flow_slug) def handle_invalid_flow(self, exc: BaseException) -> HttpResponse: """When a flow is non-applicable check if user is on the correct domain""" if NEXT_ARG_NAME in self.request.GET: if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)): self._logger.debug("f(exec): Redirecting to next on fail") return redirect(self.request.GET.get(NEXT_ARG_NAME)) message = exc.__doc__ if exc.__doc__ else str(exc) return self.stage_invalid(error_message=message) # pylint: disable=unused-argument def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse: # Early check if theres an active Plan for the current session if SESSION_KEY_PLAN in self.request.session: self.plan = self.request.session[SESSION_KEY_PLAN] if self.plan.flow_pk != self.flow.pk.hex: self._logger.warning( "f(exec): Found existing plan for other flow, deleteing plan", ) # Existing plan is deleted from session and instance self.plan = None self.cancel() self._logger.debug("f(exec): Continuing existing plan") # Don't check session again as we've either already loaded the plan or we need to plan if not self.plan: self._logger.debug( "f(exec): No active Plan found, initiating planner") try: self.plan = self._initiate_plan() except FlowNonApplicableException as exc: self._logger.warning( "f(exec): Flow not applicable to current user", exc=exc) return to_stage_response(self.request, self.handle_invalid_flow(exc)) except EmptyFlowException as exc: self._logger.warning("f(exec): Flow is empty", exc=exc) # To match behaviour with loading an empty flow plan from cache, # we don't show an error message here, but rather call _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 # as it hasn't been successfully passed yet next_stage = self.plan.next(self.request) if not next_stage: self._logger.debug("f(exec): no more stages, flow is done.") return self._flow_done() self.current_stage = next_stage self._logger.debug( "f(exec): Current stage", current_stage=self.current_stage, flow_slug=self.flow.slug, ) stage_cls = self.current_stage.type self.current_stage_view = stage_cls(self) self.current_stage_view.args = self.args self.current_stage_view.kwargs = self.kwargs self.current_stage_view.request = request return super().dispatch(request) @extend_schema( responses={ 200: PolymorphicProxySerializer( component_name="FlowChallengeRequest", serializers=challenge_types(), resource_type_field_name="component", ), 404: OpenApiResponse(description="No Token found" ), # This error can be raised by the email stage }, request=OpenApiTypes.NONE, parameters=[ OpenApiParameter( name="query", location=OpenApiParameter.QUERY, required=True, description="Querystring as received", type=OpenApiTypes.STR, ) ], operation_id="flows_executor_get", ) def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Get the next pending challenge from the currently active flow.""" self._logger.debug( "f(exec): Passing GET", view_class=class_to_path(self.current_stage_view.__class__), stage=self.current_stage, ) try: stage_response = self.current_stage_view.get( request, *args, **kwargs) return to_stage_response(request, stage_response) except Exception as exc: # pylint: disable=broad-except capture_exception(exc) self._logger.warning(exc) return to_stage_response(request, FlowErrorResponse(request, exc)) @extend_schema( responses={ 200: PolymorphicProxySerializer( component_name="FlowChallengeRequest", serializers=challenge_types(), resource_type_field_name="component", ), }, request=PolymorphicProxySerializer( component_name="FlowChallengeResponse", serializers=challenge_response_types(), resource_type_field_name="component", ), parameters=[ OpenApiParameter( name="query", location=OpenApiParameter.QUERY, required=True, description="Querystring as received", type=OpenApiTypes.STR, ) ], operation_id="flows_executor_solve", ) def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: """Solve the previously retrieved challenge and advanced to the next stage.""" self._logger.debug( "f(exec): Passing POST", view_class=class_to_path(self.current_stage_view.__class__), stage=self.current_stage, ) try: stage_response = self.current_stage_view.post( request, *args, **kwargs) return to_stage_response(request, stage_response) except Exception as exc: # pylint: disable=broad-except capture_exception(exc) self._logger.warning(exc) return to_stage_response(request, FlowErrorResponse(request, exc)) def _initiate_plan(self) -> FlowPlan: planner = FlowPlanner(self.flow) plan = planner.plan(self.request) self.request.session[SESSION_KEY_PLAN] = plan return plan def _flow_done(self) -> HttpResponse: """User Successfully passed all stages""" # Since this is wrapped by the ExecutorShell, the next argument is saved in the session # extract the next param before cancel as that cleans it next_param = None if self.plan: next_param = self.plan.context.get(PLAN_CONTEXT_REDIRECT) if not next_param: next_param = self.request.session.get(SESSION_KEY_GET, {}).get( NEXT_ARG_NAME, "authentik_core:root-redirect") self.cancel() return to_stage_response(self.request, redirect_with_qs(next_param)) def stage_ok(self) -> HttpResponse: """Callback called by stages upon successful completion. Persists updated plan and context to session.""" self._logger.debug( "f(exec): Stage ok", stage_class=class_to_path(self.current_stage_view.__class__), ) self.plan.pop() self.request.session[SESSION_KEY_PLAN] = self.plan if self.plan.stages: self._logger.debug( "f(exec): Continuing with next stage", remaining=len(self.plan.stages), ) kwargs = self.kwargs kwargs.update({"flow_slug": self.flow.slug}) return redirect_with_qs("authentik_api:flow-executor", self.request.GET, **kwargs) # User passed all stages self._logger.debug( "f(exec): User passed all stages", context=cleanse_dict(self.plan.context), ) return self._flow_done() def stage_invalid(self, error_message: Optional[str] = None) -> HttpResponse: """Callback used stage when data is correct but a policy denies access or the user account is disabled. Optionally, an exception can be passed, which will be shown if the current user is a superuser.""" self._logger.debug("f(exec): Stage invalid") self.cancel() response = HttpChallengeResponse( AccessDeniedChallenge({ "error_message": error_message, "title": self.flow.title, "type": ChallengeTypes.NATIVE.value, "component": "ak-stage-access-denied", })) return to_stage_response(self.request, response) def cancel(self): """Cancel current execution and return a redirect""" keys_to_delete = [ SESSION_KEY_APPLICATION_PRE, SESSION_KEY_PLAN, SESSION_KEY_GET, ] for key in keys_to_delete: if key in self.request.session: del self.request.session[key]