*: simplify API permissions checking, add API for user recovery
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
		
							
								
								
									
										28
									
								
								authentik/api/decorators.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								authentik/api/decorators.py
									
									
									
									
									
										Normal file
									
								
							| @ -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 | ||||
| @ -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, | ||||
|  | ||||
| @ -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}) | ||||
|  | ||||
| @ -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")) | ||||
|  | ||||
| @ -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() | ||||
|  | ||||
| @ -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"), | ||||
|         ] | ||||
|  | ||||
							
								
								
									
										25
									
								
								authentik/flows/migrations/0017_auto_20210329_1334.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								authentik/flows/migrations/0017_auto_20210329_1334.py
									
									
									
									
									
										Normal file
									
								
							| @ -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", | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -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"), | ||||
|         ] | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -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"}, | ||||
|  | ||||
							
								
								
									
										25
									
								
								authentik/policies/migrations/0006_auto_20210329_1334.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								authentik/policies/migrations/0006_auto_20210329_1334.py
									
									
									
									
									
										Normal file
									
								
							| @ -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", | ||||
|             }, | ||||
|         ), | ||||
|     ] | ||||
| @ -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"), | ||||
|         ] | ||||
|  | ||||
							
								
								
									
										28
									
								
								swagger.yaml
									
									
									
									
									
								
							
							
						
						
									
										28
									
								
								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: | ||||
|  | ||||
		Reference in New Issue
	
	Block a user
	 Jens Langhammer
					Jens Langhammer