Compare commits

...
This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.

17 Commits

Author SHA1 Message Date
Jens Langhammer 3925f5a208
release: 2023.5.6 2023-08-29 19:36:52 +02:00
Jens Langhammer 6add4a62b9
include cure53 report
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-08-29 19:35:50 +02:00
Jens L 54d5aa20ba
security: fix CVE-2023-39522 (#6665)
* stages/email: don't disclose whether a user exists or not when recovering

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

* update website

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

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
# Conflicts:
#	website/docs/releases/2023/v2023.5.md
#	website/docs/releases/2023/v2023.6.md
2023-08-29 19:08:47 +02:00
Jens Langhammer b99ac01228
release: 2023.5.5 2023-07-06 18:15:56 +02:00
Jens Langhammer 15026748d1
security: fix CVE-2023-36456
Signed-off-by: Jens Langhammer <jens@goauthentik.io>

# Conflicts:
#	website/sidebars.js
2023-07-06 18:15:46 +02:00
Jens Langhammer 2739376a2a
release: 2023.5.4 2023-06-22 21:45:33 +02:00
Jens Langhammer 152121175b
bump web api client
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-22 21:33:02 +02:00
Jens Langhammer 1d57a258f3
ATH-01-012: escape quotation marks
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-19 13:48:08 +02:00
Jens Langhammer 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
Jens Langhammer ce77d82b24
ATH-01-010: rework
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2023-06-19 13:48:03 +02:00
Jens Langhammer 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
Jens Langhammer 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
Jens Langhammer 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
Jens Langhammer 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
Jens Langhammer 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
Jens Langhammer 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
Jens Langhammer 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
58 changed files with 739 additions and 356 deletions

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
"""API Authentication""" """API Authentication"""
from hmac import compare_digest
from typing import Any, Optional from typing import Any, Optional
from django.conf import settings 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""" and return the service account for the managed outpost"""
from authentik.outposts.apps import MANAGED_OUTPOST from authentik.outposts.apps import MANAGED_OUTPOST
if value != settings.SECRET_KEY: if not compare_digest(value, settings.SECRET_KEY):
return None return None
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts: if not outposts:

View File

@ -82,7 +82,10 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
def retrieve_file(self) -> str: def retrieve_file(self) -> str:
"""Get blueprint from path""" """Get blueprint from path"""
try: 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: with full_path.open("r", encoding="utf-8") as _file:
return _file.read() return _file.read()
except (IOError, OSError) as exc: except (IOError, OSError) as exc:

View File

@ -1,34 +1,15 @@
"""authentik managed models tests""" """authentik managed models tests"""
from typing import Callable, Type
from django.apps import apps
from django.test import TestCase from django.test import TestCase
from authentik.blueprints.v1.importer import is_model_allowed from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
from authentik.lib.models import SerializerModel from authentik.lib.generators import generate_id
class TestModels(TestCase): class TestModels(TestCase):
"""Test Models""" """Test Models"""
def test_retrieve_file(self):
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable: """Test retrieve_file"""
"""Test serializer""" instance = BlueprintInstance.objects.create(name=generate_id(), path="../etc/hosts")
with self.assertRaises(BlueprintRetrievalFailed):
def tester(self: TestModels): instance.retrieve()
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

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

@ -67,11 +67,12 @@ from authentik.core.models import (
TokenIntents, TokenIntents,
User, User,
) )
from authentik.events.models import EventAction from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import FlowToken from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN 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.models import EmailStage
from authentik.stages.email.tasks import send_mails from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
@ -543,6 +544,58 @@ class UserViewSet(UsedByMixin, ModelViewSet):
send_mails(email_stage, message) send_mails(email_stage, message)
return Response(status=204) 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: def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting""" """Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends): for backend in list(self.filter_backends):

View File

