Compare commits

...

19 Commits

Author SHA1 Message Date
2739376a2a release: 2023.5.4 2023-06-22 21:45:33 +02:00
152121175b bump web api client
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-22 21:33:02 +02:00
1d57a258f3 ATH-01-012: escape quotation marks
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-19 13:48:08 +02:00
f15cac39c8 ATH-01-014: save authenticator validation state in flow context
Signed-off-by: Jens Langhammer <jens@goauthentik.io>

bugfixes

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-19 13:48:05 +02:00
ce77d82b24 ATH-01-010: rework
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-19 13:48:03 +02:00
c3fe57197d ATH-01-009: migrate impersonation to use API
Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	authentik/core/urls.py
#	web/src/admin/AdminInterface.ts
#	web/src/admin/users/RelatedUserList.ts
#	web/src/admin/users/UserListPage.ts
#	web/src/admin/users/UserViewPage.ts
#	web/src/user/UserInterface.ts

# Conflicts:
#	authentik/core/urls.py
2023-06-19 13:47:53 +02:00
267938d435 ATH-01-005: use hmac.compare_digest for secret_key authentication
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-19 13:47:11 +02:00
6a7c2e0662 ATH-01-003 / ATH-01-012: disable htmlLabels in mermaid
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-19 13:47:09 +02:00
5336afb1b4 ATH-01-004: remove env from admin system endpoint
this endpoint already required admin access, but for debugging the env variables are used very little

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-19 13:47:06 +02:00
9bb44055a3 ATH-01-008: fix web forms not submitting correctly when pressing enter
When submitting some forms with the Enter key instead of clicking "Confirm"/etc, the form would not get submitted correctly

This would in the worst case is when setting a user's password, where the new password can end up in the URL, but the password was not actually saved to the user.

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	web/src/admin/applications/ApplicationCheckAccessForm.ts
#	web/src/admin/crypto/CertificateGenerateForm.ts
#	web/src/admin/flows/FlowImportForm.ts
#	web/src/admin/groups/RelatedGroupList.ts
#	web/src/admin/policies/PolicyTestForm.ts
#	web/src/admin/property-mappings/PropertyMappingTestForm.ts
#	web/src/admin/providers/saml/SAMLProviderImportForm.ts
#	web/src/admin/users/RelatedUserList.ts
#	web/src/admin/users/ServiceAccountForm.ts
#	web/src/admin/users/UserPasswordForm.ts
#	web/src/admin/users/UserResetEmailForm.ts

# Conflicts:
#	web/src/admin/property-mappings/PropertyMappingTestForm.ts
2023-06-19 13:46:52 +02:00
143663d293 ATH-01-010: fix missing user filter for webauthn device
This prevents an attack that is only possible when an attacker can intercept HTTP traffic and in the case of HTTPS decrypt it.
2023-06-19 13:46:16 +02:00
bd54d034e1 ATH-01-001: resolve path and check start before loading blueprints
This is even less of an issue since 411ef239f6, since with that commit we only allow files that the listing returns

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-19 13:46:13 +02:00
be85eecac5 release: 2023.5.3 2023-06-01 19:35:13 +02:00
24385c9c68 ci: build outpost binaries statically linked (#5823) 2023-05-31 16:58:10 +02:00
e141a11475 blueprints: fix API validation with OCI blueprint path (#5822)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-05-31 14:52:12 +02:00
b055adec2a ci: replace github bot account with github app (#5819)
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2023-05-31 14:52:09 +02:00
772acb10d6 providers/ldap: fix LDAP Outpost application selection (#5812)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-05-31 14:51:46 +02:00
a7bf963409 stages/deny: fix typos (#5800)
* Fix typo in stage.py

Fix typo in "Cancells the current flow"

Signed-off-by: rlew-is <96594816+rlew-is@users.noreply.github.com>

* Fix typo in models.py

Fix typo in "Cancells the current flow"

Signed-off-by: rlew-is <96594816+rlew-is@users.noreply.github.com>

---------

Signed-off-by: rlew-is <96594816+rlew-is@users.noreply.github.com>
2023-05-30 10:54:24 +02:00
317afc932a web/flows: fix RedirectStage not detecting absolute URLs correctly (#5781)
* web: getURL() method in RedirectStage.ts now actually detects URLs (#5732)

Signed-off-by: Saeverix <1863379+Saeverix@users.noreply.github.com>

* use native API to build full URL

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Saeverix <1863379+Saeverix@users.noreply.github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2023-05-30 10:54:12 +02:00
59 changed files with 541 additions and 379 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2023.5.2
current_version = 2023.5.4
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)

View File

@ -135,4 +135,5 @@ jobs:
set -x
export GOOS=${{ matrix.goos }}
export GOARCH=${{ matrix.goarch }}
export CGO_ENABLED=0
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}

View File

@ -10,6 +10,11 @@ jobs:
name: Delete old unused container images
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Delete 'dev' containers older than a week
uses: snok/container-retention-policy@v2
with:
@ -18,5 +23,5 @@ jobs:
account-type: org
org-name: goauthentik
untagged-only: false
token: ${{ secrets.BOT_GITHUB_TOKEN }}
token: ${{ steps.generate_token.outputs.token }}
skip-tags: gh-next,gh-main

View File

@ -123,6 +123,7 @@ jobs:
set -x
export GOOS=${{ matrix.goos }}
export GOARCH=${{ matrix.goarch }}
export CGO_ENABLED=0
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
- name: Upload binaries to release
uses: svenstaro/upload-release-action@v2

View File

@ -22,18 +22,23 @@ jobs:
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test-all
- id: generate_token
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Extract version number
id: get_version
uses: actions/github-script@v6
with:
github-token: ${{ secrets.BOT_GITHUB_TOKEN }}
github-token: ${{ steps.generate_token.outputs.token }}
script: |
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
- name: Create Release
id: create_release
uses: actions/create-release@v1.1.4
env:
GITHUB_TOKEN: ${{ secrets.BOT_GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ steps.get_version.outputs.result }}

View File

@ -15,9 +15,14 @@ jobs:
compile:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v3
with:
token: ${{ secrets.BOT_GITHUB_TOKEN }}
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run compile
@ -26,7 +31,7 @@ jobs:
uses: peter-evans/create-pull-request@v5
id: cpr
with:
token: ${{ secrets.BOT_GITHUB_TOKEN }}
token: ${{ steps.generate_token.outputs.token }}
branch: compile-backend-translation
commit-message: "core: compile backend translations"
title: "core: compile backend translations"

View File

@ -9,9 +9,14 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: tibdex/github-app-token@v1
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v3
with:
token: ${{ secrets.BOT_GITHUB_TOKEN }}
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/setup-node@v3.6.0
with:
node-version: "20"
@ -33,7 +38,7 @@ jobs:
- uses: peter-evans/create-pull-request@v5
id: cpr
with:
token: ${{ secrets.BOT_GITHUB_TOKEN }}
token: ${{ steps.generate_token.outputs.token }}
branch: update-web-api-client
commit-message: "web: bump API Client version"
title: "web: bump API Client version"
@ -44,6 +49,6 @@ jobs:
author: authentik bot <github-bot@goauthentik.io>
- uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ secrets.BOT_GITHUB_TOKEN }}
token: ${{ steps.generate_token.outputs.token }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: squash

View File

@ -2,7 +2,7 @@
from os import environ
from typing import Optional
__version__ = "2023.5.2"
__version__ = "2023.5.4"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -1,5 +1,4 @@
"""authentik administration overview"""
import os
import platform
from datetime import datetime
from sys import version as python_version
@ -34,7 +33,6 @@ class RuntimeDict(TypedDict):
class SystemSerializer(PassiveSerializer):
"""Get system information."""
env = SerializerMethodField()
http_headers = SerializerMethodField()
http_host = SerializerMethodField()
http_is_secure = SerializerMethodField()
@ -43,10 +41,6 @@ class SystemSerializer(PassiveSerializer):
server_time = SerializerMethodField()
embedded_outpost_host = SerializerMethodField()
def get_env(self, request: Request) -> dict[str, str]:
"""Get Environment"""
return os.environ.copy()
def get_http_headers(self, request: Request) -> dict[str, str]:
"""Get HTTP Request headers"""
headers = {}

View File

@ -1,4 +1,5 @@
"""API Authentication"""
from hmac import compare_digest
from typing import Any, Optional
from django.conf import settings
@ -78,7 +79,7 @@ def token_secret_key(value: str) -> Optional[User]:
and return the service account for the managed outpost"""
from authentik.outposts.apps import MANAGED_OUTPOST
if value != settings.SECRET_KEY:
if not compare_digest(value, settings.SECRET_KEY):
return None
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts:

View File

@ -13,6 +13,7 @@ from rest_framework.viewsets import ModelViewSet
from authentik.api.decorators import permission_required
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.oci import OCI_PREFIX
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
@ -36,7 +37,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
def validate_path(self, path: str) -> str:
"""Ensure the path (if set) specified is retrievable"""
if path == "":
if path == "" or path.startswith(OCI_PREFIX):
return path
files: list[dict] = blueprints_find_dict.delay().get()
if path not in [file["path"] for file in files]:

View File

@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from structlog import get_logger
from authentik.blueprints.v1.oci import BlueprintOCIClient, OCIException
from authentik.blueprints.v1.oci import OCI_PREFIX, BlueprintOCIClient, OCIException
from authentik.lib.config import CONFIG
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.lib.sentry import SentryIgnoredException
@ -72,7 +72,7 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
def retrieve_oci(self) -> str:
"""Get blueprint from an OCI registry"""
client = BlueprintOCIClient(self.path.replace("oci://", "https://"))
client = BlueprintOCIClient(self.path.replace(OCI_PREFIX, "https://"))
try:
manifests = client.fetch_manifests()
return client.fetch_blobs(manifests)
@ -82,7 +82,10 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
def retrieve_file(self) -> str:
"""Get blueprint from path"""
try:
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
base = Path(CONFIG.y("blueprints_dir"))
full_path = base.joinpath(Path(self.path)).resolve()
if not str(full_path).startswith(str(base.resolve())):
raise BlueprintRetrievalFailed("Invalid blueprint path")
with full_path.open("r", encoding="utf-8") as _file:
return _file.read()
except (IOError, OSError) as exc:
@ -90,7 +93,7 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
def retrieve(self) -> str:
"""Retrieve blueprint contents"""
if self.path.startswith("oci://"):
if self.path.startswith(OCI_PREFIX):
return self.retrieve_oci()
if self.path != "":
return self.retrieve_file()

View File

@ -1,34 +1,15 @@
"""authentik managed models tests"""
from typing import Callable, Type
from django.apps import apps
from django.test import TestCase
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.lib.models import SerializerModel
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
from authentik.lib.generators import generate_id
class TestModels(TestCase):
"""Test Models"""
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
"""Test serializer"""
def tester(self: TestModels):
if test_model._meta.abstract: # pragma: no cover
return
model_class = test_model()
self.assertTrue(isinstance(model_class, SerializerModel))
self.assertIsNotNone(model_class.serializer)
return tester
for app in apps.get_app_configs():
if not app.label.startswith("authentik"):
continue
for model in app.get_models():
if not is_model_allowed(model):
continue
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))
def test_retrieve_file(self):
"""Test retrieve_file"""
instance = BlueprintInstance.objects.create(name=generate_id(), path="../etc/hosts")
with self.assertRaises(BlueprintRetrievalFailed):
instance.retrieve()

View File

@ -0,0 +1,34 @@
"""authentik managed models tests"""
from typing import Callable, Type
from django.apps import apps
from django.test import TestCase
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.lib.models import SerializerModel
class TestModels(TestCase):
"""Test Models"""
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
"""Test serializer"""
def tester(self: TestModels):
if test_model._meta.abstract: # pragma: no cover
return
model_class = test_model()
self.assertTrue(isinstance(model_class, SerializerModel))
self.assertIsNotNone(model_class.serializer)
return tester
for app in apps.get_app_configs():
if not app.label.startswith("authentik"):
continue
for model in app.get_models():
if not is_model_allowed(model):
continue
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))

View File

@ -44,6 +44,14 @@ class TestBlueprintsV1API(APITestCase):
),
)
def test_api_oci(self):
"""Test validation with OCI path"""
res = self.client.post(
reverse("authentik_api:blueprintinstance-list"),
data={"name": "foo", "path": "oci://foo/bar"},
)
self.assertEqual(res.status_code, 201)
def test_api_blank(self):
"""Test blank"""
res = self.client.post(

View File

@ -19,6 +19,7 @@ from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.http import authentik_user_agent
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
OCI_PREFIX = "oci://"
class OCIException(SentryIgnoredException):

View File

@ -28,6 +28,7 @@ from authentik.blueprints.models import (
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata, EntryInvalidError
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE
from authentik.blueprints.v1.oci import OCI_PREFIX
from authentik.events.monitored_tasks import (
MonitoredTask,
TaskResult,
@ -228,7 +229,7 @@ def apply_blueprint(self: MonitoredTask, instance_pk: str):
def clear_failed_blueprints():
"""Remove blueprints which couldn't be fetched"""
# Exclude OCI blueprints as those might be temporarily unavailable
for blueprint in BlueprintInstance.objects.exclude(path__startswith="oci://"):
for blueprint in BlueprintInstance.objects.exclude(path__startswith=OCI_PREFIX):
try:
blueprint.retrieve()
except BlueprintRetrievalFailed:

View File

@ -1,4 +1,6 @@
"""Provider API Views"""
from django.db.models import QuerySet
from django.db.models.query import Q
from django.utils.translation import gettext_lazy as _
from django_filters.filters import BooleanFilter
from django_filters.filterset import FilterSet
@ -56,17 +58,22 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
class ProviderFilter(FilterSet):
"""Filter for groups"""
"""Filter for providers"""
application__isnull = BooleanFilter(
field_name="application",
lookup_expr="isnull",
)
application__isnull = BooleanFilter(method="filter_application__isnull")
backchannel_only = BooleanFilter(
method="filter_backchannel_only",
)
def filter_backchannel_only(self, queryset, name, value):
def filter_application__isnull(self, queryset: QuerySet, name, value):
"""Only return providers that are neither assigned to application,
both as provider or application provider"""
return queryset.filter(
Q(backchannel_application__isnull=value, is_backchannel=True)
| Q(application__isnull=value)
)
def filter_backchannel_only(self, queryset: QuerySet, name, value):
"""Only return backchannel providers"""
return queryset.filter(is_backchannel=value)

View File

@ -67,11 +67,12 @@ from authentik.core.models import (
TokenIntents,
User,
)
from authentik.events.models import EventAction
from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.config import CONFIG
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
@ -543,6 +544,58 @@ class UserViewSet(UsedByMixin, ModelViewSet):
send_mails(email_stage, message)
return Response(status=204)
@permission_required("authentik_core.impersonate")
@extend_schema(
request=OpenApiTypes.NONE,
responses={
"204": OpenApiResponse(description="Successfully started impersonation"),
"401": OpenApiResponse(description="Access denied"),
},
)
@action(detail=True, methods=["POST"])
def impersonate(self, request: Request, pk: int) -> Response:
"""Impersonate a user"""
if not CONFIG.y_bool("impersonation"):
LOGGER.debug("User attempted to impersonate", user=request.user)
return Response(status=401)
if not request.user.has_perm("impersonate"):
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
return Response(status=401)
user_to_be = self.get_object()
request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
return Response(status=201)
@extend_schema(
request=OpenApiTypes.NONE,
responses={
"204": OpenApiResponse(description="Successfully started impersonation"),
},
)
@action(detail=False, methods=["GET"])
def impersonate_end(self, request: Request) -> Response:
"""End Impersonation a user"""
if (
SESSION_KEY_IMPERSONATE_USER not in request.session
or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session
):
LOGGER.debug("Can't end impersonation", user=request.user)
return Response(status=204)
original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
del request.session[SESSION_KEY_IMPERSONATE_USER]
del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
return Response(status=204)
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends):

View File

@ -1,14 +1,14 @@
"""impersonation tests"""
from json import loads
from django.test.testcases import TestCase
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
class TestImpersonation(TestCase):
class TestImpersonation(APITestCase):
"""impersonation tests"""
def setUp(self) -> None:
@ -23,10 +23,10 @@ class TestImpersonation(TestCase):
self.other_user.save()
self.client.force_login(self.user)
self.client.get(
self.client.post(
reverse(
"authentik_core:impersonate-init",
kwargs={"user_id": self.other_user.pk},
"authentik_api:user-impersonate",
kwargs={"pk": self.other_user.pk},
)
)
@ -35,7 +35,7 @@ class TestImpersonation(TestCase):
self.assertEqual(response_body["user"]["username"], self.other_user.username)
self.assertEqual(response_body["original"]["username"], self.user.username)
self.client.get(reverse("authentik_core:impersonate-end"))
self.client.get(reverse("authentik_api:user-impersonate-end"))
response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode())
@ -46,9 +46,7 @@ class TestImpersonation(TestCase):
"""test impersonation without permissions"""
self.client.force_login(self.other_user)
self.client.get(
reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk})
)
self.client.get(reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}))
response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode())
@ -58,5 +56,5 @@ class TestImpersonation(TestCase):
"""test un-impersonation without impersonating first"""
self.client.force_login(self.other_user)
response = self.client.get(reverse("authentik_core:impersonate-end"))
self.assertRedirects(response, reverse("authentik_core:if-user"))
response = self.client.get(reverse("authentik_api:user-impersonate-end"))
self.assertEqual(response.status_code, 204)

View File

@ -16,7 +16,7 @@ from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet
from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.users import UserViewSet
from authentik.core.views import apps, impersonate
from authentik.core.views import apps
from authentik.core.views.debug import AccessDeniedView
from authentik.core.views.interface import FlowInterfaceView, InterfaceView
from authentik.core.views.session import EndSessionView
@ -38,17 +38,6 @@ urlpatterns = [
apps.RedirectToAppLaunch.as_view(),
name="application-launch",
),
# Impersonation
path(
"-/impersonation/<int:user_id>/",
impersonate.ImpersonateInitView.as_view(),
name="impersonate-init",
),
path(
"-/impersonation/end/",
impersonate.ImpersonateEndView.as_view(),
name="impersonate-end",
),
# Interfaces
path(
"if/admin/",

View File

@ -1,60 +0,0 @@
"""authentik impersonation views"""
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.views import View
from structlog.stdlib import get_logger
from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
SESSION_KEY_IMPERSONATE_USER,
)
from authentik.core.models import User
from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG
LOGGER = get_logger()
class ImpersonateInitView(View):
"""Initiate Impersonation"""
def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
"""Impersonation handler, checks permissions"""
if not CONFIG.y_bool("impersonation"):
LOGGER.debug("User attempted to impersonate", user=request.user)
return HttpResponse("Unauthorized", status=401)
if not request.user.has_perm("impersonate"):
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
return HttpResponse("Unauthorized", status=401)
user_to_be = get_object_or_404(User, pk=user_id)
request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
return redirect("authentik_core:if-user")
class ImpersonateEndView(View):
"""End User impersonation"""
def get(self, request: HttpRequest) -> HttpResponse:
"""End Impersonation handler"""
if (
SESSION_KEY_IMPERSONATE_USER not in request.session
or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session
):
LOGGER.debug("Can't end impersonation", user=request.user)
return redirect("authentik_core:if-user")
original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
del request.session[SESSION_KEY_IMPERSONATE_USER]
del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
return redirect("authentik_core:root-redirect")

View File

@ -23,7 +23,8 @@ class DiagramElement:
style: list[str] = field(default_factory=lambda: ["[", "]"])
def __str__(self) -> str:
element = f'{self.identifier}{self.style[0]}"{self.description}"{self.style[1]}'
description = self.description.replace('"', "#quot;")
element = f'{self.identifier}{self.style[0]}"{description}"{self.style[1]}'
if self.action is not None:
if self.action != "":
element = f"--{self.action}--> {element}"

View File

@ -204,12 +204,12 @@ class ChallengeStageView(StageView):
for field, errors in response.errors.items():
for error in errors:
full_errors.setdefault(field, [])
full_errors[field].append(
{
"string": str(error),
"code": error.code,
}
)
field_error = {
"string": str(error),
}
if hasattr(error, "code"):
field_error["code"] = error.code
full_errors[field].append(field_error)
challenge_response.initial_data["response_errors"] = full_errors
if not challenge_response.is_valid():
self.logger.error(

View File

@ -132,9 +132,9 @@ class TestPolicyProcess(TestCase):
)
binding = PolicyBinding(policy=policy, target=Application.objects.create(name="test"))
http_request = self.factory.get(reverse("authentik_core:impersonate-end"))
http_request = self.factory.get(reverse("authentik_api:user-impersonate-end"))
http_request.user = self.user
http_request.resolver_match = resolve(reverse("authentik_core:impersonate-end"))
http_request.resolver_match = resolve(reverse("authentik_api:user-impersonate-end"))
request = PolicyRequest(self.user)
request.set_http_request(http_request)

View File

@ -1,4 +1,8 @@
"""LDAPProvider API Views"""
from django.db.models import QuerySet
from django.db.models.query import Q
from django_filters.filters import BooleanFilter
from django_filters.filterset import FilterSet
from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
@ -29,24 +33,41 @@ class LDAPProviderSerializer(ProviderSerializer):
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
class LDAPProviderFilter(FilterSet):
"""LDAP Provider filters"""
application__isnull = BooleanFilter(method="filter_application__isnull")
def filter_application__isnull(self, queryset: QuerySet, name, value):
"""Only return providers that are neither assigned to application,
both as provider or application provider"""
return queryset.filter(
Q(backchannel_application__isnull=value) | Q(application__isnull=value)
)
class Meta:
model = LDAPProvider
fields = {
"application": ["isnull"],
"name": ["iexact"],
"authorization_flow__slug": ["iexact"],
"base_dn": ["iexact"],
"search_group__group_uuid": ["iexact"],
"search_group__name": ["iexact"],
"certificate__kp_uuid": ["iexact"],
"certificate__name": ["iexact"],
"tls_server_name": ["iexact"],
"uid_start_number": ["iexact"],
"gid_start_number": ["iexact"],
}
class LDAPProviderViewSet(UsedByMixin, ModelViewSet):
"""LDAPProvider Viewset"""
queryset = LDAPProvider.objects.all()
serializer_class = LDAPProviderSerializer
filterset_fields = {
"application": ["isnull"],
"name": ["iexact"],
"authorization_flow__slug": ["iexact"],
"base_dn": ["iexact"],
"search_group__group_uuid": ["iexact"],
"search_group__name": ["iexact"],
"certificate__kp_uuid": ["iexact"],
"certificate__name": ["iexact"],
"tls_server_name": ["iexact"],
"uid_start_number": ["iexact"],
"gid_start_number": ["iexact"],
}
filterset_class = LDAPProviderFilter
search_fields = ["name"]
ordering = ["name"]

View File

@ -66,8 +66,8 @@ def ldap_sync_password(sender, user: User, password: str, **_):
if not sources.exists():
return
source = sources.first()
changer = LDAPPasswordChanger(source)
try:
changer = LDAPPasswordChanger(source)
changer.change_password(user, password)
except LDAPOperationResult as exc:
LOGGER.warning("failed to set LDAP password", exc=exc)

View File

@ -133,6 +133,12 @@ def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -
device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
if not device:
raise ValidationError("Invalid device")
# We can only check the device's user if the user we're given isn't anonymous
# as this validation is also used for password-less login where webauthn is the very first
# step done by a user. Only if this validation happens at a later stage we can check
# that the device belongs to the user
if not user.is_anonymous and device.user != user:
raise ValidationError("Invalid device")
stage: AuthenticatorValidateStage = stage_view.executor.current_stage

View File

@ -36,9 +36,9 @@ from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_ME
COOKIE_NAME_MFA = "authentik_mfa"
SESSION_KEY_STAGES = "authentik/stages/authenticator_validate/stages"
SESSION_KEY_SELECTED_STAGE = "authentik/stages/authenticator_validate/selected_stage"
SESSION_KEY_DEVICE_CHALLENGES = "authentik/stages/authenticator_validate/device_challenges"
PLAN_CONTEXT_STAGES = "goauthentik.io/stages/authenticator_validate/stages"
PLAN_CONTEXT_SELECTED_STAGE = "goauthentik.io/stages/authenticator_validate/selected_stage"
PLAN_CONTEXT_DEVICE_CHALLENGES = "goauthentik.io/stages/authenticator_validate/device_challenges"
class SelectableStageSerializer(PassiveSerializer):
@ -72,8 +72,8 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
component = CharField(default="ak-stage-authenticator-validate")
def _challenge_allowed(self, classes: list):
device_challenges: list[dict] = self.stage.request.session.get(
SESSION_KEY_DEVICE_CHALLENGES, []
device_challenges: list[dict] = self.stage.executor.plan.context.get(
PLAN_CONTEXT_DEVICE_CHALLENGES, []
)
if not any(x["device_class"] in classes for x in device_challenges):
raise ValidationError("No compatible device class allowed")
@ -103,7 +103,9 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
"""Check which challenge the user has selected. Actual logic only used for SMS stage."""
# First check if the challenge is valid
allowed = False
for device_challenge in self.stage.request.session.get(SESSION_KEY_DEVICE_CHALLENGES, []):
for device_challenge in self.stage.executor.plan.context.get(
PLAN_CONTEXT_DEVICE_CHALLENGES, []
):
if device_challenge.get("device_class", "") == challenge.get(
"device_class", ""
) and device_challenge.get("device_uid", "") == challenge.get("device_uid", ""):
@ -121,11 +123,11 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
def validate_selected_stage(self, stage_pk: str) -> str:
"""Check that the selected stage is valid"""
stages = self.stage.request.session.get(SESSION_KEY_STAGES, [])
stages = self.stage.executor.plan.context.get(PLAN_CONTEXT_STAGES, [])
if not any(str(stage.pk) == stage_pk for stage in stages):
raise ValidationError("Selected stage is invalid")
self.stage.logger.debug("Setting selected stage to ", stage=stage_pk)
self.stage.request.session[SESSION_KEY_SELECTED_STAGE] = stage_pk
self.stage.executor.plan.context[PLAN_CONTEXT_SELECTED_STAGE] = stage_pk
return stage_pk
def validate(self, attrs: dict):
@ -230,7 +232,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
else:
self.logger.debug("No pending user, continuing")
return self.executor.stage_ok()
self.request.session[SESSION_KEY_DEVICE_CHALLENGES] = challenges
self.executor.plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = challenges
# No allowed devices
if len(challenges) < 1:
@ -263,23 +265,23 @@ class AuthenticatorValidateStageView(ChallengeStageView):
if stage.configuration_stages.count() == 1:
next_stage = Stage.objects.get_subclass(pk=stage.configuration_stages.first().pk)
self.logger.debug("Single stage configured, auto-selecting", stage=next_stage)
self.request.session[SESSION_KEY_SELECTED_STAGE] = next_stage
self.executor.plan.context[PLAN_CONTEXT_SELECTED_STAGE] = next_stage
# Because that normal execution only happens on post, we directly inject it here and
# return it
self.executor.plan.insert_stage(next_stage)
return self.executor.stage_ok()
stages = Stage.objects.filter(pk__in=stage.configuration_stages.all()).select_subclasses()
self.request.session[SESSION_KEY_STAGES] = stages
self.executor.plan.context[PLAN_CONTEXT_STAGES] = stages
return super().get(self.request, *args, **kwargs)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
res = super().post(request, *args, **kwargs)
if (
SESSION_KEY_SELECTED_STAGE in self.request.session
PLAN_CONTEXT_SELECTED_STAGE in self.executor.plan.context
and self.executor.current_stage.not_configured_action == NotConfiguredAction.CONFIGURE
):
self.logger.debug("Got selected stage in session, running that")
stage_pk = self.request.session.get(SESSION_KEY_SELECTED_STAGE)
self.logger.debug("Got selected stage in context, running that")
stage_pk = self.executor.plan.context(PLAN_CONTEXT_SELECTED_STAGE)
# Because the foreign key to stage.configuration_stage points to
# a base stage class, we need to do another lookup
stage = Stage.objects.get_subclass(pk=stage_pk)
@ -290,8 +292,8 @@ class AuthenticatorValidateStageView(ChallengeStageView):
return res
def get_challenge(self) -> AuthenticatorValidationChallenge:
challenges = self.request.session.get(SESSION_KEY_DEVICE_CHALLENGES, [])
stages = self.request.session.get(SESSION_KEY_STAGES, [])
challenges = self.executor.plan.context.get(PLAN_CONTEXT_DEVICE_CHALLENGES, [])
stages = self.executor.plan.context.get(PLAN_CONTEXT_STAGES, [])
stage_challenges = []
for stage in stages:
serializer = SelectableStageSerializer(
@ -306,6 +308,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
stage_challenges.append(serializer.data)
return AuthenticatorValidationChallenge(
data={
"component": "ak-stage-authenticator-validate",
"type": ChallengeTypes.NATIVE.value,
"device_challenges": challenges,
"configuration_stages": stage_challenges,
@ -385,8 +388,3 @@ class AuthenticatorValidateStageView(ChallengeStageView):
"device": webauthn_device,
}
return self.set_valid_mfa_cookie(response.device)
def cleanup(self):
self.request.session.pop(SESSION_KEY_STAGES, None)
self.request.session.pop(SESSION_KEY_SELECTED_STAGE, None)
self.request.session.pop(SESSION_KEY_DEVICE_CHALLENGES, None)

View File

@ -1,26 +1,19 @@
"""Test validator stage"""
from unittest.mock import MagicMock, patch
from django.contrib.sessions.middleware import SessionMiddleware
from django.test.client import RequestFactory
from django.urls.base import reverse
from rest_framework.exceptions import ValidationError
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction
from authentik.flows.planner import FlowPlan
from authentik.flows.stage import StageView
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN, FlowExecutorView
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id, generate_key
from authentik.lib.tests.utils import dummy_get_response
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_validate.stage import (
SESSION_KEY_DEVICE_CHALLENGES,
AuthenticatorValidationChallengeResponse,
)
from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES
from authentik.stages.identification.models import IdentificationStage, UserFields
@ -86,12 +79,17 @@ class AuthenticatorValidateStageTests(FlowTestCase):
def test_validate_selected_challenge(self):
"""Test validate_selected_challenge"""
# Prepare request with session
request = self.request_factory.get("/")
flow = create_test_flow()
stage = AuthenticatorValidateStage.objects.create(
name=generate_id(),
not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.STATIC, DeviceClasses.TOTP],
)
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(request)
request.session[SESSION_KEY_DEVICE_CHALLENGES] = [
session = self.client.session
plan = FlowPlan(flow_pk=flow.pk.hex)
plan.append_stage(stage)
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
{
"device_class": "static",
"device_uid": "1",
@ -101,23 +99,43 @@ class AuthenticatorValidateStageTests(FlowTestCase):
"device_uid": "2",
},
]
request.session.save()
session[SESSION_KEY_PLAN] = plan
session.save()
res = AuthenticatorValidationChallengeResponse()
res.stage = StageView(FlowExecutorView())
res.stage.request = request
with self.assertRaises(ValidationError):
res.validate_selected_challenge(
{
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
data={
"selected_challenge": {
"device_class": "baz",
"device_uid": "quox",
"challenge": {},
}
)
res.validate_selected_challenge(
{
"device_class": "static",
"device_uid": "1",
}
},
)
self.assertStageResponse(
response,
flow,
response_errors={
"selected_challenge": [{"string": "invalid challenge selected", "code": "invalid"}]
},
component="ak-stage-authenticator-validate",
)
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
data={
"selected_challenge": {
"device_class": "static",
"device_uid": "1",
"challenge": {},
},
},
)
self.assertStageResponse(
response,
flow,
response_errors={"non_field_errors": [{"string": "Empty response", "code": "invalid"}]},
component="ak-stage-authenticator-validate",
)
@patch(

View File

@ -22,7 +22,7 @@ from authentik.stages.authenticator_validate.challenge import (
)
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_validate.stage import (
SESSION_KEY_DEVICE_CHALLENGES,
PLAN_CONTEXT_DEVICE_CHALLENGES,
AuthenticatorValidateStageView,
)
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
@ -211,14 +211,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
plan.append_stage(stage)
plan.append_stage(UserLoginStage(name=generate_id()))
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_DEVICE_CHALLENGES] = [
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
{
"device_class": device.__class__.__name__.lower().replace("device", ""),
"device_uid": device.pk,
"challenge": {},
}
]
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
)
@ -283,14 +283,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
plan = FlowPlan(flow_pk=flow.pk.hex)
plan.append_stage(stage)
plan.append_stage(UserLoginStage(name=generate_id()))
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_DEVICE_CHALLENGES] = [
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
{
"device_class": device.__class__.__name__.lower().replace("device", ""),
"device_uid": device.pk,
"challenge": {},
}
]
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
)

View File

@ -8,7 +8,7 @@ from authentik.flows.models import Stage
class DenyStage(Stage):
"""Cancells the current flow."""
"""Cancels the current flow."""
@property
def serializer(self) -> type[BaseSerializer]:

View File

@ -5,10 +5,10 @@ from authentik.flows.stage import StageView
class DenyStageView(StageView):
"""Cancells the current flow"""
"""Cancels the current flow"""
def get(self, request: HttpRequest) -> HttpResponse:
"""Cancells the current flow"""
"""Cancels the current flow"""
return self.executor.stage_invalid()
def post(self, request: HttpRequest) -> HttpResponse:

View File

@ -32,7 +32,7 @@ services:
volumes:
- redis:/data
server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.5.2}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.5.4}
restart: unless-stopped
command: server
environment:
@ -50,7 +50,7 @@ services:
- "${COMPOSE_PORT_HTTP:-9000}:9000"
- "${COMPOSE_PORT_HTTPS:-9443}:9443"
worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.5.2}
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.5.4}
restart: unless-stopped
command: worker
environment:

View File

@ -29,4 +29,4 @@ func UserAgent() string {
return fmt.Sprintf("authentik@%s", FullVersion())
}
const VERSION = "2023.5.2"
const VERSION = "2023.5.4"

View File

@ -113,7 +113,7 @@ filterwarnings = [
[tool.poetry]
name = "authentik"
version = "2023.5.2"
version = "2023.5.4"
description = ""
authors = ["authentik Team <hello@goauthentik.io>"]

View File

@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: authentik
version: 2023.5.2
version: 2023.5.4
description: Making authentication simple.
contact:
email: hello@goauthentik.io
@ -4783,6 +4783,38 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/users/{id}/impersonate/:
post:
operationId: core_users_impersonate_create
description: Impersonate a user
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User.
required: true
tags:
- core
security:
- authentik: []
responses:
'204':
description: Successfully started impersonation
'401':
description: Access denied
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/users/{id}/metrics/:
get:
operationId: core_users_metrics_retrieve
@ -4962,6 +4994,29 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/users/impersonate_end/:
get:
operationId: core_users_impersonate_end_retrieve
description: End Impersonation a user
tags:
- core
security:
- authentik: []
responses:
'204':
description: Successfully started impersonation
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/core/users/me/:
get:
operationId: core_users_me_retrieve
@ -40493,12 +40548,6 @@ components:
type: object
description: Get system information.
properties:
env:
type: object
additionalProperties:
type: string
description: Get Environment
readOnly: true
http_headers:
type: object
additionalProperties:
@ -40552,7 +40601,6 @@ components:
readOnly: true
required:
- embedded_outpost_host
- env
- http_headers
- http_host
- http_is_secure

8
web/package-lock.json generated
View File

@ -17,7 +17,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@formatjs/intl-listformat": "^7.2.2",
"@fortawesome/fontawesome-free": "^6.4.0",
"@goauthentik/api": "^2023.5.0-1684333401",
"@goauthentik/api": "^2023.5.3-1687462221",
"@lingui/cli": "^4.1.2",
"@lingui/core": "^4.1.2",
"@lingui/detect-locale": "^4.1.2",
@ -2127,9 +2127,9 @@
}
},
"node_modules/@goauthentik/api": {
"version": "2023.5.0-1684333401",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.5.0-1684333401.tgz",
"integrity": "sha512-lbLXgqhvI65w3uodsJMdGIvFvJUzpleC0M4QneiXH3rTNbufVZWP9WbLCRLynKzEateOf3XSgjQ322BeZBA2bA=="
"version": "2023.5.3-1687462221",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2023.5.3-1687462221.tgz",
"integrity": "sha512-34LJCBVPOfdlIHhDPQEA7NS7mJvrKJKHSfa2HPQClyVM3o5us8Bp4yJKs2nm4hGime3rbZAwYYq7n75tccr7rQ=="
},
"node_modules/@hcaptcha/types": {
"version": "1.0.3",

View File

@ -24,7 +24,7 @@
"@codemirror/theme-one-dark": "^6.1.2",
"@formatjs/intl-listformat": "^7.2.2",
"@fortawesome/fontawesome-free": "^6.4.0",
"@goauthentik/api": "^2023.5.0-1684333401",
"@goauthentik/api": "^2023.5.3-1687462221",
"@lingui/cli": "^4.1.2",
"@lingui/core": "^4.1.2",
"@lingui/detect-locale": "^4.1.2",

View File

@ -31,7 +31,7 @@ import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { AdminApi, SessionUser, Version } from "@goauthentik/api";
import { AdminApi, CoreApi, SessionUser, Version } from "@goauthentik/api";
autoDetectLanguage();
@ -175,10 +175,11 @@ export class AdminInterface extends Interface {
${this.user?.original
? html`<ak-sidebar-item
?highlight=${true}
?isAbsoluteLink=${true}
path=${`/-/impersonation/end/?back=${encodeURIComponent(
`${window.location.pathname}#${window.location.hash}`,
)}`}
@click=${() => {
new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
window.location.reload();
});
}}
>
<span slot="label"
>${t`You're currently impersonating ${this.user.user.username}. Click to stop.`}</span

View File

@ -115,9 +115,8 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
`;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`User`} ?required=${true} name="forUser">
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${t`User`} ?required=${true} name="forUser">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = {
@ -144,7 +143,6 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
>
</ak-search-select>
</ak-form-element-horizontal>
${this.result ? this.renderResult() : html``}
</form>`;
${this.result ? this.renderResult() : html``}`;
}
}

View File

@ -21,9 +21,12 @@ export class CertificateKeyPairForm extends Form<CertificateGenerationRequest> {
});
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Common Name`} name="commonName" ?required=${true}>
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal
label=${t`Common Name`}
name="commonName"
?required=${true}
>
<input type="text" class="pf-c-form-control" required />
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Subject-alt name`} name="subjectAltName">
@ -38,7 +41,6 @@ export class CertificateKeyPairForm extends Form<CertificateGenerationRequest> {
?required=${true}
>
<input class="pf-c-form-control" type="number" value="365" />
</ak-form-element-horizontal>
</form>`;
</ak-form-element-horizontal>`;
}
}

View File

@ -87,15 +87,13 @@ export class FlowImportForm extends Form<Flow> {
`;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Flow`} name="flow">
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${t`Flow`} name="flow">
<input type="file" value="" class="pf-c-form-control" />
<p class="pf-c-form__helper-text">
${t`.yaml files, which can be found on goauthentik.io and can be exported by authentik.`}
</p>
</ak-form-element-horizontal>
${this.result ? this.renderResult() : html``}
</form>`;
${this.result ? this.renderResult() : html``}`;
}
}

View File

@ -46,41 +46,39 @@ export class RelatedGroupAdd extends Form<{ groups: string[] }> {
return data;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Groups to add`} name="groups">
<div class="pf-c-input-group">
<ak-user-group-select-table
.confirm=${(items: Group[]) => {
this.groupsToAdd = items;
this.requestUpdate();
return Promise.resolve();
}}
>
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
<i class="fas fa-plus" aria-hidden="true"></i>
</button>
</ak-user-group-select-table>
<div class="pf-c-form-control">
<ak-chip-group>
${this.groupsToAdd.map((group) => {
return html`<ak-chip
.removable=${true}
value=${ifDefined(group.pk)}
@remove=${() => {
const idx = this.groupsToAdd.indexOf(group);
this.groupsToAdd.splice(idx, 1);
this.requestUpdate();
}}
>
${group.name}
</ak-chip>`;
})}
</ak-chip-group>
</div>
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${t`Groups to add`} name="groups">
<div class="pf-c-input-group">
<ak-user-group-select-table
.confirm=${(items: Group[]) => {
this.groupsToAdd = items;
this.requestUpdate();
return Promise.resolve();
}}
>
<button slot="trigger" class="pf-c-button pf-m-control" type="button">
<i class="fas fa-plus" aria-hidden="true"></i>
</button>
</ak-user-group-select-table>
<div class="pf-c-form-control">
<ak-chip-group>
${this.groupsToAdd.map((group) => {
return html`<ak-chip
.removable=${true}
value=${ifDefined(group.pk)}
@remove=${() => {
const idx = this.groupsToAdd.indexOf(group);
this.groupsToAdd.splice(idx, 1);
this.requestUpdate();
}}
>
${group.name}
</ak-chip>`;
})}
</ak-chip-group>
</div>
</ak-form-element-horizontal>
</form> `;
</div>
</ak-form-element-horizontal>`;
}
}

View File

@ -191,8 +191,12 @@ export class OutpostForm extends ModelForm<Outpost, string> {
const selected = Array.from(this.instance?.providers || []).some((sp) => {
return sp == provider.pk;
});
let appName = provider.assignedApplicationName;
if (provider.assignedBackchannelApplicationName) {
appName = provider.assignedBackchannelApplicationName;
}
return html`<option value=${ifDefined(provider.pk)} ?selected=${selected}>
${provider.assignedApplicationName} (${provider.name})
${appName} (${provider.name})
</option>`;
})}
</select>

View File

@ -116,9 +116,8 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
`;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`User`} ?required=${true} name="user">
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${t`User`} ?required=${true} name="user">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = {
@ -155,7 +154,6 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
${t`Set custom attributes using YAML or JSON.`}
</p>
</ak-form-element-horizontal>
${this.result ? this.renderResult() : html``}
</form>`;
${this.result ? this.renderResult() : html``}`;
}
}

View File

@ -119,9 +119,8 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
`;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`User`} ?required=${true} name="user">
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${t`User`} ?required=${true} name="user">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = {
@ -156,7 +155,6 @@ export class PolicyTestForm extends Form<PolicyTestRequest> {
</ak-codemirror>
<p class="pf-c-form__helper-text">${this.renderExampleButtons()}</p>
</ak-form-element-horizontal>
${this.result ? this.renderResult() : html``}
</form>`;
${this.result ? this.renderResult() : html``}`;
}
}

View File

@ -37,9 +37,8 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
});
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${t`Name`} ?required=${true} name="name">
<input type="text" class="pf-c-form-control" required />
</ak-form-element-horizontal>
<ak-form-element-horizontal
@ -77,7 +76,6 @@ export class SAMLProviderImportForm extends Form<SAMLProvider> {
<ak-form-element-horizontal label=${t`Metadata`} name="metadata">
<input type="file" value="" class="pf-c-form-control" />
</ak-form-element-horizontal>
</form>`;
</ak-form-element-horizontal>`;
}
}

View File

@ -59,9 +59,8 @@ export class RelatedUserAdd extends Form<{ users: number[] }> {
return data;
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
${this.group?.isSuperuser ? html`` : html``}
renderInlineForm(): TemplateResult {
return html`${this.group?.isSuperuser ? html`` : html``}
<ak-form-element-horizontal label=${t`Users to add`} name="users">
<div class="pf-c-input-group">
<ak-group-member-select-table
@ -93,8 +92,7 @@ export class RelatedUserAdd extends Form<{ users: number[] }> {
</ak-chip-group>
</div>
</div>
</ak-form-element-horizontal>
</form> `;
</ak-form-element-horizontal>`;
}
}
@ -193,12 +191,20 @@ export class RelatedUserList extends Table<User> {
</ak-forms-modal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate)
? html`
<a
class="pf-c-button pf-m-tertiary"
href="${`/-/impersonation/${item.pk}/`}"
<ak-action-button
class="pf-m-tertiary"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({
id: item.pk,
})
.then(() => {
window.location.href = "/";
});
}}
>
${t`Impersonate`}
</a>
</ak-action-button>
`
: html``}`,
];

View File

@ -35,9 +35,8 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
this.result = undefined;
}
renderRequestForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Username`} ?required=${true} name="name">
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal label=${t`Username`} ?required=${true} name="name">
<input type="text" value="" class="pf-c-form-control" required />
<p class="pf-c-form__helper-text">
${t`User's primary identifier. 150 characters or fewer.`}
@ -78,8 +77,7 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
value="${dateTimeLocal(new Date(Date.now() + 1000 * 60 ** 2 * 24 * 360))}"
class="pf-c-form-control"
/>
</ak-form-element-horizontal>
</form>`;
</ak-form-element-horizontal>`;
}
renderResponseForm(): TemplateResult {
@ -113,6 +111,6 @@ export class ServiceAccountForm extends Form<UserServiceAccountRequest> {
if (this.result) {
return this.renderResponseForm();
}
return this.renderRequestForm();
return super.renderForm();
}
}

View File

@ -196,12 +196,20 @@ export class UserListPage extends TablePage<User> {
</ak-forms-modal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate)
? html`
<a
class="pf-c-button pf-m-tertiary"
href="${`/-/impersonation/${item.pk}/`}"
<ak-action-button
class="pf-m-tertiary"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({
id: item.pk,
})
.then(() => {
window.location.href = "/";
});
}}
>
${t`Impersonate`}
</a>
</ak-action-button>
`
: html``}`,
];

View File

@ -26,11 +26,13 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
});
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Password`} ?required=${true} name="password">
<input type="password" value="" class="pf-c-form-control" required />
</ak-form-element-horizontal>
</form>`;
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal
label=${t`Password`}
?required=${true}
name="password"
>
<input type="password" value="" class="pf-c-form-control" required />
</ak-form-element-horizontal>`;
}
}

View File

@ -32,32 +32,34 @@ export class UserResetEmailForm extends Form<CoreUsersRecoveryEmailRetrieveReque
return new CoreApi(DEFAULT_CONFIG).coreUsersRecoveryEmailRetrieve(data);
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal label=${t`Email stage`} ?required=${true} name="emailStage">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
const args: StagesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const stages = await new StagesApi(DEFAULT_CONFIG).stagesEmailList(args);
return stages.results;
}}
.groupBy=${(items: Stage[]) => {
return groupBy(items, (stage) => stage.verboseNamePlural);
}}
.renderElement=${(stage: Stage): string => {
return stage.name;
}}
.value=${(stage: Stage | undefined): string | undefined => {
return stage?.pk;
}}
>
</ak-search-select>
</ak-form-element-horizontal>
</form>`;
renderInlineForm(): TemplateResult {
return html`<ak-form-element-horizontal
label=${t`Email stage`}
?required=${true}
name="emailStage"
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
const args: StagesAllListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const stages = await new StagesApi(DEFAULT_CONFIG).stagesEmailList(args);
return stages.results;
}}
.groupBy=${(items: Stage[]) => {
return groupBy(items, (stage) => stage.verboseNamePlural);
}}
.renderElement=${(stage: Stage): string => {
return stage.name;
}}
.value=${(stage: Stage | undefined): string | undefined => {
return stage?.pk;
}}
>
</ak-search-select>
</ak-form-element-horizontal>`;
}
}

View File

@ -201,12 +201,20 @@ export class UserViewPage extends AKElement {
)
? html`
<div class="pf-c-card__footer">
<a
class="pf-c-button pf-m-tertiary"
href="${`/-/impersonation/${this.user?.pk}/`}"
<ak-action-button
class="pf-m-tertiary"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateCreate({
id: this.user?.pk || 0,
})
.then(() => {
window.location.href = "/";
});
}}
>
${t`Impersonate`}
</a>
</ak-action-button>
</div>
`
: html``}

View File

@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2023.5.2";
export const VERSION = "2023.5.4";
export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";";

View File

@ -46,6 +46,7 @@ export class Diagram extends AKElement {
flowchart: {
curve: "linear",
},
htmlLabels: false,
};
mermaid.initialize(this.config);
}

View File

@ -283,9 +283,23 @@ export abstract class Form<T> extends AKElement {
}
renderForm(): TemplateResult {
const inline = this.renderInlineForm();
if (inline) {
return html`<form class="pf-c-form pf-m-horizontal" @submit=${this.submit}>
${inline}
</form>`;
}
return html`<slot></slot>`;
}
/**
* Inline form render callback when inheriting this class, should be overwritten
* instead of `this.renderForm`
*/
renderInlineForm(): TemplateResult | undefined {
return undefined;
}
renderNonFieldErrors(): TemplateResult {
if (!this.nonFieldErrors) {
return html``;

View File

@ -39,10 +39,7 @@ export class RedirectStage extends BaseStage<RedirectChallenge, FlowChallengeRes
}
getURL(): string {
if (!this.challenge.to.includes("://")) {
return window.location.origin + this.challenge.to;
}
return this.challenge.to;
return new URL(this.challenge.to, document.baseURI).toString();
}
firstUpdated(): void {

View File

@ -11,6 +11,7 @@ import { me } from "@goauthentik/common/users";
import { first } from "@goauthentik/common/utils";
import { WebsocketClient } from "@goauthentik/common/ws";
import { Interface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/notifications/APIDrawer";
import "@goauthentik/elements/notifications/NotificationDrawer";
@ -36,7 +37,7 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import { EventsApi, SessionUser } from "@goauthentik/api";
import { CoreApi, EventsApi, SessionUser } from "@goauthentik/api";
autoDetectLanguage();
@ -234,18 +235,23 @@ export class UserInterface extends Interface {
: html``}
</div>
${this.me.original
? html`<div class="pf-c-page__header-tools">
<div class="pf-c-page__header-tools-group">
<a
class="pf-c-button pf-m-warning pf-m-small"
href=${`/-/impersonation/end/?back=${encodeURIComponent(
`${window.location.pathname}#${window.location.hash}`,
)}`}
>
${t`Stop impersonation`}
</a>
</div>
</div>`
? html`&nbsp;
<div class="pf-c-page__header-tools">
<div class="pf-c-page__header-tools-group">
<ak-action-button
class="pf-m-warning pf-m-small"
.apiRequest=${() => {
return new CoreApi(DEFAULT_CONFIG)
.coreUsersImpersonateEndRetrieve()
.then(() => {
window.location.reload();
});
}}
>
${t`Stop impersonation`}
</ak-action-button>
</div>
</div>`
: html``}
<div class="pf-c-page__header-tools-group">
<div class="pf-c-page__header-tools-item pf-m-hidden pf-m-visible-on-md">