Merge pull request #664 from BeryJu/new-forms

web: New forms
This commit is contained in:
Jens L 2021-03-30 16:32:50 +02:00 committed by GitHub
commit 1f89b94f66
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
146 changed files with 6703 additions and 2341 deletions

View File

@ -40,7 +40,7 @@ RUN apt-get update && \
chown authentik:authentik /backups
COPY ./authentik/ /authentik
COPY ./pytest.ini /
COPY ./pyproject.toml /
COPY ./xml /xml
COPY ./manage.py /
COPY ./lifecycle/ /lifecycle

View File

@ -6,7 +6,7 @@ test-integration:
coverage run manage.py test -v 3 tests/integration
test-e2e:
coverage run manage.py test -v 3 tests/e2e
coverage run manage.py test --failfast -v 3 tests/e2e
coverage:
coverage run manage.py test -v 3 authentik

View File

@ -22,7 +22,7 @@ django-storages = "*"
djangorestframework = "*"
djangorestframework-guardian = "*"
docker = "*"
drf_yasg2 = "*"
drf_yasg = "*"
facebook-sdk = "*"
geoip2 = "*"
gunicorn = "*"

10
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
"sha256": "5fce5772178e4bc782d7112fab658f5bbb21abb77bb93fc3c0a66e9db3a23a37"
"sha256": "a9d504f00ee8820017f26a4fda2938de456cb72b4bc2f8735fc8c6a6c615d46a"
},
"pipfile-spec": 6,
"requires": {
@ -416,13 +416,13 @@
"index": "pypi",
"version": "==4.4.4"
},
"drf-yasg2": {
"drf-yasg": {
"hashes": [
"sha256:7037a8041eb5d1073fa504a284fc889685f93d0bfd008a963db1b366db786734",
"sha256:75e661ca5cf15eb44fcfab408c7b864f87c20794f564aa08b3a31817a857f19d"
"sha256:8b72e5b1875931a8d11af407be3a9a5ba8776541492947a0df5bafda6b7f8267",
"sha256:d50f197c7f02545d0b736df88c6d5cf874f8fea2507ad85ad7de6ae5bf2d9e5a"
],
"index": "pypi",
"version": "==1.19.4"
"version": "==1.20.0"
},
"facebook-sdk": {
"hashes": [

View File

@ -3,18 +3,18 @@ import time
from collections import Counter
from datetime import timedelta
from django.db.models import Count, ExpressionWrapper, F, Model
from django.db.models import Count, ExpressionWrapper, F
from django.db.models.fields import DurationField
from django.db.models.functions import ExtractHour
from django.utils.timezone import now
from drf_yasg2.utils import swagger_auto_schema, swagger_serializer_method
from drf_yasg.utils import swagger_auto_schema, swagger_serializer_method
from rest_framework.fields import IntegerField, SerializerMethodField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import Event, EventAction
@ -45,20 +45,14 @@ def get_events_per_1h(**filter_kwargs) -> list[dict[str, int]]:
return results
class CoordinateSerializer(Serializer):
class CoordinateSerializer(PassiveSerializer):
"""Coordinates for diagrams"""
x_cord = IntegerField(read_only=True)
y_cord = IntegerField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class LoginMetricsSerializer(Serializer):
class LoginMetricsSerializer(PassiveSerializer):
"""Login Metrics per 1h"""
logins_per_1h = SerializerMethodField()
@ -74,12 +68,6 @@ class LoginMetricsSerializer(Serializer):
"""Get failed logins per hour for the last 24 hours"""
return get_events_per_1h(action=EventAction.LOGIN_FAILED)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class AdministrationMetricsViewSet(ViewSet):
"""Login Metrics per 1h"""

View File

@ -2,22 +2,21 @@
from importlib import import_module
from django.contrib import messages
from django.db.models import Model
from django.http.response import Http404
from django.utils.translation import gettext_lazy as _
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import PassiveSerializer
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
class TaskSerializer(Serializer):
class TaskSerializer(PassiveSerializer):
"""Serialize TaskInfo and TaskResult"""
task_name = CharField()
@ -30,12 +29,6 @@ class TaskSerializer(Serializer):
)
messages = ListField(source="result.messages")
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class TaskViewSet(ViewSet):
"""Read-only view set that returns all background tasks"""

View File

@ -2,22 +2,21 @@
from os import environ
from django.core.cache import cache
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from packaging.version import parse
from rest_framework.fields import SerializerMethodField
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from rest_framework.viewsets import GenericViewSet
from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
from authentik.core.api.utils import PassiveSerializer
class VersionSerializer(Serializer):
class VersionSerializer(PassiveSerializer):
"""Get running and latest version."""
version_current = SerializerMethodField()
@ -47,12 +46,6 @@ class VersionSerializer(Serializer):
self.get_version_latest(instance)
)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class VersionViewSet(ListModelMixin, GenericViewSet):
"""Get running and latest version."""

View File

@ -1,22 +0,0 @@
"""authentik administrative user forms"""
from django import forms
from authentik.admin.fields import CodeMirrorWidget, YAMLField
from authentik.core.models import User
class UserForm(forms.ModelForm):
"""Update User Details"""
class Meta:
model = User
fields = ["username", "name", "email", "is_active", "attributes"]
widgets = {
"name": forms.TextInput,
"attributes": CodeMirrorWidget,
}
field_classes = {
"attributes": YAMLField,
}

View File

@ -1,9 +0,0 @@
"""authentik admin mixins"""
from django.contrib.auth.mixins import UserPassesTestMixin
class AdminRequiredMixin(UserPassesTestMixin):
"""Make sure user is administrator"""
def test_func(self):
return self.request.user.is_superuser

View File

@ -1,14 +0,0 @@
{% extends base_template|default:"generic/form.html" %}
{% load authentik_utils %}
{% load i18n %}
{% block above_form %}
<h1>
{% trans 'Generate Certificate-Key Pair' %}
</h1>
{% endblock %}
{% block action %}
{% trans 'Generate Certificate-Key Pair' %}
{% endblock %}

View File

@ -1,13 +0,0 @@
{% extends base_template|default:"generic/form.html" %}
{% load i18n %}
{% block above_form %}
<h1>
{% trans 'Import Flow' %}
</h1>
{% endblock %}
{% block action %}
{% trans 'Import Flow' %}
{% endblock %}

View File

@ -2,13 +2,6 @@
from django.urls import path
from authentik.admin.views import (
applications,
certificate_key_pair,
events_notifications_rules,
events_notifications_transports,
flows,
groups,
outposts,
outposts_service_connections,
policies,
policies_bindings,
@ -19,22 +12,10 @@ from authentik.admin.views import (
stages_bindings,
stages_invitations,
stages_prompts,
users,
)
from authentik.providers.saml.views.metadata import MetadataImportView
urlpatterns = [
# Applications
path(
"applications/create/",
applications.ApplicationCreateView.as_view(),
name="application-create",
),
path(
"applications/<uuid:pk>/update/",
applications.ApplicationUpdateView.as_view(),
name="application-update",
),
# Sources
path("sources/create/", sources.SourceCreateView.as_view(), name="source-create"),
path(
@ -116,27 +97,6 @@ urlpatterns = [
stages_invitations.InvitationCreateView.as_view(),
name="stage-invitation-create",
),
# Flows
path(
"flows/create/",
flows.FlowCreateView.as_view(),
name="flow-create",
),
path(
"flows/import/",
flows.FlowImportView.as_view(),
name="flow-import",
),
path(
"flows/<uuid:pk>/update/",
flows.FlowUpdateView.as_view(),
name="flow-update",
),
path(
"flows/<uuid:pk>/execute/",
flows.FlowDebugExecuteView.as_view(),
name="flow-execute",
),
# Property Mappings
path(
"property-mappings/create/",
@ -153,48 +113,6 @@ urlpatterns = [
property_mappings.PropertyMappingTestView.as_view(),
name="property-mapping-test",
),
# Users
path("users/create/", users.UserCreateView.as_view(), name="user-create"),
path("users/<int:pk>/update/", users.UserUpdateView.as_view(), name="user-update"),
path(
"users/<int:pk>/reset/",
users.UserPasswordResetView.as_view(),
name="user-password-reset",
),
# Groups
path("groups/create/", groups.GroupCreateView.as_view(), name="group-create"),
path(
"groups/<uuid:pk>/update/",
groups.GroupUpdateView.as_view(),
name="group-update",
),
# Certificate-Key Pairs
path(
"crypto/certificates/create/",
certificate_key_pair.CertificateKeyPairCreateView.as_view(),
name="certificatekeypair-create",
),
path(
"crypto/certificates/generate/",
certificate_key_pair.CertificateKeyPairGenerateView.as_view(),
name="certificatekeypair-generate",
),
path(
"crypto/certificates/<uuid:pk>/update/",
certificate_key_pair.CertificateKeyPairUpdateView.as_view(),
name="certificatekeypair-update",
),
# Outposts
path(
"outposts/create/",
outposts.OutpostCreateView.as_view(),
name="outpost-create",
),
path(
"outposts/<uuid:pk>/update/",
outposts.OutpostUpdateView.as_view(),
name="outpost-update",
),
# Outpost Service Connections
path(
"outpost_service_connections/create/",
@ -206,26 +124,4 @@ urlpatterns = [
outposts_service_connections.OutpostServiceConnectionUpdateView.as_view(),
name="outpost-service-connection-update",
),
# Event Notification Transpots
path(
"events/transports/create/",
events_notifications_transports.NotificationTransportCreateView.as_view(),
name="notification-transport-create",
),
path(
"events/transports/<uuid:pk>/update/",
events_notifications_transports.NotificationTransportUpdateView.as_view(),
name="notification-transport-update",
),
# Event Notification Rules
path(
"events/rules/create/",
events_notifications_rules.NotificationRuleCreateView.as_view(),
name="notification-rule-create",
),
path(
"events/rules/<uuid:pk>/update/",
events_notifications_rules.NotificationRuleUpdateView.as_view(),
name="notification-rule-update",
),
]

View File

@ -1,66 +0,0 @@
"""authentik Application administration"""
from typing import Any
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
from guardian.shortcuts import get_objects_for_user
from authentik.core.forms.applications import ApplicationForm
from authentik.core.models import Application
from authentik.lib.views import CreateAssignPermView
class ApplicationCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create new Application"""
model = Application
form_class = ApplicationForm
permission_required = "authentik_core.add_application"
success_url = "/"
template_name = "generic/create.html"
success_message = _("Successfully created Application")
def get_initial(self) -> dict[str, Any]:
if "provider" in self.request.GET:
try:
initial_provider_pk = int(self.request.GET["provider"])
except ValueError:
return super().get_initial()
providers = (
get_objects_for_user(self.request.user, "authentik_core.view_provider")
.filter(pk=initial_provider_pk)
.select_subclasses()
)
if not providers.exists():
return {}
return {"provider": providers.first()}
return super().get_initial()
class ApplicationUpdateView(
SuccessMessageMixin,
LoginRequiredMixin,
PermissionRequiredMixin,
UpdateView,
):
"""Update application"""
model = Application
form_class = ApplicationForm
permission_required = "authentik_core.change_application"
success_url = "/"
template_name = "generic/update.html"
success_message = _("Successfully updated Application")

View File

@ -1,81 +0,0 @@
"""authentik CertificateKeyPair administration"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http.response import HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from django.views.generic.edit import FormView
from guardian.mixins import PermissionRequiredMixin
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.forms import (
CertificateKeyPairForm,
CertificateKeyPairGenerateForm,
)
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.views import CreateAssignPermView
class CertificateKeyPairCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create new CertificateKeyPair"""
model = CertificateKeyPair
form_class = CertificateKeyPairForm
permission_required = "authentik_crypto.add_certificatekeypair"
template_name = "generic/create.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully created Certificate-Key Pair")
class CertificateKeyPairGenerateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
FormView,
):
"""Generate new CertificateKeyPair"""
model = CertificateKeyPair
form_class = CertificateKeyPairGenerateForm
permission_required = "authentik_crypto.add_certificatekeypair"
template_name = "administration/certificatekeypair/generate.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully generated Certificate-Key Pair")
def form_valid(self, form: CertificateKeyPairGenerateForm) -> HttpResponse:
builder = CertificateBuilder()
builder.common_name = form.data["common_name"]
builder.build(
subject_alt_names=form.data.get("subject_alt_name", "").split(","),
validity_days=int(form.data["validity_days"]),
)
builder.save()
return super().form_valid(form)
class CertificateKeyPairUpdateView(
SuccessMessageMixin,
LoginRequiredMixin,
PermissionRequiredMixin,
UpdateView,
):
"""Update certificatekeypair"""
model = CertificateKeyPair
form_class = CertificateKeyPairForm
permission_required = "authentik_crypto.change_certificatekeypair"
template_name = "generic/update.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully updated Certificate-Key Pair")

View File

@ -1,47 +0,0 @@
"""authentik NotificationRule administration"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
from authentik.events.forms import NotificationRuleForm
from authentik.events.models import NotificationRule
from authentik.lib.views import CreateAssignPermView
class NotificationRuleCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create new NotificationRule"""
model = NotificationRule
form_class = NotificationRuleForm
permission_required = "authentik_events.add_NotificationRule"
success_url = "/"
template_name = "generic/create.html"
success_message = _("Successfully created Notification Rule")
class NotificationRuleUpdateView(
SuccessMessageMixin,
LoginRequiredMixin,
PermissionRequiredMixin,
UpdateView,
):
"""Update application"""
model = NotificationRule
form_class = NotificationRuleForm
permission_required = "authentik_events.change_NotificationRule"
success_url = "/"
template_name = "generic/update.html"
success_message = _("Successfully updated Notification Rule")

View File

@ -1,45 +0,0 @@
"""authentik NotificationTransport administration"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
from authentik.events.forms import NotificationTransportForm
from authentik.events.models import NotificationTransport
from authentik.lib.views import CreateAssignPermView
class NotificationTransportCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create new NotificationTransport"""
model = NotificationTransport
form_class = NotificationTransportForm
permission_required = "authentik_events.add_notificationtransport"
success_url = "/"
template_name = "generic/create.html"
success_message = _("Successfully created Notification Transport")
class NotificationTransportUpdateView(
SuccessMessageMixin,
LoginRequiredMixin,
PermissionRequiredMixin,
UpdateView,
):
"""Update application"""
model = NotificationTransport
form_class = NotificationTransportForm
permission_required = "authentik_events.change_notificationtransport"
success_url = "/"
template_name = "generic/update.html"
success_message = _("Successfully updated Notification Transport")

View File

@ -1,108 +0,0 @@
"""authentik Flow administration"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpRequest, HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import DetailView, FormView, UpdateView
from guardian.mixins import PermissionRequiredMixin
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.forms import FlowForm, FlowImportForm
from authentik.flows.models import Flow
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.transfer.importer import FlowImporter
from authentik.flows.views import SESSION_KEY_PLAN, FlowPlanner
from authentik.lib.utils.urls import redirect_with_qs
from authentik.lib.views import CreateAssignPermView, bad_request_message
class FlowCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create new Flow"""
model = Flow
form_class = FlowForm
permission_required = "authentik_flows.add_flow"
template_name = "generic/create.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully created Flow")
class FlowUpdateView(
SuccessMessageMixin,
LoginRequiredMixin,
PermissionRequiredMixin,
UpdateView,
):
"""Update flow"""
model = Flow
form_class = FlowForm
permission_required = "authentik_flows.change_flow"
template_name = "generic/update.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully updated Flow")
class FlowDebugExecuteView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
"""Debug exectue flow, setting the current user as pending user"""
model = Flow
permission_required = "authentik_flows.view_flow"
# pylint: disable=unused-argument
def get(self, request: HttpRequest, pk: str) -> HttpResponse:
"""Debug exectue flow, setting the current user as pending user"""
flow: Flow = self.get_object()
planner = FlowPlanner(flow)
planner.use_cache = False
try:
plan = planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: request.user})
self.request.session[SESSION_KEY_PLAN] = plan
except FlowNonApplicableException as exc:
return bad_request_message(
request,
_(
"Flow not applicable to current user/request: %(messages)s"
% {"messages": str(exc)}
),
)
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=flow.slug,
)
class FlowImportView(LoginRequiredMixin, FormView):
"""Import flow from JSON Export; only allowed for superusers
as these flows can contain python code"""
form_class = FlowImportForm
template_name = "administration/flow/import.html"
success_url = reverse_lazy("authentik_core:if-admin")
def dispatch(self, request, *args, **kwargs):
if not request.user.is_superuser:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form: FlowImportForm) -> HttpResponse:
importer = FlowImporter(form.cleaned_data["flow"].read().decode())
successful = importer.apply()
if not successful:
messages.error(self.request, _("Failed to import flow."))
else:
messages.success(self.request, _("Successfully imported flow."))
return super().form_valid(form)

View File

@ -1,48 +0,0 @@
"""authentik Group administration"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
from authentik.core.forms.groups import GroupForm
from authentik.core.models import Group
from authentik.lib.views import CreateAssignPermView
class GroupCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create new Group"""
model = Group
form_class = GroupForm
permission_required = "authentik_core.add_group"
template_name = "generic/create.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully created Group")
class GroupUpdateView(
SuccessMessageMixin,
LoginRequiredMixin,
PermissionRequiredMixin,
UpdateView,
):
"""Update group"""
model = Group
form_class = GroupForm
permission_required = "authentik_core.change_group"
template_name = "generic/update.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully updated Group")

View File

@ -1,55 +0,0 @@
"""authentik Outpost administration"""
from dataclasses import asdict
from typing import Any
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from guardian.mixins import PermissionRequiredMixin
from authentik.lib.views import CreateAssignPermView
from authentik.outposts.forms import OutpostForm
from authentik.outposts.models import Outpost, OutpostConfig
class OutpostCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create new Outpost"""
model = Outpost
form_class = OutpostForm
permission_required = "authentik_outposts.add_outpost"
success_url = "/"
template_name = "generic/create.html"
success_message = _("Successfully created Outpost")
def get_initial(self) -> dict[str, Any]:
return {
"_config": asdict(
OutpostConfig(authentik_host=self.request.build_absolute_uri("/"))
)
}
class OutpostUpdateView(
SuccessMessageMixin,
LoginRequiredMixin,
PermissionRequiredMixin,
UpdateView,
):
"""Update outpost"""
model = Outpost
form_class = OutpostForm
permission_required = "authentik_outposts.change_outpost"
success_url = "/"
template_name = "generic/update.html"
success_message = _("Successfully updated Outpost")

View File

@ -1,74 +0,0 @@
"""authentik User administration"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse_lazy
from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django.views.generic import DetailView, UpdateView
from guardian.mixins import PermissionRequiredMixin
from authentik.admin.forms.users import UserForm
from authentik.core.models import Token, User
from authentik.lib.views import CreateAssignPermView
class UserCreateView(
SuccessMessageMixin,
LoginRequiredMixin,
DjangoPermissionRequiredMixin,
CreateAssignPermView,
):
"""Create user"""
model = User
form_class = UserForm
permission_required = "authentik_core.add_user"
template_name = "generic/create.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully created User")
class UserUpdateView(
SuccessMessageMixin,
LoginRequiredMixin,
PermissionRequiredMixin,
UpdateView,
):
"""Update user"""
model = User
form_class = UserForm
permission_required = "authentik_core.change_user"
# By default the object's name is user which is used by other checks
context_object_name = "object"
template_name = "generic/update.html"
success_url = reverse_lazy("authentik_core:if-admin")
success_message = _("Successfully updated User")
class UserPasswordResetView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
"""Get Password reset link for user"""
model = User
permission_required = "authentik_core.reset_user_password"
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Create token for user and return link"""
super().get(request, *args, **kwargs)
token, __ = Token.objects.get_or_create(
identifier="password-reset-temp", user=self.object
)
querystring = urlencode({"token": token.key})
link = request.build_absolute_uri(
reverse_lazy("authentik_flows:default-recovery") + f"?{querystring}"
)
messages.success(request, _("Password reset link: %(link)s" % {"link": link}))
return redirect("/")

View File

@ -1,26 +1,13 @@
"""authentik admin util views"""
from typing import Any
from django.contrib import messages
from django.contrib.messages.views import SuccessMessageMixin
from django.http import Http404
from django.urls import reverse_lazy
from django.views.generic import DeleteView, UpdateView
from django.views.generic import UpdateView
from authentik.lib.utils.reflection import all_subclasses
from authentik.lib.views import CreateAssignPermView
class DeleteMessageView(SuccessMessageMixin, DeleteView):
"""DeleteView which shows `self.success_message` on successful deletion"""
success_url = reverse_lazy("authentik_core:if-admin")
def delete(self, request, *args, **kwargs):
messages.success(self.request, self.success_message)
return super().delete(request, *args, **kwargs)
class InheritanceCreateView(CreateAssignPermView):
"""CreateView for objects using InheritanceManager"""

View File

@ -1,13 +1,15 @@
"""API Decorators"""
from functools import wraps
from typing import Callable
from typing import Callable, Optional
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):
def permission_required(
perm: Optional[str] = None, other_perms: Optional[list[str]] = None
):
"""Check permissions for a single custom action"""
def wrapper_outter(func: Callable):
@ -15,12 +17,14 @@ def permission_required(perm: str, *other_perms: str):
@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):
if perm:
obj = self.get_object()
if not request.user.has_perm(perm, obj):
return self.permission_denied(request)
if other_perms:
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

View File

@ -1,8 +1,8 @@
"""Swagger Pagination Schema class"""
from typing import OrderedDict
from drf_yasg2 import openapi
from drf_yasg2.inspectors import PaginatorInspector
from drf_yasg import openapi
from drf_yasg.inspectors import PaginatorInspector
class PaginationInspector(PaginatorInspector):

102
authentik/api/schema.py Normal file
View File

@ -0,0 +1,102 @@
"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""
from drf_yasg import openapi
from drf_yasg.inspectors.view import SwaggerAutoSchema
from drf_yasg.utils import force_real_str, is_list_view
from rest_framework import exceptions, status
from rest_framework.settings import api_settings
class ErrorResponseAutoSchema(SwaggerAutoSchema):
"""Inspector which includes an error schema"""
def get_generic_error_schema(self):
"""Get a generic error schema"""
return openapi.Schema(
"Generic API Error",
type=openapi.TYPE_OBJECT,
properties={
"detail": openapi.Schema(
type=openapi.TYPE_STRING, description="Error details"
),
"code": openapi.Schema(
type=openapi.TYPE_STRING, description="Error code"
),
},
required=["detail"],
)
def get_validation_error_schema(self):
"""Get a generic validation error schema"""
return openapi.Schema(
"Validation Error",
type=openapi.TYPE_OBJECT,
properties={
api_settings.NON_FIELD_ERRORS_KEY: openapi.Schema(
description="List of validation errors not related to any field",
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_STRING),
),
},
additional_properties=openapi.Schema(
description=(
"A list of error messages for each "
"field that triggered a validation error"
),
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_STRING),
),
)
def get_response_serializers(self):
responses = super().get_response_serializers()
definitions = self.components.with_scope(
openapi.SCHEMA_DEFINITIONS
) # type: openapi.ReferenceResolver
definitions.setdefault("GenericError", self.get_generic_error_schema)
definitions.setdefault("ValidationError", self.get_validation_error_schema)
definitions.setdefault("APIException", self.get_generic_error_schema)
if self.get_request_serializer() or self.get_query_serializer():
responses.setdefault(
exceptions.ValidationError.status_code,
openapi.Response(
description=force_real_str(
exceptions.ValidationError.default_detail
),
schema=openapi.SchemaRef(definitions, "ValidationError"),
),
)
security = self.get_security()
if security is None or len(security) > 0:
# Note: 401 error codes are coerced into 403 see
# rest_framework/views.py:433:handle_exception
# This is b/c the API uses token auth which doesn't have WWW-Authenticate header
responses.setdefault(
status.HTTP_403_FORBIDDEN,
openapi.Response(
description="Authentication credentials were invalid, absent or insufficient.",
schema=openapi.SchemaRef(definitions, "GenericError"),
),
)
if not is_list_view(self.path, self.method, self.view):
responses.setdefault(
exceptions.PermissionDenied.status_code,
openapi.Response(
description="Permission denied.",
schema=openapi.SchemaRef(definitions, "APIException"),
),
)
responses.setdefault(
exceptions.NotFound.status_code,
openapi.Response(
description=(
"Object does not exist or caller "
"has insufficient permissions to access it."
),
schema=openapi.SchemaRef(definitions, "APIException"),
),
)
return responses

View File

View File

@ -0,0 +1,24 @@
"""Swagger generation tests"""
from json import loads
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import safe_load
class TestSwaggerGeneration(APITestCase):
"""Generic admin tests"""
def test_yaml(self):
"""Test YAML generation"""
response = self.client.get(
reverse("authentik_api:schema-json", kwargs={"format": ".yaml"}),
)
self.assertTrue(safe_load(response.content.decode()))
def test_json(self):
"""Test JSON generation"""
response = self.client.get(
reverse("authentik_api:schema-json", kwargs={"format": ".json"}),
)
self.assertTrue(loads(response.content.decode()))

View File

@ -1,46 +1,33 @@
"""core Configs API"""
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.fields import BooleanField, CharField, ListField
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import PassiveSerializer
from authentik.lib.config import CONFIG
class LinkSerializer(Serializer):
class FooterLinkSerializer(PassiveSerializer):
"""Links returned in Config API"""
href = CharField(read_only=True)
name = CharField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class ConfigSerializer(Serializer):
class ConfigSerializer(PassiveSerializer):
"""Serialize authentik Config into DRF Object"""
branding_logo = CharField(read_only=True)
branding_title = CharField(read_only=True)
ui_footer_links = ListField(child=LinkSerializer(), read_only=True)
ui_footer_links = ListField(child=FooterLinkSerializer(), read_only=True)
error_reporting_enabled = BooleanField(read_only=True)
error_reporting_environment = CharField(read_only=True)
error_reporting_send_pii = BooleanField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class ConfigsViewSet(ViewSet):
"""Read-only view set that returns the current session's Configs"""

View File

@ -1,7 +1,7 @@
"""api v2 urls"""
from django.urls import path, re_path
from drf_yasg2 import openapi
from drf_yasg2.views import get_schema_view
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import routers
from rest_framework.permissions import AllowAny
@ -33,7 +33,8 @@ from authentik.outposts.api.outpost_service_connections import (
ServiceConnectionViewSet,
)
from authentik.outposts.api.outposts import OutpostViewSet
from authentik.policies.api import PolicyBindingViewSet, PolicyViewSet
from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.dummy.api import DummyPolicyViewSet
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
@ -189,7 +190,7 @@ router.register("policies/dummy", DummyPolicyViewSet)
info = openapi.Info(
title="authentik API",
default_version="v2",
default_version="v2beta",
contact=openapi.Contact(email="hello@beryju.org"),
license=openapi.License(
name="GNU GPLv3", url="https://github.com/BeryJu/authentik/blob/master/LICENSE"

View File

@ -1,9 +1,12 @@
"""Application API Views"""
from django.core.cache import cache
from django.db.models import QuerySet
from drf_yasg2.utils import swagger_auto_schema
from django.http.response import HttpResponseBadRequest
from drf_yasg import openapi
from drf_yasg.utils import no_body, swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import SerializerMethodField
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
@ -49,7 +52,6 @@ class ApplicationSerializer(ModelSerializer):
"meta_icon",
"meta_description",
"meta_publisher",
"policies",
]
@ -108,8 +110,33 @@ class ApplicationViewSet(ModelViewSet):
serializer = self.get_serializer(allowed_applications, many=True)
return self.get_paginated_response(serializer.data)
@permission_required("authentik_core.change_application")
@swagger_auto_schema(
request_body=no_body,
manual_parameters=[
openapi.Parameter(
name="file",
in_=openapi.IN_FORM,
type=openapi.TYPE_FILE,
required=True,
)
],
responses={200: "Success"},
)
@action(detail=True, methods=["POST"], parser_classes=(MultiPartParser,))
# pylint: disable=unused-argument
def set_icon(self, request: Request, slug: str):
"""Set application icon"""
app: Application = self.get_object()
icon = request.FILES.get("file", None)
if not icon:
return HttpResponseBadRequest()
app.meta_icon = icon
app.save()
return Response({})
@permission_required(
"authentik_core.view_application", "authentik_events.view_event"
"authentik_core.view_application", ["authentik_events.view_event"]
)
@swagger_auto_schema(responses={200: CoordinateSerializer(many=True)})
@action(detail=True)

View File

@ -1,6 +1,6 @@
"""PropertyMapping API Views"""
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.request import Request

View File

@ -1,7 +1,7 @@
"""Provider API Views"""
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField
from rest_framework.request import Request

View File

@ -2,7 +2,7 @@
from typing import Iterable
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.request import Request

View File

@ -1,15 +1,16 @@
"""Tokens API Viewset"""
from django.db.models.base import Model
from django.http.response import Http404
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.api.decorators import permission_required
from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import Token
from authentik.events.models import Event, EventAction
@ -34,17 +35,11 @@ class TokenSerializer(ModelSerializer):
depth = 2
class TokenViewSerializer(Serializer):
class TokenViewSerializer(PassiveSerializer):
"""Show token's current key"""
key = CharField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class TokenViewSet(ModelViewSet):
"""Token Viewset"""
@ -66,6 +61,7 @@ class TokenViewSet(ModelViewSet):
]
ordering = ["expires"]
@permission_required("authentik_core.view_token_key")
@swagger_auto_schema(responses={200: TokenViewSerializer(many=False)})
@action(detail=True)
# pylint: disable=unused-argument

View File

@ -1,18 +1,18 @@
"""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 drf_yasg.utils import swagger_auto_schema, swagger_serializer_method
from guardian.utils import get_anonymous_user
from rest_framework.decorators import action
from rest_framework.fields import CharField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import BooleanField, ModelSerializer, Serializer
from rest_framework.serializers import BooleanField, ModelSerializer
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.api.utils import LinkSerializer, PassiveSerializer
from authentik.core.middleware import (
SESSION_IMPERSONATE_ORIGINAL_USER,
SESSION_IMPERSONATE_USER,
@ -43,33 +43,15 @@ class UserSerializer(ModelSerializer):
]
class SessionUserSerializer(Serializer):
class SessionUserSerializer(PassiveSerializer):
"""Response for the /user/me endpoint, returns the currently active user (as `user` property)
and, if this user is being impersonated, the original user in the `original` property."""
user = UserSerializer()
original = UserSerializer(required=False)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
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(PassiveSerializer):
"""User Metrics"""
logins_per_1h = SerializerMethodField()
@ -98,12 +80,6 @@ class UserMetricsSerializer(Serializer):
action=EventAction.AUTHORIZE_APPLICATION, user__pk=request.user.pk
)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class UserViewSet(ModelViewSet):
"""User Viewset"""
@ -131,7 +107,7 @@ class UserViewSet(ModelViewSet):
serializer.is_valid()
return Response(serializer.data)
@permission_required("authentik_core.view_user", "authentik_events.view_event")
@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:
@ -142,7 +118,7 @@ class UserViewSet(ModelViewSet):
@permission_required("authentik_core.reset_user_password")
@swagger_auto_schema(
responses={"200": UserRecoverySerializer(many=False)},
responses={"200": LinkSerializer(many=False)},
)
@action(detail=True)
# pylint: disable=invalid-name, unused-argument

View File

@ -4,18 +4,22 @@ from rest_framework.fields import CharField, IntegerField
from rest_framework.serializers import Serializer, SerializerMethodField
class MetaNameSerializer(Serializer):
class PassiveSerializer(Serializer):
"""Base serializer class which doesn't implement create/update methods"""
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class MetaNameSerializer(PassiveSerializer):
"""Add verbose names to response"""
verbose_name = SerializerMethodField()
verbose_name_plural = SerializerMethodField()
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
def get_verbose_name(self, obj: Model) -> str:
"""Return object's verbose_name"""
return obj._meta.verbose_name
@ -25,27 +29,21 @@ class MetaNameSerializer(Serializer):
return obj._meta.verbose_name_plural
class TypeCreateSerializer(Serializer):
class TypeCreateSerializer(PassiveSerializer):
"""Types of an object that can be created"""
name = CharField(required=True)
description = CharField(required=True)
link = CharField(required=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class CacheSerializer(Serializer):
class CacheSerializer(PassiveSerializer):
"""Generic cache stats for an object"""
count = IntegerField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class LinkSerializer(PassiveSerializer):
"""Returns a single link"""
link = CharField()

View File

@ -1,50 +0,0 @@
"""authentik Core Application forms"""
from django import forms
from django.utils.translation import gettext_lazy as _
from authentik.core.models import Application, Provider
from authentik.lib.widgets import GroupedModelChoiceField
class ApplicationForm(forms.ModelForm):
"""Application Form"""
def __init__(self, *args, **kwargs): # pragma: no cover
super().__init__(*args, **kwargs)
self.fields["provider"].queryset = (
Provider.objects.all().order_by("name").select_subclasses()
)
class Meta:
model = Application
fields = [
"name",
"slug",
"provider",
"meta_launch_url",
"meta_icon",
"meta_description",
"meta_publisher",
]
widgets = {
"name": forms.TextInput(),
"meta_launch_url": forms.TextInput(),
"meta_publisher": forms.TextInput(),
"meta_icon": forms.FileInput(),
}
help_texts = {
"meta_launch_url": _(
(
"If left empty, authentik will try to extract the launch URL "
"based on the selected provider."
)
),
}
field_classes = {"provider": GroupedModelChoiceField}
labels = {
"meta_launch_url": _("Launch URL"),
"meta_icon": _("Icon"),
"meta_description": _("Description"),
"meta_publisher": _("Publisher"),
}

View File

@ -1,38 +0,0 @@
"""authentik Core Group forms"""
from django import forms
from authentik.admin.fields import CodeMirrorWidget, YAMLField
from authentik.core.models import Group, User
class GroupForm(forms.ModelForm):
"""Group Form"""
members = forms.ModelMultipleChoiceField(
User.objects.all(),
required=False,
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.initial["members"] = self.instance.users.values_list("pk", flat=True)
def save(self, *args, **kwargs):
instance = super().save(*args, **kwargs)
if instance.pk:
instance.users.clear()
instance.users.add(*self.cleaned_data["members"])
return instance
class Meta:
model = Group
fields = ["name", "is_superuser", "parent", "members", "attributes"]
widgets = {
"name": forms.TextInput(),
"attributes": CodeMirrorWidget,
}
field_classes = {
"attributes": YAMLField,
}

View File

@ -1,15 +0,0 @@
"""authentik core user forms"""
from django import forms
from authentik.core.models import User
class UserDetailForm(forms.ModelForm):
"""Update User Details"""
class Meta:
model = User
fields = ["username", "name", "email"]
widgets = {"name": forms.TextInput}

View File

@ -0,0 +1,21 @@
# Generated by Django 3.1.7 on 2021-03-30 13:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0017_managed"),
]
operations = [
migrations.AlterModelOptions(
name="token",
options={
"permissions": (("view_token_key", "View token's key"),),
"verbose_name": "Token",
"verbose_name_plural": "Tokens",
},
),
]

View File

@ -369,6 +369,7 @@ class Token(ManagedModel, ExpiringModel):
models.Index(fields=["identifier"]),
models.Index(fields=["key"]),
]
permissions = (("view_token_key", "View token's key"),)
class PropertyMapping(SerializerModel, ManagedModel):

View File

@ -17,7 +17,7 @@ def post_save_application(sender, instance, created: bool, **_):
if sender != Application:
return
if not created:
if not created: # pragma: no cover
return
# Also delete user application cache
keys = cache.keys(user_app_cache_key("*"))

View File

@ -1,26 +0,0 @@
{% load i18n %}
<div class="pf-c-card">
<div class="pf-c-card__title">
{% trans 'Update details' %}
</div>
<div class="pf-c-card__body">
<form action="" method="post" class="pf-c-form pf-m-horizontal">
{% include 'partials/form_horizontal.html' with form=form %}
{% block beneath_form %}
{% endblock %}
<div class="pf-c-form__group pf-m-action">
<div class="pf-c-form__horizontal-group">
<div class="pf-c-form__actions">
<input class="pf-c-button pf-m-primary" type="submit" value="{% trans 'Update' %}" />
{% if unenrollment_enabled %}
<a class="pf-c-button pf-m-danger"
href="{% url 'authentik_flows:default-unenrollment' %}?back={{ request.get_full_path }}">{%
trans "Delete account" %}</a>
{% endif %}
</div>
</div>
</div>
</form>
</div>
</div>

View File

@ -1,30 +0,0 @@
"""authentik user view tests"""
import string
from random import SystemRandom
from django.test import TestCase
from django.urls import reverse
from authentik.core.models import User
class TestUserViews(TestCase):
"""Test User Views"""
def setUp(self):
super().setUp()
self.user = User.objects.create_user(
username="unittest user",
email="unittest@example.com",
password="".join(
SystemRandom().choice(string.ascii_uppercase + string.digits)
for _ in range(8)
),
)
self.client.force_login(self.user)
def test_user_details(self):
"""Test UserDetailsView"""
self.assertEqual(
self.client.get(reverse("authentik_core:user-details")).status_code, 200
)

View File

@ -2,9 +2,9 @@
from dataclasses import dataclass
from typing import Optional
from django.db.models.base import Model
from rest_framework.fields import CharField
from rest_framework.serializers import Serializer
from authentik.core.api.utils import PassiveSerializer
@dataclass
@ -21,29 +21,17 @@ class UILoginButton:
icon_url: Optional[str] = None
class UILoginButtonSerializer(Serializer):
class UILoginButtonSerializer(PassiveSerializer):
"""Serializer for Login buttons of sources"""
name = CharField()
url = CharField()
icon_url = CharField(required=False)
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class UserSettingSerializer(Serializer):
class UserSettingSerializer(PassiveSerializer):
"""Serializer for User settings for stages and sources"""
object_uid = CharField()
component = CharField()
title = CharField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()

View File

@ -14,7 +14,6 @@ urlpatterns = [
name="root-redirect",
),
# User views
path("-/user/details/", user.UserDetailsView.as_view(), name="user-details"),
path(
"-/user/tokens/create/",
user.TokenCreateView.as_view(),

View File

@ -1,53 +1,20 @@
"""authentik core user views"""
from typing import Any
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.mixins import (
PermissionRequiredMixin as DjangoPermissionRequiredMixin,
)
from django.contrib.messages.views import SuccessMessageMixin
from django.http.response import HttpResponse
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from django.views.generic.base import TemplateView
from guardian.mixins import PermissionRequiredMixin
from guardian.shortcuts import get_objects_for_user
from authentik.core.forms.token import UserTokenForm
from authentik.core.forms.users import UserDetailForm
from authentik.core.models import Token, TokenIntents
from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.views import CreateAssignPermView
class UserSettingsView(TemplateView):
"""Multiple SiteShells for user details and all stages"""
template_name = "user/settings.html"
class UserDetailsView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
"""Update User details"""
template_name = "user/details.html"
form_class = UserDetailForm
success_message = _("Successfully updated user.")
success_url = reverse_lazy("authentik_core:user-details")
def get_object(self):
return self.request.user
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs = super().get_context_data(**kwargs)
unenrollment_flow = Flow.with_policy(
self.request, designation=FlowDesignation.UNRENOLLMENT
)
kwargs["unenrollment_enabled"] = bool(unenrollment_flow)
return kwargs
class TokenCreateView(
SuccessMessageMixin,
LoginRequiredMixin,

View File

@ -2,15 +2,23 @@
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import load_pem_x509_certificate
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema
from django.utils.translation import gettext_lazy as _
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField, DateTimeField, SerializerMethodField
from rest_framework.fields import (
CharField,
DateTimeField,
IntegerField,
SerializerMethodField,
)
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer, ValidationError
from rest_framework.serializers import ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet
from authentik.api.decorators import permission_required
from authentik.core.api.utils import PassiveSerializer
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
from authentik.events.models import Event, EventAction
@ -71,16 +79,20 @@ class CertificateKeyPairSerializer(ModelSerializer):
}
class CertificateDataSerializer(Serializer):
class CertificateDataSerializer(PassiveSerializer):
"""Get CertificateKeyPair's data"""
data = CharField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class CertificateGenerationSerializer(PassiveSerializer):
"""Certificate generation parameters"""
common_name = CharField()
subject_alt_name = CharField(
required=False, allow_blank=True, label=_("Subject-alt name")
)
validity_days = IntegerField(initial=365)
class CertificateKeyPairViewSet(ModelViewSet):
@ -89,6 +101,29 @@ class CertificateKeyPairViewSet(ModelViewSet):
queryset = CertificateKeyPair.objects.all()
serializer_class = CertificateKeyPairSerializer
@permission_required(None, ["authentik_crypto.add_certificatekeypair"])
@swagger_auto_schema(
request_body=CertificateGenerationSerializer(),
responses={200: CertificateKeyPairSerializer},
)
@action(detail=False, methods=["POST"])
def generate(self, request: Request) -> Response:
"""Generate a new, self-signed certificate-key pair"""
data = CertificateGenerationSerializer(data=request.data)
if not data.is_valid():
return Response(data.errors, status=400)
builder = CertificateBuilder()
builder.common_name = data.validated_data["common_name"]
builder.build(
subject_alt_names=data.validated_data.get("subject_alt_name", "").split(
","
),
validity_days=int(data.validated_data["validity_days"]),
)
instance = builder.save()
serializer = self.get_serializer(instance)
return Response(serializer.data)
@swagger_auto_schema(responses={200: CertificateDataSerializer(many=False)})
@action(detail=True)
# pylint: disable=invalid-name, unused-argument

View File

@ -1,64 +0,0 @@
"""authentik Crypto forms"""
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import load_pem_x509_certificate
from django import forms
from django.utils.translation import gettext_lazy as _
from authentik.crypto.models import CertificateKeyPair
class CertificateKeyPairGenerateForm(forms.Form):
"""CertificateKeyPair generation form"""
common_name = forms.CharField()
subject_alt_name = forms.CharField(required=False, label=_("Subject-alt name"))
validity_days = forms.IntegerField(initial=365)
class CertificateKeyPairForm(forms.ModelForm):
"""CertificateKeyPair Form"""
def clean_certificate_data(self):
"""Verify that input is a valid PEM x509 Certificate"""
certificate_data = self.cleaned_data["certificate_data"]
try:
load_pem_x509_certificate(
certificate_data.encode("utf-8"), default_backend()
)
except ValueError:
raise forms.ValidationError("Unable to load certificate.")
return certificate_data
def clean_key_data(self):
"""Verify that input is a valid PEM RSA Key"""
key_data = self.cleaned_data["key_data"]
# Since this field is optional, data can be empty.
if key_data != "":
try:
load_pem_private_key(
str.encode("\n".join([x.strip() for x in key_data.split("\n")])),
password=None,
backend=default_backend(),
)
except ValueError:
raise forms.ValidationError("Unable to load private key.")
return key_data
class Meta:
model = CertificateKeyPair
fields = [
"name",
"certificate_data",
"key_data",
]
widgets = {
"name": forms.TextInput(),
"certificate_data": forms.Textarea(attrs={"class": "monospaced"}),
"key_data": forms.Textarea(attrs={"class": "monospaced"}),
}
labels = {
"certificate_data": _("Certificate"),
"key_data": _("Private Key"),
}

View File

@ -2,31 +2,12 @@
from django.test import TestCase
from authentik.crypto.api import CertificateKeyPairSerializer
from authentik.crypto.forms import CertificateKeyPairForm
from authentik.crypto.models import CertificateKeyPair
class TestCrypto(TestCase):
"""Test Crypto validation"""
def test_form(self):
"""Test form validation"""
keypair = CertificateKeyPair.objects.first()
self.assertTrue(
CertificateKeyPairForm(
{
"name": keypair.name,
"certificate_data": keypair.certificate_data,
"key_data": keypair.key_data,
}
).is_valid()
)
self.assertFalse(
CertificateKeyPairForm(
{"name": keypair.name, "certificate_data": "test", "key_data": "test"}
).is_valid()
)
def test_serializer(self):
"""Test API Validation"""
keypair = CertificateKeyPair.objects.first()

View File

@ -2,7 +2,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 drf_yasg.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

View File

@ -1,6 +1,6 @@
"""NotificationTransport API Views"""
from django.http.response import Http404
from drf_yasg2.utils import no_body, swagger_auto_schema
from drf_yasg.utils import no_body, swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.request import Request
@ -36,6 +36,7 @@ class NotificationTransportSerializer(ModelSerializer):
"mode",
"mode_verbose",
"webhook_url",
"send_once",
]

View File

@ -1,48 +0,0 @@
"""authentik events NotificationTransport forms"""
from django import forms
from django.utils.translation import gettext_lazy as _
from authentik.events.models import NotificationRule, NotificationTransport
class NotificationTransportForm(forms.ModelForm):
"""NotificationTransport Form"""
class Meta:
model = NotificationTransport
fields = [
"name",
"mode",
"webhook_url",
"send_once",
]
widgets = {
"name": forms.TextInput(),
"webhook_url": forms.TextInput(),
}
labels = {
"webhook_url": _("Webhook URL"),
}
help_texts = {
"webhook_url": _(
("Only required when the Generic or Slack Webhook is used.")
),
}
class NotificationRuleForm(forms.ModelForm):
"""NotificationRule Form"""
class Meta:
model = NotificationRule
fields = [
"name",
"group",
"transports",
"severity",
]
widgets = {
"name": forms.TextInput(),
}

View File

@ -76,7 +76,9 @@ class AuditMiddleware:
user: User, request: HttpRequest, sender, instance: Model, **_
):
"""Signal handler for all object's pre_delete"""
if isinstance(instance, (Event, Notification, UserObjectPermission)):
if isinstance(
instance, (Event, Notification, UserObjectPermission)
): # pragma: no cover
return
EventNewThread(

View File

@ -3,11 +3,14 @@ from dataclasses import dataclass
from django.core.cache import cache
from django.db.models import Model
from django.http.response import JsonResponse
from drf_yasg2 import openapi
from drf_yasg2.utils import no_body, swagger_auto_schema
from django.http.response import HttpResponseBadRequest, JsonResponse
from django.urls import reverse
from django.utils.translation import gettext as _
from drf_yasg import openapi
from drf_yasg.utils import no_body, swagger_auto_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import (
@ -20,11 +23,15 @@ 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.core.api.utils import CacheSerializer, LinkSerializer
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import Flow
from authentik.flows.planner import cache_key
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner, cache_key
from authentik.flows.transfer.common import DataclassEncoder
from authentik.flows.transfer.exporter import FlowExporter
from authentik.flows.transfer.importer import FlowImporter
from authentik.flows.views import SESSION_KEY_PLAN
from authentik.lib.views import bad_request_message
LOGGER = get_logger()
@ -56,7 +63,7 @@ class FlowSerializer(ModelSerializer):
class FlowDiagramSerializer(Serializer):
"""response of the flow's /diagram/ action"""
"""response of the flow's diagram action"""
diagram = CharField(read_only=True)
@ -88,14 +95,14 @@ class FlowViewSet(ModelViewSet):
search_fields = ["name", "slug", "designation", "title"]
filterset_fields = ["flow_uuid", "name", "slug", "designation"]
@permission_required("authentik_flows.view_flow_cache")
@permission_required(None, ["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")
@permission_required(None, ["authentik_flows.clear_flow_cache"])
@swagger_auto_schema(
request_body=no_body,
responses={204: "Successfully cleared cache", 400: "Bad request"},
@ -108,7 +115,61 @@ class FlowViewSet(ModelViewSet):
LOGGER.debug("Cleared flow cache", keys=len(keys))
return Response(status=204)
@permission_required("authentik_flows.export_flow")
@permission_required(
None,
[
"authentik_flows.add_flow",
"authentik_flows.change_flow",
"authentik_flows.add_flowstagebinding",
"authentik_flows.change_flowstagebinding",
"authentik_flows.add_stage",
"authentik_flows.change_stage",
"authentik_policies.add_policy",
"authentik_policies.change_policy",
"authentik_policies.add_policybinding",
"authentik_policies.change_policybinding",
"authentik_stages_prompt.add_prompt",
"authentik_stages_prompt.change_prompt",
],
)
@swagger_auto_schema(
request_body=no_body,
manual_parameters=[
openapi.Parameter(
name="file",
in_=openapi.IN_FORM,
type=openapi.TYPE_FILE,
required=True,
)
],
responses={204: "Successfully imported flow", 400: "Bad request"},
)
@action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,))
def import_flow(self, request: Request) -> Response:
"""Import flow from .akflow file"""
file = request.FILES.get("file", None)
if not file:
return HttpResponseBadRequest()
importer = FlowImporter(file.read().decode())
valid = importer.validate()
if not valid:
return HttpResponseBadRequest()
successful = importer.apply()
if not successful:
return Response(status=204)
return HttpResponseBadRequest()
@permission_required(
"authentik_flows.export_flow",
[
"authentik_flows.view_flow",
"authentik_flows.view_flowstagebinding",
"authentik_flows.view_stage",
"authentik_policies.view_policy",
"authentik_policies.view_policybinding",
"authentik_stages_prompt.view_prompt",
],
)
@swagger_auto_schema(
responses={
"200": openapi.Response(
@ -194,3 +255,57 @@ class FlowViewSet(ModelViewSet):
)
diagram = "\n".join([str(x) for x in header + body + footer])
return Response({"diagram": diagram})
@permission_required("authentik_flows.change_flow")
@swagger_auto_schema(
request_body=no_body,
manual_parameters=[
openapi.Parameter(
name="file",
in_=openapi.IN_FORM,
type=openapi.TYPE_FILE,
required=True,
)
],
responses={200: "Success"},
)
@action(detail=True, methods=["POST"], parser_classes=(MultiPartParser,))
# pylint: disable=unused-argument
def set_background(self, request: Request, slug: str):
"""Set Flow background"""
app: Flow = self.get_object()
icon = request.FILES.get("file", None)
if not icon:
return HttpResponseBadRequest()
app.background = icon
app.save()
return Response({})
@swagger_auto_schema(
responses={200: LinkSerializer(many=False)},
)
@action(detail=True)
# pylint: disable=unused-argument
def execute(self, request: Request, slug: str):
"""Execute flow for current user"""
flow: Flow = self.get_object()
planner = FlowPlanner(flow)
planner.use_cache = False
try:
plan = planner.plan(self.request, {PLAN_CONTEXT_PENDING_USER: request.user})
self.request.session[SESSION_KEY_PLAN] = plan
except FlowNonApplicableException as exc:
return bad_request_message(
request,
_(
"Flow not applicable to current user/request: %(messages)s"
% {"messages": str(exc)}
),
)
return Response(
{
"link": request._request.build_absolute_uri(
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
)
}
)

View File

@ -2,7 +2,7 @@
from typing import Iterable
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.request import Request

View File

@ -2,11 +2,11 @@
from enum import Enum
from typing import TYPE_CHECKING, Optional
from django.db.models.base import Model
from django.http import JsonResponse
from rest_framework.fields import ChoiceField, DictField
from rest_framework.serializers import CharField, Serializer
from rest_framework.serializers import CharField
from authentik.core.api.utils import PassiveSerializer
from authentik.flows.transfer.common import DataclassEncoder
if TYPE_CHECKING:
@ -21,20 +21,14 @@ class ChallengeTypes(Enum):
REDIRECT = "redirect"
class ErrorDetailSerializer(Serializer):
class ErrorDetailSerializer(PassiveSerializer):
"""Serializer for rest_framework's error messages"""
string = CharField()
code = CharField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class Challenge(Serializer):
class Challenge(PassiveSerializer):
"""Challenge that gets sent to the client based on which stage
is currently active"""
@ -49,12 +43,6 @@ class Challenge(Serializer):
child=ErrorDetailSerializer(many=True), allow_empty=True, required=False
)
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class RedirectChallenge(Challenge):
"""Challenge type to redirect the client"""
@ -81,20 +69,14 @@ class AccessDeniedChallenge(Challenge):
error_message = CharField(required=False)
class PermissionSerializer(Serializer):
class PermissionSerializer(PassiveSerializer):
"""Permission used for consent"""
name = CharField()
id = CharField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class ChallengeResponse(Serializer):
class ChallengeResponse(PassiveSerializer):
"""Base class for all challenge responses"""
stage: Optional["StageView"]
@ -103,12 +85,6 @@ class ChallengeResponse(Serializer):
self.stage = kwargs.pop("stage", None)
super().__init__(instance=instance, data=data, **kwargs)
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class HttpChallengeResponse(JsonResponse):
"""Subclass of JsonResponse that uses the `DataclassEncoder`"""

View File

@ -1,35 +1,10 @@
"""Flow and Stage forms"""
from django import forms
from django.core.validators import FileExtensionValidator
from django.forms import ValidationError
from django.utils.translation import gettext_lazy as _
from authentik.flows.models import Flow, FlowStageBinding, Stage
from authentik.flows.transfer.importer import FlowImporter
from authentik.flows.models import FlowStageBinding, Stage
from authentik.lib.widgets import GroupedModelChoiceField
class FlowForm(forms.ModelForm):
"""Flow Form"""
class Meta:
model = Flow
fields = [
"name",
"title",
"slug",
"designation",
"background",
]
widgets = {
"name": forms.TextInput(),
"title": forms.TextInput(),
"background": forms.FileInput(),
}
class FlowStageBindingForm(forms.ModelForm):
"""FlowStageBinding Form"""
@ -56,20 +31,3 @@ class FlowStageBindingForm(forms.ModelForm):
widgets = {
"name": forms.TextInput(),
}
class FlowImportForm(forms.Form):
"""Form used for flow importing"""
flow = forms.FileField(
validators=[FileExtensionValidator(allowed_extensions=["akflow"])]
)
def clean_flow(self):
"""Check if the flow is valid and rewind the file to the start"""
flow = self.cleaned_data["flow"].read()
valid = FlowImporter(flow.decode()).validate()
if not valid:
raise ValidationError(_("Flow invalid."))
self.cleaned_data["flow"].seek(0)
return self.cleaned_data["flow"]

View File

@ -10,8 +10,8 @@ from django.template.response import TemplateResponse
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.generic import View
from drf_yasg2 import openapi
from drf_yasg2.utils import no_body, swagger_auto_schema
from drf_yasg import openapi
from drf_yasg.utils import no_body, swagger_auto_schema
from rest_framework.permissions import AllowAny
from rest_framework.views import APIView
from structlog.stdlib import BoundLogger, get_logger

View File

@ -1,6 +1,5 @@
"""Managed Object models"""
from django.db import models
from django.db.models import QuerySet
from django.utils.translation import gettext_lazy as _
@ -22,10 +21,6 @@ class ManagedModel(models.Model):
unique=True,
)
def managed_objects(self) -> QuerySet:
"""Get all objects which are managed"""
return self.objects.exclude(managed__isnull=True)
class Meta:
abstract = True

View File

@ -1,17 +1,20 @@
"""Outpost API Views"""
from dataclasses import asdict
from django.db.models.base import Model
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import BooleanField, CharField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.api.utils import (
MetaNameSerializer,
PassiveSerializer,
TypeCreateSerializer,
)
from authentik.lib.templatetags.authentik_utils import verbose_name
from authentik.lib.utils.reflection import all_subclasses
from authentik.outposts.models import (
@ -43,18 +46,12 @@ class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer):
]
class ServiceConnectionStateSerializer(Serializer):
class ServiceConnectionStateSerializer(PassiveSerializer):
"""Serializer for Service connection state"""
healthy = BooleanField(read_only=True)
version = CharField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class ServiceConnectionViewSet(ModelViewSet):
"""ServiceConnection Viewset"""

View File

@ -1,15 +1,15 @@
"""Outpost API Views"""
from django.db.models import Model
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import BooleanField, CharField, DateTimeField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import JSONField, ModelSerializer, Serializer
from rest_framework.serializers import JSONField, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.providers import ProviderSerializer
from authentik.outposts.models import Outpost
from authentik.core.api.utils import PassiveSerializer
from authentik.outposts.models import Outpost, default_outpost_config
class OutpostSerializer(ModelSerializer):
@ -32,7 +32,13 @@ class OutpostSerializer(ModelSerializer):
]
class OutpostHealthSerializer(Serializer):
class OutpostDefaultConfigSerializer(PassiveSerializer):
"""Global default outpost config"""
config = JSONField(read_only=True)
class OutpostHealthSerializer(PassiveSerializer):
"""Outpost health status"""
last_seen = DateTimeField(read_only=True)
@ -40,12 +46,6 @@ class OutpostHealthSerializer(Serializer):
version_should = CharField(read_only=True)
version_outdated = BooleanField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class OutpostViewSet(ModelViewSet):
"""Outpost Viewset"""
@ -78,3 +78,10 @@ class OutpostViewSet(ModelViewSet):
}
)
return Response(OutpostHealthSerializer(states, many=True).data)
@swagger_auto_schema(responses={200: OutpostDefaultConfigSerializer(many=False)})
@action(detail=False, methods=["GET"])
def default_settings(self, request: Request) -> Response:
"""Global default outpost config"""
host = self.request.build_absolute_uri("/")
return Response({"config": default_outpost_config(host)})

View File

@ -80,9 +80,9 @@ class OutpostType(models.TextChoices):
PROXY = "proxy"
def default_outpost_config():
def default_outpost_config(host: Optional[str] = None):
"""Get default outpost config"""
return asdict(OutpostConfig(authentik_host=""))
return asdict(OutpostConfig(authentik_host=host or ""))
@dataclass

View File

View File

@ -0,0 +1,80 @@
"""policy binding API Views"""
from django.core.exceptions import ObjectDoesNotExist
from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField
from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.core.api.groups import GroupSerializer
from authentik.policies.models import PolicyBinding, PolicyBindingModel
LOGGER = get_logger()
class PolicyBindingModelForeignKey(PrimaryKeyRelatedField):
"""rest_framework PrimaryKeyRelatedField which resolves
model_manager's InheritanceQuerySet"""
def use_pk_only_optimization(self):
return False
# pylint: disable=inconsistent-return-statements
def to_internal_value(self, data):
if self.pk_field is not None:
data = self.pk_field.to_internal_value(data)
try:
# Due to inheritance, a direct DB lookup for the primary key
# won't return anything. This is because the direct lookup
# checks the PK of PolicyBindingModel (for example),
# but we get given the Primary Key of the inheriting class
for model in self.get_queryset().select_subclasses().all().select_related():
if model.pk == data:
return model
# as a fallback we still try a direct lookup
return self.get_queryset().get_subclass(pk=data)
except ObjectDoesNotExist:
self.fail("does_not_exist", pk_value=data)
except (TypeError, ValueError):
self.fail("incorrect_type", data_type=type(data).__name__)
def to_representation(self, value):
correct_model = PolicyBindingModel.objects.get_subclass(pbm_uuid=value.pbm_uuid)
return correct_model.pk
class PolicyBindingSerializer(ModelSerializer):
"""PolicyBinding Serializer"""
# Because we're not interested in the PolicyBindingModel's PK but rather the subclasses PK,
# we have to manually declare this field
target = PolicyBindingModelForeignKey(
queryset=PolicyBindingModel.objects.select_subclasses(),
required=True,
)
group = GroupSerializer(required=False)
class Meta:
model = PolicyBinding
fields = [
"pk",
"policy",
"group",
"user",
"target",
"enabled",
"order",
"timeout",
]
depth = 2
class PolicyBindingViewSet(ModelViewSet):
"""PolicyBinding Viewset"""
queryset = PolicyBinding.objects.all().select_related(
"policy", "target", "group", "user"
)
serializer_class = PolicyBindingSerializer
filterset_fields = ["policy", "target", "enabled", "order", "timeout"]
search_fields = ["policy__name"]

View File

@ -0,0 +1,20 @@
"""Serializer for policy execution"""
from rest_framework.fields import BooleanField, CharField, JSONField, ListField
from rest_framework.relations import PrimaryKeyRelatedField
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User
class PolicyTestSerializer(PassiveSerializer):
"""Test policy execution for a user with context"""
user = PrimaryKeyRelatedField(queryset=User.objects.all())
context = JSONField(required=False)
class PolicyTestResultSerializer(PassiveSerializer):
"""result of a policy test"""
passing = BooleanField()
messages = ListField(child=CharField(), read_only=True)

View File

@ -1,19 +1,16 @@
"""policy API Views"""
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
from django.http.response import HttpResponseBadRequest
from django.urls import reverse
from drf_yasg2.utils import no_body, swagger_auto_schema
from drf_yasg.utils import no_body, swagger_auto_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework import mixins
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 (
ModelSerializer,
PrimaryKeyRelatedField,
SerializerMethodField,
)
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import GenericViewSet
from structlog.stdlib import get_logger
from authentik.api.decorators import permission_required
@ -25,42 +22,14 @@ from authentik.core.api.utils import (
)
from authentik.lib.templatetags.authentik_utils import verbose_name
from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel
from authentik.policies.api.exec import PolicyTestResultSerializer, PolicyTestSerializer
from authentik.policies.models import Policy, PolicyBinding
from authentik.policies.process import PolicyProcess
from authentik.policies.types import PolicyRequest
LOGGER = get_logger()
class PolicyBindingModelForeignKey(PrimaryKeyRelatedField):
"""rest_framework PrimaryKeyRelatedField which resolves
model_manager's InheritanceQuerySet"""
def use_pk_only_optimization(self):
return False
# pylint: disable=inconsistent-return-statements
def to_internal_value(self, data):
if self.pk_field is not None:
data = self.pk_field.to_internal_value(data)
try:
# Due to inheritance, a direct DB lookup for the primary key
# won't return anything. This is because the direct lookup
# checks the PK of PolicyBindingModel (for example),
# but we get given the Primary Key of the inheriting class
for model in self.get_queryset().select_subclasses().all().select_related():
if model.pk == data:
return model
# as a fallback we still try a direct lookup
return self.get_queryset().get_subclass(pk=data)
except ObjectDoesNotExist:
self.fail("does_not_exist", pk_value=data)
except (TypeError, ValueError):
self.fail("incorrect_type", data_type=type(data).__name__)
def to_representation(self, value):
correct_model = PolicyBindingModel.objects.get_subclass(pbm_uuid=value.pbm_uuid)
return correct_model.pk
class PolicySerializer(ModelSerializer, MetaNameSerializer):
"""Policy Serializer"""
@ -168,39 +137,32 @@ class PolicyViewSet(
cache.delete_many(keys)
return Response(status=204)
class PolicyBindingSerializer(ModelSerializer):
"""PolicyBinding Serializer"""
# Because we're not interested in the PolicyBindingModel's PK but rather the subclasses PK,
# we have to manually declare this field
target = PolicyBindingModelForeignKey(
queryset=PolicyBindingModel.objects.select_subclasses(),
required=True,
@permission_required("authentik_policies.view_policy")
@swagger_auto_schema(
request_body=PolicyTestSerializer(),
responses={200: PolicyTestResultSerializer()},
)
@action(detail=True, methods=["POST"])
def test(self, request: Request) -> Response:
"""Test policy"""
policy = self.get_object()
test_params = PolicyTestSerializer(request.data)
if not test_params.is_valid():
return Response(test_params.errors, status=400)
class Meta:
# User permission check, only allow policy testing for users that are readable
users = get_objects_for_user(request.user, "authentik_core.view_user").filter(
pk=test_params["user"]
)
if not users.exists():
raise PermissionDenied()
model = PolicyBinding
fields = [
"pk",
"policy",
"group",
"user",
"target",
"enabled",
"order",
"timeout",
]
depth = 2
p_request = PolicyRequest(users.first())
p_request.debug = True
p_request.set_http_request(self.request)
p_request.context = test_params.validated_data.get("context", {})
class PolicyBindingViewSet(ModelViewSet):
"""PolicyBinding Viewset"""
queryset = PolicyBinding.objects.all().select_related(
"policy", "target", "group", "user"
)
serializer_class = PolicyBindingSerializer
filterset_fields = ["policy", "target", "enabled", "order", "timeout"]
search_fields = ["policy__name"]
proc = PolicyProcess(PolicyBinding(policy=policy), p_request, None)
result = proc.execute()
response = PolicyTestResultSerializer(result)
return Response(response)

View File

@ -1,7 +1,7 @@
"""Dummy Policy API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.policies.api import PolicySerializer
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.dummy.models import DummyPolicy

View File

@ -1,7 +1,7 @@
"""Event Matcher Policy API"""
from rest_framework.viewsets import ModelViewSet
from authentik.policies.api import PolicySerializer
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.event_matcher.models import EventMatcherPolicy

View File

@ -1,7 +1,7 @@
"""Password Expiry Policy API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.policies.api import PolicySerializer
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.expiry.models import PasswordExpiryPolicy

View File

@ -1,7 +1,7 @@
"""Expression Policy API"""
from rest_framework.viewsets import ModelViewSet
from authentik.policies.api import PolicySerializer
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.expression.models import ExpressionPolicy

View File

@ -1,7 +1,7 @@
"""Source API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.policies.api import PolicySerializer
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.hibp.models import HaveIBeenPwendPolicy

View File

@ -95,7 +95,7 @@ class PolicyBinding(SerializerModel):
@property
def serializer(self) -> BaseSerializer:
from authentik.policies.api import PolicyBindingSerializer
from authentik.policies.api.bindings import PolicyBindingSerializer
return PolicyBindingSerializer

View File

@ -1,7 +1,7 @@
"""Password Policy API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.policies.api import PolicySerializer
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.password.models import PasswordPolicy

View File

@ -1,7 +1,7 @@
"""Source API Views"""
from rest_framework.viewsets import ModelViewSet
from authentik.policies.api import PolicySerializer
from authentik.policies.api.policies import PolicySerializer
from authentik.policies.reputation.models import (
IPReputation,
ReputationPolicy,

View File

@ -1,6 +1,6 @@
"""OAuth2Provider API Views"""
from django.urls import reverse
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField
from rest_framework.generics import get_object_or_404

View File

@ -1,5 +1,5 @@
"""ProxyProvider API Views"""
from drf_yasg2.utils import swagger_serializer_method
from drf_yasg.utils import swagger_serializer_method
from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.request import Request
from rest_framework.response import Response

View File

@ -1,5 +1,5 @@
"""SAMLProvider API Views"""
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField
from rest_framework.request import Request

View File

@ -126,7 +126,7 @@ INSTALLED_APPS = [
"authentik.stages.user_write.apps.AuthentikStageUserWriteConfig",
"rest_framework",
"django_filters",
"drf_yasg2",
"drf_yasg",
"guardian",
"django_prometheus",
"channels",
@ -137,6 +137,7 @@ INSTALLED_APPS = [
GUARDIAN_MONKEY_PATCH = False
SWAGGER_SETTINGS = {
"DEFAULT_AUTO_SCHEMA_CLASS": "authentik.api.schema.ErrorResponseAutoSchema",
"DEFAULT_INFO": "authentik.api.v2.urls.info",
"DEFAULT_PAGINATOR_INSPECTORS": [
"authentik.api.pagination_schema.PaginationInspector",
@ -437,7 +438,7 @@ _LOGGING_HANDLER_MAP = {
"kubernetes": "INFO",
"asyncio": "WARNING",
"aioredis": "WARNING",
"drf_yasg2.utils": "WARNING",
"drf_yasg.utils": "WARNING",
}
for handler_name, level in _LOGGING_HANDLER_MAP.items():
# pyright: reportGeneralTypeIssues=false

View File

@ -3,17 +3,16 @@ from datetime import datetime
from time import time
from django.core.cache import cache
from django.db.models.base import Model
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import DateTimeField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, Serializer
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.sources import SourceSerializer
from authentik.core.api.utils import MetaNameSerializer
from authentik.core.api.utils import MetaNameSerializer, PassiveSerializer
from authentik.sources.ldap.models import LDAPPropertyMapping, LDAPSource
@ -44,17 +43,11 @@ class LDAPSourceSerializer(SourceSerializer):
extra_kwargs = {"bind_password": {"write_only": True}}
class LDAPSourceSyncStatusSerializer(Serializer):
class LDAPSourceSyncStatusSerializer(PassiveSerializer):
"""LDAP Sync status"""
last_sync = DateTimeField(read_only=True)
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
class LDAPSourceViewSet(ModelViewSet):
"""LDAP Source Viewset"""

View File

@ -1,5 +1,5 @@
"""SAMLSource API Views"""
from drf_yasg2.utils import swagger_auto_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response

View File

@ -1,5 +1,4 @@
"""Validation stage challenge checking"""
from django.db.models import Model
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from django_otp import match_token
@ -7,7 +6,7 @@ from django_otp.models import Device
from django_otp.plugins.otp_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice
from rest_framework.fields import CharField, JSONField
from rest_framework.serializers import Serializer, ValidationError
from rest_framework.serializers import ValidationError
from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser
from webauthn.webauthn import (
AuthenticationRejectedException,
@ -15,24 +14,19 @@ from webauthn.webauthn import (
WebAuthnUserDataMissing,
)
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
from authentik.stages.authenticator_webauthn.utils import generate_challenge, get_origin
class DeviceChallenge(Serializer):
class DeviceChallenge(PassiveSerializer):
"""Single device challenge"""
device_class = CharField()
device_uid = CharField()
challenge = JSONField()
def create(self, validated_data: dict) -> Model:
raise NotImplementedError
def update(self, instance: Model, validated_data: dict) -> Model:
raise NotImplementedError
def get_challenge_for_device(request: HttpRequest, device: Device) -> dict:
"""Generate challenge for a single device"""

View File

@ -8,4 +8,3 @@ class AuthentikStageAuthenticatorWebAuthnConfig(AppConfig):
name = "authentik.stages.authenticator_webauthn"
label = "authentik_stages_authenticator_webauthn"
verbose_name = "authentik Stages.Authenticator.WebAuthn"
mountpoint = "-/user/authenticator/webauthn/"

View File

@ -1,8 +0,0 @@
"""WebAuthn urls"""
from django.urls import path
from authentik.stages.authenticator_webauthn.views import DeviceUpdateView
urlpatterns = [
path("devices/<int:pk>/update/", DeviceUpdateView.as_view(), name="device-update"),
]

View File

@ -1,25 +0,0 @@
"""webauthn views"""
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.messages.views import SuccessMessageMixin
from django.http.response import Http404
from django.utils.translation import gettext as _
from django.views.generic import UpdateView
from authentik.stages.authenticator_webauthn.forms import DeviceEditForm
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
class DeviceUpdateView(SuccessMessageMixin, LoginRequiredMixin, UpdateView):
"""Update device"""
model = WebAuthnDevice
form_class = DeviceEditForm
template_name = "generic/update.html"
success_url = "/"
success_message = _("Successfully updated Device")
def get_object(self) -> WebAuthnDevice:
device: WebAuthnDevice = super().get_object()
if device.user != self.request.user:
raise Http404
return device

View File

@ -3,16 +3,16 @@ from email.policy import Policy
from types import MethodType
from typing import Any, Callable, Iterator
from django.db.models.base import Model
from django.db.models.query import QuerySet
from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
from django.utils.translation import gettext_lazy as _
from guardian.shortcuts import get_anonymous_user
from rest_framework.fields import BooleanField, CharField, IntegerField
from rest_framework.serializers import Serializer, ValidationError
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import User
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
@ -26,7 +26,7 @@ LOGGER = get_logger()
PLAN_CONTEXT_PROMPT = "prompt_data"
class PromptSerializer(Serializer):
class PromptSerializer(PassiveSerializer):
"""Serializer for a single Prompt field"""
field_key = CharField()
@ -36,12 +36,6 @@ class PromptSerializer(Serializer):
placeholder = CharField()
order = IntegerField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class PromptChallenge(Challenge):
"""Initial challenge being sent, define fields"""

View File

@ -285,9 +285,9 @@ stages:
inputs:
script: |
docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
sudo chmod 777 -R api/
sudo chmod 777 -R web/api/
cd web
sudo chmod 777 -R api/
cd api && npm i && cd ..
npm i
npm run build
- task: CmdLine@2

File diff suppressed because it is too large Load Diff

View File

@ -98,7 +98,7 @@ class TestFlowsEnroll(SeleniumTestCase):
wait = WebDriverWait(interface_admin, self.wait_timeout)
wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-sidebar")))
self.driver.get(self.if_admin_url("authentik_core:user-details"))
self.driver.get(self.if_admin_url("/user"))
user = User.objects.get(username="foo")
self.assertEqual(user.username, "foo")
@ -198,7 +198,7 @@ class TestFlowsEnroll(SeleniumTestCase):
)
wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-sidebar")))
self.driver.get(self.if_admin_url("authentik_core:user-details"))
self.driver.get(self.if_admin_url("/user"))
self.assert_user(User.objects.get(username="foo"))

View File

@ -15,6 +15,7 @@ from selenium.webdriver.support.wait import WebDriverWait
from structlog.stdlib import get_logger
from yaml import safe_dump
from authentik.core.models import User
from authentik.flows.models import Flow
from authentik.providers.oauth2.generators import (
generate_client_id,
@ -160,19 +161,9 @@ class TestSourceOAuth2(SeleniumTestCase):
# Wait until we've logged in
self.wait_for_url(self.if_admin_url("/library"))
self.driver.get(self.url("authentik_core:user-details"))
self.driver.get(self.if_admin_url("/user"))
self.assertEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo"
)
self.assertEqual(
self.driver.find_element(By.ID, "id_name").get_attribute("value"),
"admin",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
"admin@example.com",
)
self.assert_user(User(username="foo", name="admin", email="admin@example.com"))
@retry()
@apply_migration("authentik_core", "0003_default_user")
@ -255,19 +246,9 @@ class TestSourceOAuth2(SeleniumTestCase):
# Wait until we've logged in
self.wait_for_url(self.if_admin_url("/library"))
self.driver.get(self.url("authentik_core:user-details"))
self.driver.get(self.if_admin_url("/user"))
self.assertEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"), "foo"
)
self.assertEqual(
self.driver.find_element(By.ID, "id_name").get_attribute("value"),
"admin",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
"admin@example.com",
)
self.assert_user(User(username="foo", name="admin", email="admin@example.com"))
@skipUnless(platform.startswith("linux"), "requires local docker")
@ -359,17 +340,8 @@ class TestSourceOAuth1(SeleniumTestCase):
sleep(2)
# Wait until we've logged in
self.wait_for_url(self.if_admin_url("/library"))
self.driver.get(self.url("authentik_core:user-details"))
self.driver.get(self.if_admin_url("/user"))
self.assertEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"),
"example-user",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_name").get_attribute("value"),
"test name",
)
self.assertEqual(
self.driver.find_element(By.ID, "id_email").get_attribute("value"),
"foo@example.com",
self.assert_user(
User(username="example-user", name="test name", email="foo@example.com")
)

View File

@ -5,12 +5,14 @@ from typing import Any, Optional
from unittest.case import skipUnless
from docker.types import Healthcheck
from guardian.utils import get_anonymous_user
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as ec
from selenium.webdriver.support.wait import WebDriverWait
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
@ -153,11 +155,12 @@ class TestSourceSAML(SeleniumTestCase):
# Wait until we're logged in
self.wait_for_url(self.if_admin_url("/library"))
self.driver.get(self.url("authentik_core:user-details"))
self.driver.get(self.if_admin_url("/user"))
# Wait until we've loaded the user info page
self.assertNotEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"), ""
self.assert_user(
User.objects.exclude(username="akadmin")
.exclude(pk=get_anonymous_user().pk)
.first()
)
@retry()
@ -233,11 +236,12 @@ class TestSourceSAML(SeleniumTestCase):
# Wait until we're logged in
self.wait_for_url(self.if_admin_url("/library"))
self.driver.get(self.url("authentik_core:user-details"))
self.driver.get(self.if_admin_url("/user"))
# Wait until we've loaded the user info page
self.assertNotEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"), ""
self.assert_user(
User.objects.exclude(username="akadmin")
.exclude(pk=get_anonymous_user().pk)
.first()
)
@retry()
@ -300,9 +304,10 @@ class TestSourceSAML(SeleniumTestCase):
# Wait until we're logged in
self.wait_for_url(self.if_admin_url("/library"))
self.driver.get(self.url("authentik_core:user-details"))
self.driver.get(self.if_admin_url("/user"))
# Wait until we've loaded the user info page
self.assertNotEqual(
self.driver.find_element(By.ID, "id_username").get_attribute("value"), ""
self.assert_user(
User.objects.exclude(username="akadmin")
.exclude(pk=get_anonymous_user().pk)
.first()
)

143
web/package-lock.json generated
View File

@ -133,6 +133,139 @@
"resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.90.5.tgz",
"integrity": "sha512-Fe0C8UkzSjtacQ+fHXlFB/LHzrv/c2K4z479C6dboOgkGQE1FyB0wt1NBfxij0D++rhOy04OOYdE+Tr0JSlZKw=="
},
"@polymer/font-roboto": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@polymer/font-roboto/-/font-roboto-3.0.2.tgz",
"integrity": "sha512-tx5TauYSmzsIvmSqepUPDYbs4/Ejz2XbZ1IkD7JEGqkdNUJlh+9KU85G56Tfdk/xjEZ8zorFfN09OSwiMrIQWA=="
},
"@polymer/iron-a11y-announcer": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.1.0.tgz",
"integrity": "sha512-lc5i4NKB8kSQHH0Hwu8WS3ym93m+J69OHJWSSBxwd17FI+h2wmgxDzeG9LI4ojMMck17/uc2pLe7g/UHt5/K/A==",
"requires": {
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-a11y-keys-behavior": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/iron-a11y-keys-behavior/-/iron-a11y-keys-behavior-3.0.1.tgz",
"integrity": "sha512-lnrjKq3ysbBPT/74l0Fj0U9H9C35Tpw2C/tpJ8a+5g8Y3YJs1WSZYnEl1yOkw6sEyaxOq/1DkzH0+60gGu5/PQ==",
"requires": {
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-ajax": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/iron-ajax/-/iron-ajax-3.0.1.tgz",
"integrity": "sha512-7+TPEAfWsRdhj1Y8UeF1759ktpVu+c3sG16rJiUC3wF9+woQ9xI1zUm2d59i7Yc3aDEJrR/Q8Y262KlOvyGVNg==",
"requires": {
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-autogrow-textarea": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@polymer/iron-autogrow-textarea/-/iron-autogrow-textarea-3.0.3.tgz",
"integrity": "sha512-5r0VkWrIlm0JIp5E5wlnvkw7slK72lFRZXncmrsLZF+6n1dg2rI8jt7xpFzSmUWrqpcyXwyKaGaDvUjl3j4JLA==",
"requires": {
"@polymer/iron-behaviors": "^3.0.0-pre.26",
"@polymer/iron-flex-layout": "^3.0.0-pre.26",
"@polymer/iron-validatable-behavior": "^3.0.0-pre.26",
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-behaviors": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/iron-behaviors/-/iron-behaviors-3.0.1.tgz",
"integrity": "sha512-IMEwcv1lhf1HSQxuyWOUIL0lOBwmeaoSTpgCJeP9IBYnuB1SPQngmfRuHKgK6/m9LQ9F9miC7p3HeQQUdKAE0w==",
"requires": {
"@polymer/iron-a11y-keys-behavior": "^3.0.0-pre.26",
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-flex-layout": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/iron-flex-layout/-/iron-flex-layout-3.0.1.tgz",
"integrity": "sha512-7gB869czArF+HZcPTVSgvA7tXYFze9EKckvM95NB7SqYF+NnsQyhoXgKnpFwGyo95lUjUW9TFDLUwDXnCYFtkw==",
"requires": {
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-form": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/iron-form/-/iron-form-3.0.1.tgz",
"integrity": "sha512-JwSQXHjYALsytCeBkXlY8aRwqgZuYIqzOk3iHuugb1RXOdZ7MZHyJhMDVBbscHjxqPKu/KaVzAjrcfwNNafzEA==",
"requires": {
"@polymer/iron-ajax": "^3.0.0-pre.26",
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-form-element-behavior": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/iron-form-element-behavior/-/iron-form-element-behavior-3.0.1.tgz",
"integrity": "sha512-G/e2KXyL5AY7mMjmomHkGpgS0uAf4ovNpKhkuUTRnMuMJuf589bKqE85KN4ovE1Tzhv2hJoh/igyD6ekHiYU1A==",
"requires": {
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-input": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/iron-input/-/iron-input-3.0.1.tgz",
"integrity": "sha512-WLx13kEcbH9GKbj9+pWR6pbJkA5kxn3796ynx6eQd2rueMyUfVTR3GzOvadBKsciUuIuzrxpBWZ2+3UcueVUQQ==",
"requires": {
"@polymer/iron-a11y-announcer": "^3.0.0-pre.26",
"@polymer/iron-validatable-behavior": "^3.0.0-pre.26",
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-meta": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/iron-meta/-/iron-meta-3.0.1.tgz",
"integrity": "sha512-pWguPugiLYmWFV9UWxLWzZ6gm4wBwQdDy4VULKwdHCqR7OP7u98h+XDdGZsSlDPv6qoryV/e3tGHlTIT0mbzJA==",
"requires": {
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/iron-validatable-behavior": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/iron-validatable-behavior/-/iron-validatable-behavior-3.0.1.tgz",
"integrity": "sha512-wwpYh6wOa4fNI+jH5EYKC7TVPYQ2OfgQqocWat7GsNWcsblKYhLYbwsvEY5nO0n2xKqNfZzDLrUom5INJN7msQ==",
"requires": {
"@polymer/iron-meta": "^3.0.0-pre.26",
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/paper-input": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@polymer/paper-input/-/paper-input-3.2.1.tgz",
"integrity": "sha512-6ghgwQKM6mS0hAQxQqj+tkeEY1VUBqAsrasAm8V5RpNcfSWQC/hhRFxU0beGuKTAhndzezDzWYP6Zz4b8fExGg==",
"requires": {
"@polymer/iron-a11y-keys-behavior": "^3.0.0-pre.26",
"@polymer/iron-autogrow-textarea": "^3.0.0-pre.26",
"@polymer/iron-behaviors": "^3.0.0-pre.26",
"@polymer/iron-form-element-behavior": "^3.0.0-pre.26",
"@polymer/iron-input": "^3.0.0-pre.26",
"@polymer/paper-styles": "^3.0.0-pre.26",
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/paper-styles": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@polymer/paper-styles/-/paper-styles-3.0.1.tgz",
"integrity": "sha512-y6hmObLqlCx602TQiSBKHqjwkE7xmDiFkoxdYGaNjtv4xcysOTdVJsDR/R9UHwIaxJ7gHlthMSykir1nv78++g==",
"requires": {
"@polymer/font-roboto": "^3.0.1",
"@polymer/iron-flex-layout": "^3.0.0-pre.26",
"@polymer/polymer": "^3.0.0"
}
},
"@polymer/polymer": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/@polymer/polymer/-/polymer-3.4.1.tgz",
"integrity": "sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==",
"requires": {
"@webcomponents/shadycss": "^1.9.1"
}
},
"@rollup/plugin-typescript": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-8.2.1.tgz",
@ -547,6 +680,11 @@
"eslint-visitor-keys": "^2.0.0"
}
},
"@webcomponents/shadycss": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/@webcomponents/shadycss/-/shadycss-1.10.2.tgz",
"integrity": "sha512-9Iseu8bRtecb0klvv+WXZOVZatsRkbaH7M97Z+f+Pt909R4lDfgUODAnra23DOZTpeMTAkVpf4m/FZztN7Ox1A=="
},
"acorn": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
@ -3948,6 +4086,11 @@
"integrity": "sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ==",
"dev": true
},
"yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="
},
"yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",

View File

@ -12,6 +12,8 @@
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.3",
"@patternfly/patternfly": "^4.90.5",
"@polymer/iron-form": "^3.0.1",
"@polymer/paper-input": "^3.2.1",
"@sentry/browser": "^6.2.3",
"@sentry/tracing": "^6.2.3",
"@types/chart.js": "^2.9.31",
@ -31,7 +33,8 @@
"rollup-plugin-cssimport": "^1.0.2",
"rollup-plugin-external-globals": "^0.6.1",
"tslib": "^2.1.0",
"webcomponent-qr-code": "^1.0.5"
"webcomponent-qr-code": "^1.0.5",
"yaml": "^1.10.2"
},
"devDependencies": {
"@rollup/plugin-typescript": "^8.2.1",

View File

@ -1,16 +1,3 @@
export interface QueryArguments {
page?: number;
page_size?: number;
[key: string]: number | string | boolean | undefined | null;
}
export interface BaseInheritanceModel {
objectType: string;
verboseName: string;
verboseNamePlural: string;
}
export interface AKPagination {
next?: number;
previous?: number;

View File

@ -1,13 +1,5 @@
export class AdminURLManager {
static applications(rest: string): string {
return `/administration/applications/${rest}`;
}
static cryptoCertificates(rest: string): string {
return `/administration/crypto/certificates/${rest}`;
}
static policies(rest: string): string {
return `/administration/policies/${rest}`;
}
@ -24,18 +16,10 @@ export class AdminURLManager {
return `/administration/property-mappings/${rest}`;
}
static outposts(rest: string): string {
return `/administration/outposts/${rest}`;
}
static outpostServiceConnections(rest: string): string {
return `/administration/outpost_service_connections/${rest}`;
}
static flows(rest: string): string {
return `/administration/flows/${rest}`;
}
static stages(rest: string): string {
return `/administration/stages/${rest}`;
}
@ -60,21 +44,6 @@ export class AdminURLManager {
return `/administration/tokens/${rest}`;
}
static eventRules(rest: string): string {
return `/administration/events/rules/${rest}`;
}
static eventTransports(rest: string): string {
return `/administration/events/transports/${rest}`;
}
static users(rest: string): string {
return `/administration/users/${rest}`;
}
static groups(rest: string): string {
return `/administration/groups/${rest}`;
}
}
export class UserURLManager {
@ -105,6 +74,10 @@ export class AppURLManager {
export class FlowURLManager {
static defaultUnenrollment(): string {
return "/flows/-/default/unenrollment/";
}
static configure(stageUuid: string, rest: string): string {
return `/flows/-/configure/${stageUuid}/${rest}`;
}

View File

@ -88,6 +88,7 @@ body {
@media (prefers-color-scheme: dark) {
:root {
--ak-accent: #fd4b2d;
--ak-dark-foreground: #fafafa;
--ak-dark-foreground-darker: #bebebe;
--ak-dark-foreground-link: #5a5cb9;
@ -100,6 +101,15 @@ body {
--pf-c-page__main-section--m-light--BackgroundColor: var(--ak-dark-background-darker);
--pf-global--link--Color: var(--ak-dark-foreground-link);
}
paper-input {
/* --paper-input-container-input-color: var(--ak-dark-foreground); */
--primary-text-color: var(--ak-dark-foreground);
}
paper-checkbox {
--primary-text-color: var(--ak-dark-foreground);
}
/* Global page background colour */
.pf-c-page {
--pf-c-page--BackgroundColor: var(--ak-dark-background);

View File

@ -9,3 +9,4 @@ export const EVENT_REFRESH = "ak-refresh";
export const EVENT_NOTIFICATION_TOGGLE = "ak-notification-toggle";
export const EVENT_SIDEBAR_TOGGLE = "ak-sidebar-toggle";
export const EVENT_API_DRAWER_REFRESH = "ak-api-drawer-refresh";
export const TITLE_SUFFIX = "authentik";

View File

@ -1,4 +1,5 @@
import { customElement, LitElement, property } from "lit-element";
import { CSSResult, customElement, html, LitElement, property, TemplateResult } from "lit-element";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import CodeMirror from "codemirror";
import "codemirror/addon/display/autorefresh";
@ -6,6 +7,9 @@ import "codemirror/mode/xml/xml.js";
import "codemirror/mode/yaml/yaml.js";
import "codemirror/mode/javascript/javascript.js";
import "codemirror/mode/python/python.js";
import CodeMirrorStyle from "codemirror/lib/codemirror.css";
import CodeMirrorTheme from "codemirror/theme/monokai.css";
import { ifDefined } from "lit-html/directives/if-defined";
@customElement("ak-codemirror")
export class CodeMirrorTextarea extends LitElement {
@ -15,14 +19,20 @@ export class CodeMirrorTextarea extends LitElement {
@property()
mode = "yaml";
@property()
name?: string;
@property()
value?: string;
editor?: CodeMirror.EditorFromTextArea;
createRenderRoot() : ShadowRoot | Element {
return this;
static get styles(): CSSResult[] {
return [PFForm, CodeMirrorStyle, CodeMirrorTheme];
}
firstUpdated(): void {
const textarea = this.querySelector("textarea");
const textarea = this.shadowRoot?.querySelector("textarea");
if (!textarea) {
return;
}
@ -37,4 +47,8 @@ export class CodeMirrorTextarea extends LitElement {
this.editor?.save();
});
}
render(): TemplateResult {
return html`<textarea class="pf-c-form-control" name=${ifDefined(this.name)}>${this.value || ""}</textarea>`;
}
}

View File

@ -0,0 +1,37 @@
import { css, CSSResult, customElement, html, LitElement, TemplateResult } from "lit-element";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import AKGlobal from "../authentik.css";
@customElement("ak-divider")
export class Divider extends LitElement {
static get styles(): CSSResult[] {
return [PFBase, AKGlobal, css`
.separator {
display: flex;
align-items: center;
text-align: center;
}
.separator::before,
.separator::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--pf-global--Color--100);
}
.separator:not(:empty)::before {
margin-right: .25em;
}
.separator:not(:empty)::after {
margin-left: .25em;
}
`];
}
render(): TemplateResult {
return html`<div class="separator"><slot></slot></div>`;
}
}

View File

@ -23,8 +23,7 @@ export class ActionButton extends SpinnerButton {
this.setLoading();
this.apiRequest().then(() => {
this.setDone(SUCCESS_CLASS);
})
.catch((e: Error | Response) => {
}).catch((e: Error | Response) => {
if (e instanceof Error) {
showMessage({
level: MessageLevel.error,

Some files were not shown because too many files have changed in this diff Show More