*: simplify API permissions checking, add API for user recovery

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-03-29 15:36:35 +02:00
parent 4fa122b827
commit 0793fff222
12 changed files with 173 additions and 28 deletions

View 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

View file

@ -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,

View file

@ -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})

View file

@ -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"))

View file

@ -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()

View file

@ -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"),
]

View 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",
},
),
]

View file

@ -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"),
]

View file

@ -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"},

View 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",
},
),
]

View file

@ -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"),
]

View file

@ -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: