From 7e85524e51cc5cb80ffff167d03378979effc238 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Mon, 29 Mar 2021 15:36:35 +0200 Subject: [PATCH] *: simplify API permissions checking, add API for user recovery Signed-off-by: Jens Langhammer --- authentik/api/decorators.py | 28 ++++++++++++++ authentik/core/api/applications.py | 15 +++----- authentik/core/api/users.py | 38 ++++++++++++++++++- authentik/events/api/event.py | 4 +- .../events/api/notification_transport.py | 9 ++--- authentik/flows/api/flows.py | 19 ++++------ .../migrations/0017_auto_20210329_1334.py | 25 ++++++++++++ authentik/flows/models.py | 2 + authentik/policies/api.py | 3 ++ .../migrations/0006_auto_20210329_1334.py | 25 ++++++++++++ authentik/policies/models.py | 5 +++ swagger.yaml | 28 ++++++++++++++ 12 files changed, 173 insertions(+), 28 deletions(-) create mode 100644 authentik/api/decorators.py create mode 100644 authentik/flows/migrations/0017_auto_20210329_1334.py create mode 100644 authentik/policies/migrations/0006_auto_20210329_1334.py diff --git a/authentik/api/decorators.py b/authentik/api/decorators.py new file mode 100644 index 000000000..fd89c01dc --- /dev/null +++ b/authentik/api/decorators.py @@ -0,0 +1,28 @@ +"""API Decorators""" +from functools import wraps +from typing import Callable + +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.viewsets import ModelViewSet + + +def permission_required(perm: str, *other_perms: str): + """Check permissions for a single custom action""" + + def wrapper_outter(func: Callable): + """Check permissions for a single custom action""" + + @wraps(func) + def wrapper(self: ModelViewSet, request: Request, *args, **kwargs) -> Response: + obj = self.get_object() + if not request.user.has_perm(perm, obj): + return self.permission_denied(request) + for other_perm in other_perms: + if not request.user.has_perm(other_perm): + return self.permission_denied(request) + return func(self, request, *args, **kwargs) + + return wrapper + + return wrapper_outter diff --git a/authentik/core/api/applications.py b/authentik/core/api/applications.py index eae9fc986..66076fe6c 100644 --- a/authentik/core/api/applications.py +++ b/authentik/core/api/applications.py @@ -1,12 +1,9 @@ """Application API Views""" from django.core.cache import cache from django.db.models import QuerySet -from django.http.response import Http404 from drf_yasg2.utils import swagger_auto_schema -from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.fields import SerializerMethodField -from rest_framework.generics import get_object_or_404 from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ModelSerializer @@ -15,6 +12,7 @@ from rest_framework_guardian.filters import ObjectPermissionsFilter from structlog.stdlib import get_logger from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h +from authentik.api.decorators import permission_required from authentik.core.api.providers import ProviderSerializer from authentik.core.models import Application from authentik.events.models import EventAction @@ -110,16 +108,15 @@ class ApplicationViewSet(ModelViewSet): serializer = self.get_serializer(allowed_applications, many=True) return self.get_paginated_response(serializer.data) + @permission_required( + "authentik_core.view_application", "authentik_events.view_event" + ) @swagger_auto_schema(responses={200: CoordinateSerializer(many=True)}) @action(detail=True) + # pylint: disable=unused-argument def metrics(self, request: Request, slug: str): """Metrics for application logins""" - app = get_object_or_404( - get_objects_for_user(request.user, "authentik_core.view_application"), - slug=slug, - ) - if not request.user.has_perm("authentik_events.view_event"): - raise Http404 + app = self.get_object() return Response( get_events_per_1h( action=EventAction.AUTHORIZE_APPLICATION, diff --git a/authentik/core/api/users.py b/authentik/core/api/users.py index c8c04082c..2c9e0adcd 100644 --- a/authentik/core/api/users.py +++ b/authentik/core/api/users.py @@ -1,5 +1,7 @@ """User API Views""" from django.db.models.base import Model +from django.urls import reverse_lazy +from django.utils.http import urlencode from drf_yasg2.utils import swagger_auto_schema, swagger_serializer_method from guardian.utils import get_anonymous_user from rest_framework.decorators import action @@ -10,11 +12,12 @@ from rest_framework.serializers import BooleanField, ModelSerializer, Serializer from rest_framework.viewsets import ModelViewSet from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h +from authentik.api.decorators import permission_required from authentik.core.middleware import ( SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER, ) -from authentik.core.models import User +from authentik.core.models import Token, TokenIntents, User from authentik.events.models import EventAction @@ -54,6 +57,18 @@ class SessionUserSerializer(Serializer): raise NotImplementedError +class UserRecoverySerializer(Serializer): + """Recovery link for a user to reset their password""" + + link = CharField() + + def create(self, validated_data: dict) -> Model: + raise NotImplementedError + + def update(self, instance: Model, validated_data: dict) -> Model: + raise NotImplementedError + + class UserMetricsSerializer(Serializer): """User Metrics""" @@ -116,6 +131,7 @@ class UserViewSet(ModelViewSet): serializer.is_valid() return Response(serializer.data) + @permission_required("authentik_core.view_user", "authentik_events.view_event") @swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)}) @action(detail=False) def metrics(self, request: Request) -> Response: @@ -123,3 +139,23 @@ class UserViewSet(ModelViewSet): serializer = UserMetricsSerializer(True) serializer.context["request"] = request return Response(serializer.data) + + @permission_required("authentik_core.reset_user_password") + @swagger_auto_schema( + responses={"200": UserRecoverySerializer(many=False)}, + ) + @action(detail=True) + # pylint: disable=invalid-name, unused-argument + def recovery(self, request: Request, pk: int) -> Response: + """Create a temporary link that a user can use to recover their accounts""" + user: User = self.get_object() + token, __ = Token.objects.get_or_create( + identifier=f"{user.uid}-password-reset", + user=user, + intent=TokenIntents.INTENT_RECOVERY, + ) + querystring = urlencode({"token": token.key}) + link = request.build_absolute_uri( + reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}" + ) + return Response({"link": link}) diff --git a/authentik/events/api/event.py b/authentik/events/api/event.py index 75cfbeae9..3eb7230bc 100644 --- a/authentik/events/api/event.py +++ b/authentik/events/api/event.py @@ -3,6 +3,7 @@ import django_filters from django.db.models.aggregates import Count from django.db.models.fields.json import KeyTextTransform from drf_yasg2.utils import swagger_auto_schema +from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.fields import CharField, DictField, IntegerField from rest_framework.request import Request @@ -132,7 +133,8 @@ class EventViewSet(ReadOnlyModelViewSet): filtered_action = request.query_params.get("action", EventAction.LOGIN) top_n = request.query_params.get("top_n", 15) return Response( - Event.objects.filter(action=filtered_action) + get_objects_for_user(request.user, "authentik_events.view_event") + .filter(action=filtered_action) .exclude(context__authorized_application=None) .annotate(application=KeyTextTransform("authorized_application", "context")) .annotate(user_pk=KeyTextTransform("pk", "user")) diff --git a/authentik/events/api/notification_transport.py b/authentik/events/api/notification_transport.py index e951f2a9f..b36b2dd71 100644 --- a/authentik/events/api/notification_transport.py +++ b/authentik/events/api/notification_transport.py @@ -1,7 +1,6 @@ """NotificationTransport API Views""" from django.http.response import Http404 from drf_yasg2.utils import no_body, swagger_auto_schema -from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action from rest_framework.fields import CharField, ListField, SerializerMethodField from rest_framework.request import Request @@ -9,6 +8,7 @@ from rest_framework.response import Response from rest_framework.serializers import ModelSerializer, Serializer from rest_framework.viewsets import ModelViewSet +from authentik.api.decorators import permission_required from authentik.events.models import ( Notification, NotificationSeverity, @@ -57,18 +57,17 @@ class NotificationTransportViewSet(ModelViewSet): queryset = NotificationTransport.objects.all() serializer_class = NotificationTransportSerializer + @permission_required("authentik_events.change_notificationtransport") @swagger_auto_schema( responses={200: NotificationTransportTestSerializer(many=False)}, request_body=no_body, ) @action(detail=True, methods=["post"]) - # pylint: disable=invalid-name + # pylint: disable=invalid-name, unused-argument def test(self, request: Request, pk=None) -> Response: """Send example notification using selected transport. Requires Modify permissions.""" - transports = get_objects_for_user( - request.user, "authentik_events.change_notificationtransport" - ).filter(pk=pk) + transports = self.get_object() if not transports.exists(): raise Http404 transport: NotificationTransport = transports.first() diff --git a/authentik/flows/api/flows.py b/authentik/flows/api/flows.py index a87aff0b3..c2a886e4c 100644 --- a/authentik/flows/api/flows.py +++ b/authentik/flows/api/flows.py @@ -3,13 +3,11 @@ from dataclasses import dataclass from django.core.cache import cache from django.db.models import Model -from django.http.response import HttpResponseBadRequest, JsonResponse -from django.shortcuts import get_object_or_404 +from django.http.response import JsonResponse from drf_yasg2 import openapi from drf_yasg2.utils import no_body, swagger_auto_schema from guardian.shortcuts import get_objects_for_user from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request from rest_framework.response import Response from rest_framework.serializers import ( @@ -21,6 +19,7 @@ from rest_framework.serializers import ( from rest_framework.viewsets import ModelViewSet from structlog.stdlib import get_logger +from authentik.api.decorators import permission_required from authentik.core.api.utils import CacheSerializer from authentik.flows.models import Flow from authentik.flows.planner import cache_key @@ -89,12 +88,14 @@ class FlowViewSet(ModelViewSet): search_fields = ["name", "slug", "designation", "title"] filterset_fields = ["flow_uuid", "name", "slug", "designation"] + @permission_required("authentik_flows.view_flow_cache") @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) @action(detail=False) def cache_info(self, request: Request) -> Response: """Info about cached flows""" return Response(data={"count": len(cache.keys("flow_*"))}) + @permission_required("authentik_flows.clear_flow_cache") @swagger_auto_schema( request_body=no_body, responses={204: "Successfully cleared cache", 400: "Bad request"}, @@ -102,13 +103,12 @@ class FlowViewSet(ModelViewSet): @action(detail=False, methods=["POST"]) def cache_clear(self, request: Request) -> Response: """Clear flow cache""" - if not request.user.is_superuser: - return HttpResponseBadRequest() keys = cache.keys("flow_*") cache.delete_many(keys) LOGGER.debug("Cleared flow cache", keys=len(keys)) return Response(status=204) + @permission_required("authentik_flows.export_flow") @swagger_auto_schema( responses={ "200": openapi.Response( @@ -121,8 +121,6 @@ class FlowViewSet(ModelViewSet): def export(self, request: Request, slug: str) -> Response: """Export flow to .akflow file""" flow = self.get_object() - if not request.user.has_perm("authentik_flows.export_flow", flow): - raise PermissionDenied() exporter = FlowExporter(flow) response = JsonResponse(exporter.export(), encoder=DataclassEncoder, safe=False) response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"' @@ -130,13 +128,10 @@ class FlowViewSet(ModelViewSet): @swagger_auto_schema(responses={200: FlowDiagramSerializer()}) @action(detail=True, methods=["get"]) + # pylint: disable=unused-argument def diagram(self, request: Request, slug: str) -> Response: """Return diagram for flow with slug `slug`, in the format used by flowchart.js""" - flow = get_object_or_404( - get_objects_for_user(request.user, "authentik_flows.view_flow").filter( - slug=slug - ) - ) + flow = self.get_object() header = [ DiagramElement("st", "start", "Start"), ] diff --git a/authentik/flows/migrations/0017_auto_20210329_1334.py b/authentik/flows/migrations/0017_auto_20210329_1334.py new file mode 100644 index 000000000..bcaf18eef --- /dev/null +++ b/authentik/flows/migrations/0017_auto_20210329_1334.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2021-03-29 13:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0016_auto_20201202_1307"), + ] + + operations = [ + migrations.AlterModelOptions( + name="flow", + options={ + "permissions": [ + ("export_flow", "Can export a Flow"), + ("view_flow_cache", "View Flow's cache metrics"), + ("clear_flow_cache", "Clear Flow's cache metrics"), + ], + "verbose_name": "Flow", + "verbose_name_plural": "Flows", + }, + ), + ] diff --git a/authentik/flows/models.py b/authentik/flows/models.py index 136882acd..3db3db439 100644 --- a/authentik/flows/models.py +++ b/authentik/flows/models.py @@ -158,6 +158,8 @@ class Flow(SerializerModel, PolicyBindingModel): permissions = [ ("export_flow", "Can export a Flow"), + ("view_flow_cache", "View Flow's cache metrics"), + ("clear_flow_cache", "Clear Flow's cache metrics"), ] diff --git a/authentik/policies/api.py b/authentik/policies/api.py index 42ce8bc9f..c59cacfed 100644 --- a/authentik/policies/api.py +++ b/authentik/policies/api.py @@ -16,6 +16,7 @@ from rest_framework.serializers import ( from rest_framework.viewsets import GenericViewSet, ModelViewSet from structlog.stdlib import get_logger +from authentik.api.decorators import permission_required from authentik.core.api.applications import user_app_cache_key from authentik.core.api.utils import ( CacheSerializer, @@ -142,12 +143,14 @@ class PolicyViewSet( ) return Response(TypeCreateSerializer(data, many=True).data) + @permission_required("authentik_policies.view_policy_cache") @swagger_auto_schema(responses={200: CacheSerializer(many=False)}) @action(detail=False) def cache_info(self, request: Request) -> Response: """Info about cached policies""" return Response(data={"count": len(cache.keys("policy_*"))}) + @permission_required("authentik_policies.clear_policy_cache") @swagger_auto_schema( request_body=no_body, responses={204: "Successfully cleared cache", 400: "Bad request"}, diff --git a/authentik/policies/migrations/0006_auto_20210329_1334.py b/authentik/policies/migrations/0006_auto_20210329_1334.py new file mode 100644 index 000000000..e35b550a0 --- /dev/null +++ b/authentik/policies/migrations/0006_auto_20210329_1334.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.7 on 2021-03-29 13:34 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_policies", "0005_binding_group"), + ] + + operations = [ + migrations.AlterModelOptions( + name="policy", + options={ + "base_manager_name": "objects", + "permissions": [ + ("view_policy_cache", "View Policy's cache metrics"), + ("clear_policy_cache", "Clear Policy's cache metrics"), + ], + "verbose_name": "Policy", + "verbose_name_plural": "Policies", + }, + ), + ] diff --git a/authentik/policies/models.py b/authentik/policies/models.py index 9ad95422a..7e34131c3 100644 --- a/authentik/policies/models.py +++ b/authentik/policies/models.py @@ -149,3 +149,8 @@ class Policy(SerializerModel, CreatedUpdatedModel): verbose_name = _("Policy") verbose_name_plural = _("Policies") + + permissions = [ + ("view_policy_cache", "View Policy's cache metrics"), + ("clear_policy_cache", "Clear Policy's cache metrics"), + ] diff --git a/swagger.yaml b/swagger.yaml index da9fcb68f..27d16815e 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -1726,6 +1726,24 @@ paths: description: A unique integer value identifying this User. required: true type: integer + /core/users/{id}/recovery/: + get: + operationId: core_users_recovery + description: Create a temporary link that a user can use to recover their accounts + parameters: [] + responses: + '200': + description: Recovery link for a user to reset their password + schema: + $ref: '#/definitions/UserRecovery' + tags: + - core + parameters: + - name: id + in: path + description: A unique integer value identifying this User. + required: true + type: integer /crypto/certificatekeypairs/: get: operationId: crypto_certificatekeypairs_list @@ -11120,6 +11138,16 @@ definitions: items: $ref: '#/definitions/Coordinate' readOnly: true + UserRecovery: + description: Recovery link for a user to reset their password + required: + - link + type: object + properties: + link: + title: Link + type: string + minLength: 1 CertificateKeyPair: description: CertificateKeyPair Serializer required: