*: simplify API permissions checking, add API for user recovery
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
59e1811187
commit
7e85524e51
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 a new issue