@ -1,14 +1,14 @@
"""impersonation tests""" """impersonation tests"""
from json import loads from json import loads
from django.test.testcases import TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user from authentik.core.tests.utils import create_test_admin_user
class TestImpersonation(TestCase): class TestImpersonation(APITestCase):
"""impersonation tests""" """impersonation tests"""
def setUp(self) -> None: def setUp(self) -> None:
@ -23,10 +23,10 @@ class TestImpersonation(TestCase):
self.other_user.save() self.other_user.save()
self.client.force_login(self.user) self.client.force_login(self.user)
self.client.get( self.client.post(
reverse( reverse(
"authentik_core:impersonate-init", "authentik_api:user-impersonate",
kwargs={"user_id": self.other_user.pk}, 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["user"]["username"], self.other_user.username)
self.assertEqual(response_body["original"]["username"], self.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 = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode()) response_body = loads(response.content.decode())
@ -46,9 +46,7 @@ class TestImpersonation(TestCase):
"""test impersonation without permissions""" """test impersonation without permissions"""
self.client.force_login(self.other_user) self.client.force_login(self.other_user)
self.client.get( self.client.get(reverse("authentik_api:user-impersonate", kwargs={"pk": self.user.pk}))
reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk})
)
response = self.client.get(reverse("authentik_api:user-me")) response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode()) response_body = loads(response.content.decode())
@ -58,5 +56,5 @@ class TestImpersonation(TestCase):
"""test un-impersonation without impersonating first""" """test un-impersonation without impersonating first"""
self.client.force_login(self.other_user) self.client.force_login(self.other_user)
response = self.client.get(reverse("authentik_core:impersonate-end")) response = self.client.get(reverse("authentik_api:user-impersonate-end"))
self.assertRedirects(response, reverse("authentik_core:if-user")) 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.sources import SourceViewSet, UserSourceConnectionViewSet
from authentik.core.api.tokens import TokenViewSet from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.users import UserViewSet 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.debug import AccessDeniedView
from authentik.core.views.interface import FlowInterfaceView, InterfaceView from authentik.core.views.interface import FlowInterfaceView, InterfaceView
from authentik.core.views.session import EndSessionView from authentik.core.views.session import EndSessionView
@ -38,17 +38,6 @@ urlpatterns = [
apps.RedirectToAppLaunch.as_view(), apps.RedirectToAppLaunch.as_view(),
name="application-launch", 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 # Interfaces
path( path(
"if/admin/", "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: ["[", "]"]) style: list[str] = field(default_factory=lambda: ["[", "]"])
def __str__(self) -> str: 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 is not None:
if self.action != "": if self.action != "":
element = f"--{self.action}--> {element}" element = f"--{self.action}--> {element}"

View File

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

View File

@ -5,18 +5,25 @@ postgresql:
name: authentik name: authentik
user: authentik user: authentik
port: 5432 port: 5432
password: 'env://POSTGRES_PASSWORD' password: "env://POSTGRES_PASSWORD"
use_pgbouncer: false use_pgbouncer: false
listen: listen:
listen_http: 0.0.0.0:9000 listen_http: 0.0.0.0:9000
listen_https: 0.0.0.0:9443 listen_https: 0.0.0.0:9443
listen_metrics: 0.0.0.0:9300 listen_metrics: 0.0.0.0:9300
trusted_proxy_cidrs:
- 127.0.0.0/8
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- fe80::/10
- ::1/128
redis: redis:
host: localhost host: localhost
port: 6379 port: 6379
password: '' password: ""
tls: false tls: false
tls_reqs: "none" tls_reqs: "none"
db: 0 db: 0

View File

@ -16,10 +16,12 @@ LOGGER = get_logger()
def _get_client_ip_from_meta(meta: dict[str, Any]) -> str: def _get_client_ip_from_meta(meta: dict[str, Any]) -> str:
"""Attempt to get the client's IP by checking common HTTP Headers. """Attempt to get the client's IP by checking common HTTP Headers.
Returns none if no IP Could be found""" Returns none if no IP Could be found
No additional validation is done here as requests are expected to only arrive here
via the go proxy, which deals with validating these headers for us"""
headers = ( headers = (
"HTTP_X_FORWARDED_FOR", "HTTP_X_FORWARDED_FOR",
"HTTP_X_REAL_IP",
"REMOTE_ADDR", "REMOTE_ADDR",
) )
for _header in headers: for _header in headers:

View File

@ -132,9 +132,9 @@ class TestPolicyProcess(TestCase):
) )
binding = PolicyBinding(policy=policy, target=Application.objects.create(name="test")) 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.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 = PolicyRequest(self.user)
request.set_http_request(http_request) request.set_http_request(http_request)

View File

@ -66,8 +66,8 @@ def ldap_sync_password(sender, user: User, password: str, **_):
if not sources.exists(): if not sources.exists():
return return
source = sources.first() source = sources.first()
changer = LDAPPasswordChanger(source)
try: try:
changer = LDAPPasswordChanger(source)
changer.change_password(user, password) changer.change_password(user, password)
except LDAPOperationResult as exc: except LDAPOperationResult as exc:
LOGGER.warning("failed to set LDAP password", exc=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() device = WebAuthnDevice.objects.filter(credential_id=credential_id).first()
if not device: if not device:
raise ValidationError("Invalid 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 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" COOKIE_NAME_MFA = "authentik_mfa"
SESSION_KEY_STAGES = "authentik/stages/authenticator_validate/stages" PLAN_CONTEXT_STAGES = "goauthentik.io/stages/authenticator_validate/stages"
SESSION_KEY_SELECTED_STAGE = "authentik/stages/authenticator_validate/selected_stage" PLAN_CONTEXT_SELECTED_STAGE = "goauthentik.io/stages/authenticator_validate/selected_stage"
SESSION_KEY_DEVICE_CHALLENGES = "authentik/stages/authenticator_validate/device_challenges" PLAN_CONTEXT_DEVICE_CHALLENGES = "goauthentik.io/stages/authenticator_validate/device_challenges"
class SelectableStageSerializer(PassiveSerializer): class SelectableStageSerializer(PassiveSerializer):
@ -72,8 +72,8 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
component = CharField(default="ak-stage-authenticator-validate") component = CharField(default="ak-stage-authenticator-validate")
def _challenge_allowed(self, classes: list): def _challenge_allowed(self, classes: list):
device_challenges: list[dict] = self.stage.request.session.get( device_challenges: list[dict] = self.stage.executor.plan.context.get(
SESSION_KEY_DEVICE_CHALLENGES, [] PLAN_CONTEXT_DEVICE_CHALLENGES, []
) )
if not any(x["device_class"] in classes for x in device_challenges): if not any(x["device_class"] in classes for x in device_challenges):
raise ValidationError("No compatible device class allowed") 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.""" """Check which challenge the user has selected. Actual logic only used for SMS stage."""
# First check if the challenge is valid # First check if the challenge is valid
allowed = False 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( if device_challenge.get("device_class", "") == challenge.get(
"device_class", "" "device_class", ""
) and device_challenge.get("device_uid", "") == challenge.get("device_uid", ""): ) 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: def validate_selected_stage(self, stage_pk: str) -> str:
"""Check that the selected stage is valid""" """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): if not any(str(stage.pk) == stage_pk for stage in stages):
raise ValidationError("Selected stage is invalid") raise ValidationError("Selected stage is invalid")
self.stage.logger.debug("Setting selected stage to ", stage=stage_pk) 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 return stage_pk
def validate(self, attrs: dict): def validate(self, attrs: dict):
@ -230,7 +232,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
else: else:
self.logger.debug("No pending user, continuing") self.logger.debug("No pending user, continuing")
return self.executor.stage_ok() 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 # No allowed devices
if len(challenges) < 1: if len(challenges) < 1:
@ -263,23 +265,23 @@ class AuthenticatorValidateStageView(ChallengeStageView):
if stage.configuration_stages.count() == 1: if stage.configuration_stages.count() == 1:
next_stage = Stage.objects.get_subclass(pk=stage.configuration_stages.first().pk) next_stage = Stage.objects.get_subclass(pk=stage.configuration_stages.first().pk)
self.logger.debug("Single stage configured, auto-selecting", stage=next_stage) 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 # Because that normal execution only happens on post, we directly inject it here and
# return it # return it
self.executor.plan.insert_stage(next_stage) self.executor.plan.insert_stage(next_stage)
return self.executor.stage_ok() return self.executor.stage_ok()
stages = Stage.objects.filter(pk__in=stage.configuration_stages.all()).select_subclasses() 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) return super().get(self.request, *args, **kwargs)
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
res = super().post(request, *args, **kwargs) res = super().post(request, *args, **kwargs)
if ( 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 and self.executor.current_stage.not_configured_action == NotConfiguredAction.CONFIGURE
): ):
self.logger.debug("Got selected stage in session, running that") self.logger.debug("Got selected stage in context, running that")
stage_pk = self.request.session.get(SESSION_KEY_SELECTED_STAGE) stage_pk = self.executor.plan.context(PLAN_CONTEXT_SELECTED_STAGE)
# Because the foreign key to stage.configuration_stage points to # Because the foreign key to stage.configuration_stage points to
# a base stage class, we need to do another lookup # a base stage class, we need to do another lookup
stage = Stage.objects.get_subclass(pk=stage_pk) stage = Stage.objects.get_subclass(pk=stage_pk)
@ -290,8 +292,8 @@ class AuthenticatorValidateStageView(ChallengeStageView):
return res return res
def get_challenge(self) -> AuthenticatorValidationChallenge: def get_challenge(self) -> AuthenticatorValidationChallenge:
challenges = self.request.session.get(SESSION_KEY_DEVICE_CHALLENGES, []) challenges = self.executor.plan.context.get(PLAN_CONTEXT_DEVICE_CHALLENGES, [])
stages = self.request.session.get(SESSION_KEY_STAGES, []) stages = self.executor.plan.context.get(PLAN_CONTEXT_STAGES, [])
stage_challenges = [] stage_challenges = []
for stage in stages: for stage in stages:
serializer = SelectableStageSerializer( serializer = SelectableStageSerializer(
@ -306,6 +308,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
stage_challenges.append(serializer.data) stage_challenges.append(serializer.data)
return AuthenticatorValidationChallenge( return AuthenticatorValidationChallenge(
data={ data={
"component": "ak-stage-authenticator-validate",
"type": ChallengeTypes.NATIVE.value, "type": ChallengeTypes.NATIVE.value,
"device_challenges": challenges, "device_challenges": challenges,
"configuration_stages": stage_challenges, "configuration_stages": stage_challenges,
@ -385,8 +388,3 @@ class AuthenticatorValidateStageView(ChallengeStageView):
"device": webauthn_device, "device": webauthn_device,
} }
return self.set_valid_mfa_cookie(response.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""" """Test validator stage"""
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from django.contrib.sessions.middleware import SessionMiddleware
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls.base import reverse 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.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction
from authentik.flows.planner import FlowPlan from authentik.flows.planner import FlowPlan
from authentik.flows.stage import StageView
from authentik.flows.tests import FlowTestCase 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.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_duo.models import AuthenticatorDuoStage, DuoDevice
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_validate.stage import ( from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES
SESSION_KEY_DEVICE_CHALLENGES,
AuthenticatorValidationChallengeResponse,
)
from authentik.stages.identification.models import IdentificationStage, UserFields from authentik.stages.identification.models import IdentificationStage, UserFields
@ -86,12 +79,17 @@ class AuthenticatorValidateStageTests(FlowTestCase):
def test_validate_selected_challenge(self): def test_validate_selected_challenge(self):
"""Test validate_selected_challenge""" """Test validate_selected_challenge"""
# Prepare request with session flow = create_test_flow()
request = self.request_factory.get("/") stage = AuthenticatorValidateStage.objects.create(
name=generate_id(),
not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.STATIC, DeviceClasses.TOTP],
)
middleware = SessionMiddleware(dummy_get_response) session = self.client.session
middleware.process_request(request) plan = FlowPlan(flow_pk=flow.pk.hex)
request.session[SESSION_KEY_DEVICE_CHALLENGES] = [ plan.append_stage(stage)
plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
{ {
"device_class": "static", "device_class": "static",
"device_uid": "1", "device_uid": "1",
@ -101,23 +99,43 @@ class AuthenticatorValidateStageTests(FlowTestCase):
"device_uid": "2", "device_uid": "2",
}, },
] ]
request.session.save() session[SESSION_KEY_PLAN] = plan
session.save()
res = AuthenticatorValidationChallengeResponse() response = self.client.post(
res.stage = StageView(FlowExecutorView()) reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
res.stage.request = request data={
with self.assertRaises(ValidationError): "selected_challenge": {
res.validate_selected_challenge(
{
"device_class": "baz", "device_class": "baz",
"device_uid": "quox", "device_uid": "quox",
"challenge": {},
} }
) },
res.validate_selected_challenge( )
{ self.assertStageResponse(
"device_class": "static", response,
"device_uid": "1", 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( @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.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_validate.stage import ( from authentik.stages.authenticator_validate.stage import (
SESSION_KEY_DEVICE_CHALLENGES, PLAN_CONTEXT_DEVICE_CHALLENGES,
AuthenticatorValidateStageView, AuthenticatorValidateStageView,
) )
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
@ -211,14 +211,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
plan.append_stage(stage) plan.append_stage(stage)
plan.append_stage(UserLoginStage(name=generate_id())) plan.append_stage(UserLoginStage(name=generate_id()))
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session[SESSION_KEY_PLAN] = plan plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
session[SESSION_KEY_DEVICE_CHALLENGES] = [
{ {
"device_class": device.__class__.__name__.lower().replace("device", ""), "device_class": device.__class__.__name__.lower().replace("device", ""),
"device_uid": device.pk, "device_uid": device.pk,
"challenge": {}, "challenge": {},
} }
] ]
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
) )
@ -283,14 +283,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
plan = FlowPlan(flow_pk=flow.pk.hex) plan = FlowPlan(flow_pk=flow.pk.hex)
plan.append_stage(stage) plan.append_stage(stage)
plan.append_stage(UserLoginStage(name=generate_id())) plan.append_stage(UserLoginStage(name=generate_id()))
session[SESSION_KEY_PLAN] = plan plan.context[PLAN_CONTEXT_DEVICE_CHALLENGES] = [
session[SESSION_KEY_DEVICE_CHALLENGES] = [
{ {
"device_class": device.__class__.__name__.lower().replace("device", ""), "device_class": device.__class__.__name__.lower().replace("device", ""),
"device_uid": device.pk, "device_uid": device.pk,
"challenge": {}, "challenge": {},
} }
] ]
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes( session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA" "g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
) )

View File

@ -12,7 +12,7 @@ from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
from authentik.flows.models import FlowToken from authentik.flows.models import FlowDesignation, FlowToken
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import QS_KEY_TOKEN from authentik.flows.views.executor import QS_KEY_TOKEN
@ -82,6 +82,11 @@ class EmailStageView(ChallengeStageView):
"""Helper function that sends the actual email. Implies that you've """Helper function that sends the actual email. Implies that you've
already checked that there is a pending user.""" already checked that there is a pending user."""
pending_user = self.get_pending_user() pending_user = self.get_pending_user()
if not pending_user.pk and self.executor.flow.designation == FlowDesignation.RECOVERY:
# Pending user does not have a primary key, and we're in a recovery flow,
# which means the user entered an invalid identifier, so we pretend to send the
# email, to not disclose if the user exists
return
email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None) email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None)
if not email: if not email:
email = pending_user.email email = pending_user.email

View File

@ -5,18 +5,20 @@ from unittest.mock import MagicMock, PropertyMock, patch
from django.core import mail from django.core import mail
from django.core.mail.backends.locmem import EmailBackend from django.core.mail.backends.locmem import EmailBackend
from django.urls import reverse 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, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.markers import StageMarker from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowDesignation, FlowStageBinding from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
class TestEmailStageSending(APITestCase): class TestEmailStageSending(FlowTestCase):
"""Email tests""" """Email tests"""
def setUp(self): def setUp(self):
@ -44,6 +46,13 @@ class TestEmailStageSending(APITestCase):
): ):
response = self.client.post(url) response = self.client.post(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
self.flow,
response_errors={
"non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
},
)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, "authentik") self.assertEqual(mail.outbox[0].subject, "authentik")
events = Event.objects.filter(action=EventAction.EMAIL_SENT) events = Event.objects.filter(action=EventAction.EMAIL_SENT)
@ -54,6 +63,32 @@ class TestEmailStageSending(APITestCase):
self.assertEqual(event.context["to_email"], [self.user.email]) self.assertEqual(event.context["to_email"], [self.user.email])
self.assertEqual(event.context["from_email"], "system@authentik.local") self.assertEqual(event.context["from_email"], "system@authentik.local")
def test_pending_fake_user(self):
"""Test with pending (fake) user"""
self.flow.designation = FlowDesignation.RECOVERY
self.flow.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = User(username=generate_id())
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
with patch(
"authentik.stages.email.models.EmailStage.backend_class",
PropertyMock(return_value=EmailBackend),
):
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
self.flow,
response_errors={
"non_field_errors": [{"string": "email-sent", "code": "email-sent"}]
},
)
self.assertEqual(len(mail.outbox), 0)
def test_send_error(self): def test_send_error(self):
"""Test error during sending (sending will be retried)""" """Test error during sending (sending will be retried)"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])

View File

@ -118,8 +118,12 @@ class IdentificationChallengeResponse(ChallengeResponse):
username=uid_field, username=uid_field,
email=uid_field, email=uid_field,
) )
self.pre_user = self.stage.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
if not current_stage.show_matched_user: if not current_stage.show_matched_user:
self.stage.executor.plan.context[PLAN_CONTEXT_PENDING_USER_IDENTIFIER] = uid_field self.stage.executor.plan.context[PLAN_CONTEXT_PENDING_USER_IDENTIFIER] = uid_field
if self.stage.executor.flow.designation == FlowDesignation.RECOVERY:
# When used in a recovery flow, always continue to not disclose if a user exists
return attrs
raise ValidationError("Failed to authenticate.") raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user self.pre_user = pre_user
if not current_stage.password_stage: if not current_stage.password_stage:

View File

@ -188,7 +188,7 @@ class TestIdentificationStage(FlowTestCase):
], ],
) )
def test_recovery_flow(self): def test_link_recovery_flow(self):
"""Test that recovery flow is linked correctly""" """Test that recovery flow is linked correctly"""
flow = create_test_flow() flow = create_test_flow()
self.stage.recovery_flow = flow self.stage.recovery_flow = flow
@ -226,6 +226,38 @@ class TestIdentificationStage(FlowTestCase):
], ],
) )
def test_recovery_flow_invalid_user(self):
"""Test that an invalid user can proceed in a recovery flow"""
self.flow.designation = FlowDesignation.RECOVERY
self.flow.save()
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
)
self.assertStageResponse(
response,
self.flow,
component="ak-stage-identification",
user_fields=["email"],
password_fields=False,
show_source_labels=False,
primary_action="Continue",
sources=[
{
"challenge": {
"component": "xak-flow-redirect",
"to": "/source/oauth/login/test/",
"type": ChallengeTypes.REDIRECT.value,
},
"icon_url": "/static/authentik/sources/default.svg",
"name": "test",
}
],
)
form_data = {"uid_field": generate_id()}
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
response = self.client.post(url, form_data)
self.assertEqual(response.status_code, 200)
def test_api_validate(self): def test_api_validate(self):
"""Test API validation""" """Test API validation"""
self.assertTrue( self.assertTrue(

View File

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

View File

@ -38,13 +38,14 @@ type RedisConfig struct {
} }
type ListenConfig struct { type ListenConfig struct {
HTTP string `yaml:"listen_http" env:"AUTHENTIK_LISTEN__HTTP"` HTTP string `yaml:"listen_http" env:"AUTHENTIK_LISTEN__HTTP"`
HTTPS string `yaml:"listen_https" env:"AUTHENTIK_LISTEN__HTTPS"` HTTPS string `yaml:"listen_https" env:"AUTHENTIK_LISTEN__HTTPS"`
LDAP string `yaml:"listen_ldap" env:"AUTHENTIK_LISTEN__LDAP"` LDAP string `yaml:"listen_ldap" env:"AUTHENTIK_LISTEN__LDAP"`
LDAPS string `yaml:"listen_ldaps" env:"AUTHENTIK_LISTEN__LDAPS"` LDAPS string `yaml:"listen_ldaps" env:"AUTHENTIK_LISTEN__LDAPS"`
Radius string `yaml:"listen_radius" env:"AUTHENTIK_LISTEN__RADIUS"` Radius string `yaml:"listen_radius" env:"AUTHENTIK_LISTEN__RADIUS"`
Metrics string `yaml:"listen_metrics" env:"AUTHENTIK_LISTEN__METRICS"` Metrics string `yaml:"listen_metrics" env:"AUTHENTIK_LISTEN__METRICS"`
Debug string `yaml:"listen_debug" env:"AUTHENTIK_LISTEN__DEBUG"` Debug string `yaml:"listen_debug" env:"AUTHENTIK_LISTEN__DEBUG"`
TrustedProxyCIDRs []string `yaml:"trusted_proxy_cidrs" env:"AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS"`
} }
type PathsConfig struct { type PathsConfig struct {

View File

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

View File

@ -0,0 +1,44 @@
package web
import (
"net"
"net/http"
"github.com/gorilla/handlers"
log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config"
)
// ProxyHeaders Set proxy headers like X-Forwarded-For and such, but only if the direct connection
// comes from a client that's in a list of trusted CIDRs
func ProxyHeaders() func(http.Handler) http.Handler {
nets := []*net.IPNet{}
for _, rn := range config.Get().Listen.TrustedProxyCIDRs {
_, cidr, err := net.ParseCIDR(rn)
if err != nil {
continue
}
nets = append(nets, cidr)
}
ph := handlers.ProxyHeaders
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
// remoteAddr will be nil if the IP cannot be parsed
remoteAddr := net.ParseIP(host)
for _, allowedCidr := range nets {
if remoteAddr != nil && allowedCidr.Contains(remoteAddr) {
log.WithField("remoteAddr", remoteAddr).WithField("cidr", allowedCidr.String()).Trace("Setting proxy headers")
ph(h).ServeHTTP(w, r)
return
}
}
}
// Request is not directly coming from a CIDR we "trust"
// so set XFF to the direct host IP
r.Header.Set("X-Forwarded-For", host)
h.ServeHTTP(w, r)
})
}
}

View File

@ -35,7 +35,7 @@ type WebServer struct {
func NewWebServer(g *gounicorn.GoUnicorn) *WebServer { func NewWebServer(g *gounicorn.GoUnicorn) *WebServer {
l := log.WithField("logger", "authentik.router") l := log.WithField("logger", "authentik.router")
mainHandler := mux.NewRouter() mainHandler := mux.NewRouter()
mainHandler.Use(handlers.ProxyHeaders) mainHandler.Use(web.ProxyHeaders())
mainHandler.Use(handlers.CompressHandler) mainHandler.Use(handlers.CompressHandler)
loggingHandler := mainHandler.NewRoute().Subrouter() loggingHandler := mainHandler.NewRoute().Subrouter()
loggingHandler.Use(web.NewLoggingHandler(l, nil)) loggingHandler.Use(web.NewLoggingHandler(l, nil))

View File

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

View File

@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: authentik title: authentik
version: 2023.5.3 version: 2023.5.6
description: Making authentication simple. description: Making authentication simple.
contact: contact:
email: hello@goauthentik.io email: hello@goauthentik.io
@ -4783,6 +4783,38 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' 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/: /core/users/{id}/metrics/:
get: get:
operationId: core_users_metrics_retrieve operationId: core_users_metrics_retrieve
@ -4962,6 +4994,29 @@ paths:
schema: schema:
$ref: '#/components/schemas/GenericError' $ref: '#/components/schemas/GenericError'
description: '' 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/: /core/users/me/:
get: get:
operationId: core_users_me_retrieve operationId: core_users_me_retrieve
@ -40493,12 +40548,6 @@ components:
type: object type: object
description: Get system information. description: Get system information.
properties: properties:
env:
type: object
additionalProperties:
type: string
description: Get Environment
readOnly: true
http_headers: http_headers:
type: object type: object
additionalProperties: additionalProperties:
@ -40552,7 +40601,6 @@ components:
readOnly: true readOnly: true
required: required:
- embedded_outpost_host - embedded_outpost_host
- env
- http_headers - http_headers
- http_host - http_host
- http_is_secure - http_is_secure

8
web/package-lock.json generated
View File

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

View File

@ -24,7 +24,7 @@
"@codemirror/theme-one-dark": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.2",
"@formatjs/intl-listformat": "^7.2.2", "@formatjs/intl-listformat": "^7.2.2",
"@fortawesome/fontawesome-free": "^6.4.0", "@fortawesome/fontawesome-free": "^6.4.0",
"@goauthentik/api": "^2023.5.0-1684333401", "@goauthentik/api": "^2023.5.3-1687462221",
"@lingui/cli": "^4.1.2", "@lingui/cli": "^4.1.2",
"@lingui/core": "^4.1.2", "@lingui/core": "^4.1.2",
"@lingui/detect-locale": "^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 PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFBase from "@patternfly/patternfly/patternfly-base.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(); autoDetectLanguage();
@ -175,10 +175,11 @@ export class AdminInterface extends Interface {
${this.user?.original ${this.user?.original
? html`<ak-sidebar-item ? html`<ak-sidebar-item
?highlight=${true} ?highlight=${true}
?isAbsoluteLink=${true} @click=${() => {
path=${`/-/impersonation/end/?back=${encodeURIComponent( new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve().then(() => {
`${window.location.pathname}#${window.location.hash}`, window.location.reload();
)}`} });
}}
> >
<span slot="label" <span slot="label"
>${t`You're currently impersonating ${this.user.user.username}. Click to stop.`}</span >${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 { renderInlineForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal"> return html`<ak-form-element-horizontal label=${t`User`} ?required=${true} name="forUser">
<ak-form-element-horizontal label=${t`User`} ?required=${true} name="forUser">
<ak-search-select <ak-search-select
.fetchObjects=${async (query?: string): Promise<User[]> => { .fetchObjects=${async (query?: string): Promise<User[]> => {
const args: CoreUsersListRequest = { const args: CoreUsersListRequest = {
@ -144,7 +143,6 @@ export class ApplicationCheckAccessForm extends Form<{ forUser: number }> {
> >
</ak-search-select> </ak-search-select>
</ak-form-element-horizontal> </ak-form-element-horizontal>
${this.result ? this.renderResult() : html``} ${this.result ? this.renderResult() : html``}`;
</form>`;
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -283,9 +283,23 @@ export abstract class Form<T> extends AKElement {
} }
renderForm(): TemplateResult { 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>`; 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 { renderNonFieldErrors(): TemplateResult {
if (!this.nonFieldErrors) { if (!this.nonFieldErrors) {
return html``; return html``;

View File

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

View File

@ -59,6 +59,11 @@ kubectl exec -it deployment/authentik-worker -c authentik -- ak dump_config
- `AUTHENTIK_LISTEN__LDAPS`: Listening address:port (e.g. `0.0.0.0:6636`) for LDAPS (LDAP outpost) - `AUTHENTIK_LISTEN__LDAPS`: Listening address:port (e.g. `0.0.0.0:6636`) for LDAPS (LDAP outpost)
- `AUTHENTIK_LISTEN__METRICS`: Listening address:port (e.g. `0.0.0.0:9300`) for Prometheus metrics (All) - `AUTHENTIK_LISTEN__METRICS`: Listening address:port (e.g. `0.0.0.0:9300`) for Prometheus metrics (All)
- `AUTHENTIK_LISTEN__DEBUG`: Listening address:port (e.g. `0.0.0.0:9900`) for Go Debugging metrics (All) - `AUTHENTIK_LISTEN__DEBUG`: Listening address:port (e.g. `0.0.0.0:9900`) for Go Debugging metrics (All)
- `AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS`: List of CIDRs that proxy headers should be accepted from (Server)
Defaults to `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fe80::/10`, `::1/128`.
Requests directly coming from one an address within a CIDR specified here are able to set proxy headers, such as `X-Forwarded-For`. Requests coming from other addresses will not be able to set these headers.
## authentik Settings ## authentik Settings

View File

@ -118,6 +118,37 @@ image:
- web/flows: improve UI for TOTP code input (#5676) - web/flows: improve UI for TOTP code input (#5676)
- web/flows: update flow background (#5639) - web/flows: update flow background (#5639)
## Fixed in 2023.5.2
- blueprints: fix check for file path not being run on worker (#5703)
- blueprints: support custom ports for OCI blueprints (#5727)
- core: bump coverage from 7.2.5 to 7.2.6 (#5738)
- core: make groups field for user optional (#5702)
- events: fix ak_create_event using wrong request for event creation (#5731)
- lib: add tests for ak_create_event (#5710)
- outposts: fix missing radius outpost controller (#5730)
- web/user: fix MFA enroll dropdown broken when password stage has no configuration flow (#5744)
## Fixed in 2023.5.3
- blueprints: fix API validation with OCI blueprint path (#5822)
- ci: build outpost binaries statically linked (#5823)
- ci: replace github bot account with github app (#5819)
- providers/ldap: fix LDAP Outpost application selection (#5812)
- web/flows: fix RedirectStage not detecting absolute URLs correctly (#5781)
## Fixed in 2023.5.4
- security: Address pen-test findings from the [2023-06 Cure53 Code audit](../../security/2023-06-cure53.md)
## Fixed in 2023.5.5
- \*: fix [CVE-2023-36456](../security/CVE-2023-36456), Reported by [@thijsa](https://github.com/thijsa)
## Fixed in 2023.5.6
- \*: fix [CVE-2023-39522](../security/CVE-2023-39522), Reported by [@markrassamni](https://github.com/markrassamni)
## API Changes ## API Changes
#### What's Changed #### What's Changed

View File

@ -0,0 +1,67 @@
# 2023-06 Cure53 Code audit
In May/June of 2023, we've had a Pen-test conducted by [Cure53](https://cure53.de). The following security updates, 2023.4.2 and 2023.5.3 were released as a response to the found issues.
From the complete report, these are the points we're addressing with this update:
### ATH-01-001: Path traversal on blueprints allows arbitrary file-read (Medium)
This had accidentally been patched by a previous commit already; and was also only possible for users with superuser permissions.
### ATH-01-003: CSS injection via faulty string replacement in Mermaid (Low)
This is an unrelated issue that was found with a third-party dependency ([Mermaid](https://mermaid.js.org/)), fixed with https://github.com/mermaid-js/mermaid/releases/tag/v10.2.2
Additionally we've also taken steps to further mitigate possible issues that could be caused in this way.
### ATH-01-008: User-passwords disclosed to third-party service (High)
In certain circumstances, using the Enter key to submit some forms instead of clicking submit would cause the frontend to change the URL instead of calling the API, which could lead to sensitive data being disclosed.
### ATH-01-009: Lack of CSRF protection in impersonate feature (Low)
Previous the URL to start an impersonation was a simple GET URL request, which was susceptible to CSRF. This has been changed to an API Post request.
### ATH-01-010: Web authentication bypass via key confusion (High)
When using WebAuthn to authenticate, the owner of the WebAuthn device wasn't checked. However to exploit this, an attacker would need to be able to already intercept HTTP traffic and read the data.
### ATH-01-014: Authentication challenges abused by foreign flow (Medium)
Previously it was possible to use an MFA authenticator class that wasn't allowed in a flow, if another flow existed that allowed this class. The patch changes data to be isolated per flow to prevent this issue.
### ATH-01-004: Information disclosure on system endpoint (Info)
The `/api/v3/admin/system/` (only accessible to superusers) endpoint returns a large amount of system info (mostly used for debugging), like the HTTP headers sent to the server. It also included all environment variables set for authentik. The environment variables have been removed.
### ATH-01-005: Timing-unsafe comparison in API authentication (Info)
In the API authentication that is used by the embedded outpost (API authentication via Secret key), a timing-unsafe comparison was used.
### ATH-01-012: Unintended diagram created due to unescaped quotes (Info)
Related to ATH-01-003, it was possible to insert unintended diagrams into generated diagrams.
## Additional info
In addition to the points above, several of the findings are classified as intended features (such as the expression policies), however these are points where we do also see room for improvement that we will address in the future.
### ATH-01-002: Stored XSS in help text of prompt module (Medium)
Prompt help texts can use HTML to add markup, which also includes the option to include JavaScript. This is only possible to configure for superusers, and in the future we're planning to add an additional toggle to limit this.
### ATH-01-006: Arbitrary code execution via expressions (Critical)
This is the intended function of expression policies/property mappings, which also requires superuser permissions to edit. We're planning to also add a toggle to limit the functions that can be executed to the ones provided by authentik, and prevent the importing of modules.
### ATH-01-007: SSRF via blueprints feature for fetching manifests (Medium)
Blueprints can be fetched via OCI registries, which could be potentially used for server-side request forgery. This can only be accessed by superusers, and we're planning to add an option to limit the resolved IP ranges this functionality can connect to.
### ATH-01-013: XSS via CAPTCHA JavaScript URL (Medium)
Similar to ATH-01-002, any arbitrary JavaScript can be loaded using the Captcha stage. This is also limited to superusers.
### ATH-01-011: Weak default configs in logout/change password flows (Info)
The default logout flow does not do any additional validation and logs the user out with a single GET request. The default password-change flow does not verify the users current password, nor does it show the current users info.

View File

@ -0,0 +1,21 @@
# CVE-2023-36456
_Reported by [@thijsa](https://github.com/thijsa)_
## Lack of Proxy IP headers validation
### Summary
authentik does not verify the source of the X-Forwarded-For and X-Real-IP headers, both in the Python code and the go code.
### Impact
Only authentik setups that are directly accessible by users without a reverse proxy are susceptible to this. Possible spoofing of IP addresses in logs, downstream applications proxied by (built in) outpost, IP bypassing in custom flows if used.
### Details
This poses a possible security risk when you have flows or policies that check the user's IP address, e.g. when you want to ignore the user's 2 factor authentication when the user is connected to the company network.
Another security risk is that the IP addresses in the logfiles and user sessions is not reliable anymore, anybody can spoof this address and you cannot verify that the user has logged in from the IP address that is in their account's log.
And the third risk is that this header is passed on to the proxied application behind an outpost. The application may do any kind of verification, logging, blocking or rate limiting based on the IP address, and this IP address can be overridden by anybody that want to.

View File

@ -0,0 +1,27 @@
# CVE-2023-39522
_Reported by [@markrassamni](https://github.com/markrassamni)_
## Username enumeration attack
### Summary
Using a recovery flow with an identification stage an attacker is able to determine if a username exists.
### Patches
authentik 2023.5.6 and 2023.6.2 fix this issue.
### Impact
Only setups configured with a recovery flow are impacted by this.
### Details
An attacker can easily enumerate and check users' existence using the recovery flow, as a clear message is shown when a user doesn't exist. Depending on configuration this can either be done by username, email, or both.
### For more information
If you have any questions or comments about this advisory:
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)

View File

@ -321,10 +321,12 @@ module.exports = {
}, },
items: [ items: [
"security/policy", "security/policy",
"security/CVE-2023-39522",
"security/CVE-2023-36456",
"security/CVE-2023-26481",
"security/CVE-2022-23555", "security/CVE-2022-23555",
"security/CVE-2022-46145", "security/CVE-2022-46145",
"security/CVE-2022-46172", "security/CVE-2022-46172",
"security/CVE-2023-26481",
], ],
}, },
], ],