*: 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""" """Application API Views"""
from django.core.cache import cache from django.core.cache import cache
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http.response import Http404
from drf_yasg2.utils import swagger_auto_schema 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.decorators import action
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
from rest_framework.generics import get_object_or_404
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
@ -15,6 +12,7 @@ from rest_framework_guardian.filters import ObjectPermissionsFilter
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h 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.api.providers import ProviderSerializer
from authentik.core.models import Application from authentik.core.models import Application
from authentik.events.models import EventAction from authentik.events.models import EventAction
@ -110,16 +108,15 @@ class ApplicationViewSet(ModelViewSet):
serializer = self.get_serializer(allowed_applications, many=True) serializer = self.get_serializer(allowed_applications, many=True)
return self.get_paginated_response(serializer.data) 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)}) @swagger_auto_schema(responses={200: CoordinateSerializer(many=True)})
@action(detail=True) @action(detail=True)
# pylint: disable=unused-argument
def metrics(self, request: Request, slug: str): def metrics(self, request: Request, slug: str):
"""Metrics for application logins""" """Metrics for application logins"""
app = get_object_or_404( app = self.get_object()
get_objects_for_user(request.user, "authentik_core.view_application"),
slug=slug,
)
if not request.user.has_perm("authentik_events.view_event"):
raise Http404
return Response( return Response(
get_events_per_1h( get_events_per_1h(
action=EventAction.AUTHORIZE_APPLICATION, action=EventAction.AUTHORIZE_APPLICATION,

View File

@ -1,5 +1,7 @@
"""User API Views""" """User API Views"""
from django.db.models.base import Model 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 drf_yasg2.utils import swagger_auto_schema, swagger_serializer_method
from guardian.utils import get_anonymous_user from guardian.utils import get_anonymous_user
from rest_framework.decorators import action 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 rest_framework.viewsets import ModelViewSet
from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
from authentik.api.decorators import permission_required
from authentik.core.middleware import ( from authentik.core.middleware import (
SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_ORIGINAL_USER,
SESSION_IMPERSONATE_USER, SESSION_IMPERSONATE_USER,
) )
from authentik.core.models import User from authentik.core.models import Token, TokenIntents, User
from authentik.events.models import EventAction from authentik.events.models import EventAction
@ -54,6 +57,18 @@ class SessionUserSerializer(Serializer):
raise NotImplementedError 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): class UserMetricsSerializer(Serializer):
"""User Metrics""" """User Metrics"""
@ -116,6 +131,7 @@ class UserViewSet(ModelViewSet):
serializer.is_valid() serializer.is_valid()
return Response(serializer.data) return Response(serializer.data)
@permission_required("authentik_core.view_user", "authentik_events.view_event")
@swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)}) @swagger_auto_schema(responses={200: UserMetricsSerializer(many=False)})
@action(detail=False) @action(detail=False)
def metrics(self, request: Request) -> Response: def metrics(self, request: Request) -> Response:
@ -123,3 +139,23 @@ class UserViewSet(ModelViewSet):
serializer = UserMetricsSerializer(True) serializer = UserMetricsSerializer(True)
serializer.context["request"] = request serializer.context["request"] = request
return Response(serializer.data) 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.aggregates import Count
from django.db.models.fields.json import KeyTextTransform from django.db.models.fields.json import KeyTextTransform
from drf_yasg2.utils import swagger_auto_schema 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.decorators import action
from rest_framework.fields import CharField, DictField, IntegerField from rest_framework.fields import CharField, DictField, IntegerField
from rest_framework.request import Request from rest_framework.request import Request
@ -132,7 +133,8 @@ class EventViewSet(ReadOnlyModelViewSet):
filtered_action = request.query_params.get("action", EventAction.LOGIN) filtered_action = request.query_params.get("action", EventAction.LOGIN)
top_n = request.query_params.get("top_n", 15) top_n = request.query_params.get("top_n", 15)
return Response( 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) .exclude(context__authorized_application=None)
.annotate(application=KeyTextTransform("authorized_application", "context")) .annotate(application=KeyTextTransform("authorized_application", "context"))
.annotate(user_pk=KeyTextTransform("pk", "user")) .annotate(user_pk=KeyTextTransform("pk", "user"))

View File

@ -1,7 +1,6 @@
"""NotificationTransport API Views""" """NotificationTransport API Views"""
from django.http.response import Http404 from django.http.response import Http404
from drf_yasg2.utils import no_body, swagger_auto_schema 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.decorators import action
from rest_framework.fields import CharField, ListField, SerializerMethodField from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.request import Request 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.serializers import ModelSerializer, Serializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.api.decorators import permission_required
from authentik.events.models import ( from authentik.events.models import (
Notification, Notification,
NotificationSeverity, NotificationSeverity,
@ -57,18 +57,17 @@ class NotificationTransportViewSet(ModelViewSet):
queryset = NotificationTransport.objects.all() queryset = NotificationTransport.objects.all()
serializer_class = NotificationTransportSerializer serializer_class = NotificationTransportSerializer
@permission_required("authentik_events.change_notificationtransport")
@swagger_auto_schema( @swagger_auto_schema(
responses={200: NotificationTransportTestSerializer(many=False)}, responses={200: NotificationTransportTestSerializer(many=False)},
request_body=no_body, request_body=no_body,
) )
@action(detail=True, methods=["post"]) @action(detail=True, methods=["post"])
# pylint: disable=invalid-name # pylint: disable=invalid-name, unused-argument
def test(self, request: Request, pk=None) -> Response: def test(self, request: Request, pk=None) -> Response:
"""Send example notification using selected transport. Requires """Send example notification using selected transport. Requires
Modify permissions.""" Modify permissions."""
transports = get_objects_for_user( transports = self.get_object()
request.user, "authentik_events.change_notificationtransport"
).filter(pk=pk)
if not transports.exists(): if not transports.exists():
raise Http404 raise Http404
transport: NotificationTransport = transports.first() transport: NotificationTransport = transports.first()

View File

@ -3,13 +3,11 @@ from dataclasses import dataclass
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model from django.db.models import Model
from django.http.response import HttpResponseBadRequest, JsonResponse from django.http.response import JsonResponse
from django.shortcuts import get_object_or_404
from drf_yasg2 import openapi from drf_yasg2 import openapi
from drf_yasg2.utils import no_body, swagger_auto_schema from drf_yasg2.utils import no_body, swagger_auto_schema
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ( from rest_framework.serializers import (
@ -21,6 +19,7 @@ from rest_framework.serializers import (
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.api.decorators import permission_required
from authentik.core.api.utils import CacheSerializer from authentik.core.api.utils import CacheSerializer
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.flows.planner import cache_key from authentik.flows.planner import cache_key
@ -89,12 +88,14 @@ class FlowViewSet(ModelViewSet):
search_fields = ["name", "slug", "designation", "title"] search_fields = ["name", "slug", "designation", "title"]
filterset_fields = ["flow_uuid", "name", "slug", "designation"] filterset_fields = ["flow_uuid", "name", "slug", "designation"]
@permission_required("authentik_flows.view_flow_cache")
@swagger_auto_schema(responses={200: CacheSerializer(many=False)}) @swagger_auto_schema(responses={200: CacheSerializer(many=False)})
@action(detail=False) @action(detail=False)
def cache_info(self, request: Request) -> Response: def cache_info(self, request: Request) -> Response:
"""Info about cached flows""" """Info about cached flows"""
return Response(data={"count": len(cache.keys("flow_*"))}) return Response(data={"count": len(cache.keys("flow_*"))})
@permission_required("authentik_flows.clear_flow_cache")
@swagger_auto_schema( @swagger_auto_schema(
request_body=no_body, request_body=no_body,
responses={204: "Successfully cleared cache", 400: "Bad request"}, responses={204: "Successfully cleared cache", 400: "Bad request"},
@ -102,13 +103,12 @@ class FlowViewSet(ModelViewSet):
@action(detail=False, methods=["POST"]) @action(detail=False, methods=["POST"])
def cache_clear(self, request: Request) -> Response: def cache_clear(self, request: Request) -> Response:
"""Clear flow cache""" """Clear flow cache"""
if not request.user.is_superuser:
return HttpResponseBadRequest()
keys = cache.keys("flow_*") keys = cache.keys("flow_*")
cache.delete_many(keys) cache.delete_many(keys)
LOGGER.debug("Cleared flow cache", keys=len(keys)) LOGGER.debug("Cleared flow cache", keys=len(keys))
return Response(status=204) return Response(status=204)
@permission_required("authentik_flows.export_flow")
@swagger_auto_schema( @swagger_auto_schema(
responses={ responses={
"200": openapi.Response( "200": openapi.Response(
@ -121,8 +121,6 @@ class FlowViewSet(ModelViewSet):
def export(self, request: Request, slug: str) -> Response: def export(self, request: Request, slug: str) -> Response:
"""Export flow to .akflow file""" """Export flow to .akflow file"""
flow = self.get_object() flow = self.get_object()
if not request.user.has_perm("authentik_flows.export_flow", flow):
raise PermissionDenied()
exporter = FlowExporter(flow) exporter = FlowExporter(flow)
response = JsonResponse(exporter.export(), encoder=DataclassEncoder, safe=False) response = JsonResponse(exporter.export(), encoder=DataclassEncoder, safe=False)
response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"' response["Content-Disposition"] = f'attachment; filename="{flow.slug}.akflow"'
@ -130,13 +128,10 @@ class FlowViewSet(ModelViewSet):
@swagger_auto_schema(responses={200: FlowDiagramSerializer()}) @swagger_auto_schema(responses={200: FlowDiagramSerializer()})
@action(detail=True, methods=["get"]) @action(detail=True, methods=["get"])
# pylint: disable=unused-argument
def diagram(self, request: Request, slug: str) -> Response: def diagram(self, request: Request, slug: str) -> Response:
"""Return diagram for flow with slug `slug`, in the format used by flowchart.js""" """Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
flow = get_object_or_404( flow = self.get_object()
get_objects_for_user(request.user, "authentik_flows.view_flow").filter(
slug=slug
)
)
header = [ header = [
DiagramElement("st", "start", "Start"), 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 = [ permissions = [
("export_flow", "Can export a Flow"), ("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 rest_framework.viewsets import GenericViewSet, ModelViewSet
from structlog.stdlib import get_logger 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.applications import user_app_cache_key
from authentik.core.api.utils import ( from authentik.core.api.utils import (
CacheSerializer, CacheSerializer,
@ -142,12 +143,14 @@ class PolicyViewSet(
) )
return Response(TypeCreateSerializer(data, many=True).data) return Response(TypeCreateSerializer(data, many=True).data)
@permission_required("authentik_policies.view_policy_cache")
@swagger_auto_schema(responses={200: CacheSerializer(many=False)}) @swagger_auto_schema(responses={200: CacheSerializer(many=False)})
@action(detail=False) @action(detail=False)
def cache_info(self, request: Request) -> Response: def cache_info(self, request: Request) -> Response:
"""Info about cached policies""" """Info about cached policies"""
return Response(data={"count": len(cache.keys("policy_*"))}) return Response(data={"count": len(cache.keys("policy_*"))})
@permission_required("authentik_policies.clear_policy_cache")
@swagger_auto_schema( @swagger_auto_schema(
request_body=no_body, request_body=no_body,
responses={204: "Successfully cleared cache", 400: "Bad request"}, 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 = _("Policy")
verbose_name_plural = _("Policies") 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. description: A unique integer value identifying this User.
required: true required: true
type: integer 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/: /crypto/certificatekeypairs/:
get: get:
operationId: crypto_certificatekeypairs_list operationId: crypto_certificatekeypairs_list
@ -11120,6 +11138,16 @@ definitions:
items: items:
$ref: '#/definitions/Coordinate' $ref: '#/definitions/Coordinate'
readOnly: true 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: CertificateKeyPair:
description: CertificateKeyPair Serializer description: CertificateKeyPair Serializer
required: required: