Compare commits
10 Commits
version/20
...
version-20
Author | SHA1 | Date | |
---|---|---|---|
1a21479b0d | |||
38154f72e0 | |||
19318d4c00 | |||
be3d7c0666 | |||
5afceaa55f | |||
72dc27f1c9 | |||
b5ffd16861 | |||
8af754e88c | |||
ade1f08c89 | |||
9240fa1037 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.4.2
|
||||
current_version = 2024.4.4
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2024.4.2"
|
||||
__version__ = "2024.4.4"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -45,6 +45,13 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
self.fields["key"] = CharField(required=False)
|
||||
|
||||
def validate_user(self, user: User):
|
||||
"""Ensure user of token cannot be changed"""
|
||||
if self.instance and self.instance.user_id:
|
||||
if user.pk != self.instance.user_id:
|
||||
raise ValidationError("User cannot be changed")
|
||||
return user
|
||||
|
||||
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
|
||||
"""Ensure only API or App password tokens are created."""
|
||||
request: Request = self.context.get("request")
|
||||
|
@ -14,6 +14,7 @@ from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
|
||||
from authentik.core.api.utils import PassiveSerializer
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
|
||||
|
||||
class DeleteAction(Enum):
|
||||
@ -53,7 +54,7 @@ class UsedByMixin:
|
||||
@extend_schema(
|
||||
responses={200: UsedBySerializer(many=True)},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
|
||||
def used_by(self, request: Request, *args, **kwargs) -> Response:
|
||||
"""Get a list of all objects that use this object"""
|
||||
model: Model = self.get_object()
|
||||
|
@ -13,7 +13,7 @@ from django.utils.translation import gettext as _
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection
|
||||
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage
|
||||
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostSourceStage
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import Flow, FlowToken, Stage, in_memory_stage
|
||||
@ -206,13 +206,9 @@ class SourceFlowManager:
|
||||
|
||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||
"""Hook to override stages which are appended to the flow"""
|
||||
if not self.source.enrollment_flow:
|
||||
return []
|
||||
if flow.slug == self.source.enrollment_flow.slug:
|
||||
return [
|
||||
in_memory_stage(PostUserEnrollmentStage),
|
||||
]
|
||||
return []
|
||||
return [
|
||||
in_memory_stage(PostSourceStage),
|
||||
]
|
||||
|
||||
def _prepare_flow(
|
||||
self,
|
||||
@ -266,6 +262,9 @@ class SourceFlowManager:
|
||||
)
|
||||
# We run the Flow planner here so we can pass the Pending user in the context
|
||||
planner = FlowPlanner(flow)
|
||||
# We append some stages so the initial flow we get might be empty
|
||||
planner.allow_empty_flows = True
|
||||
planner.use_cache = False
|
||||
plan = planner.plan(self.request, kwargs)
|
||||
for stage in self.get_stages_to_append(flow):
|
||||
plan.append_stage(stage)
|
||||
@ -324,7 +323,7 @@ class SourceFlowManager:
|
||||
reverse(
|
||||
"authentik_core:if-user",
|
||||
)
|
||||
+ f"#/settings;page-{self.source.slug}"
|
||||
+ "#/settings;page-sources"
|
||||
)
|
||||
|
||||
def handle_enroll(
|
||||
|
@ -10,7 +10,7 @@ from authentik.flows.stage import StageView
|
||||
PLAN_CONTEXT_SOURCES_CONNECTION = "goauthentik.io/sources/connection"
|
||||
|
||||
|
||||
class PostUserEnrollmentStage(StageView):
|
||||
class PostSourceStage(StageView):
|
||||
"""Dynamically injected stage which saves the Connection after
|
||||
the user has been enrolled."""
|
||||
|
||||
@ -21,10 +21,12 @@ class PostUserEnrollmentStage(StageView):
|
||||
]
|
||||
user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||
connection.user = user
|
||||
linked = connection.pk is None
|
||||
connection.save()
|
||||
Event.new(
|
||||
EventAction.SOURCE_LINKED,
|
||||
message="Linked Source",
|
||||
source=connection.source,
|
||||
).from_http(self.request)
|
||||
if linked:
|
||||
Event.new(
|
||||
EventAction.SOURCE_LINKED,
|
||||
message="Linked Source",
|
||||
source=connection.source,
|
||||
).from_http(self.request)
|
||||
return self.executor.stage_ok()
|
||||
|
@ -2,11 +2,15 @@
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from guardian.utils import get_anonymous_user
|
||||
|
||||
from authentik.core.models import SourceUserMatchingModes, User
|
||||
from authentik.core.sources.flow_manager import Action
|
||||
from authentik.core.sources.stage import PostSourceStage
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import get_request
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
@ -21,41 +25,55 @@ class TestSourceFlowManager(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.source: OAuthSource = OAuthSource.objects.create(name="test")
|
||||
self.authentication_flow = create_test_flow()
|
||||
self.enrollment_flow = create_test_flow()
|
||||
self.source: OAuthSource = OAuthSource.objects.create(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
authentication_flow=self.authentication_flow,
|
||||
enrollment_flow=self.enrollment_flow,
|
||||
)
|
||||
self.identifier = generate_id()
|
||||
|
||||
def test_unauthenticated_enroll(self):
|
||||
"""Test un-authenticated user enrolling"""
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
|
||||
)
|
||||
request = get_request("/", user=AnonymousUser())
|
||||
flow_manager = OAuthSourceFlowManager(self.source, request, self.identifier, {})
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.ENROLL)
|
||||
flow_manager.get_flow()
|
||||
response = flow_manager.get_flow()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
self.assertEqual(flow_plan.bindings[0].stage.view, PostSourceStage)
|
||||
|
||||
def test_unauthenticated_auth(self):
|
||||
"""Test un-authenticated user authenticating"""
|
||||
UserOAuthSourceConnection.objects.create(
|
||||
user=get_anonymous_user(), source=self.source, identifier=self.identifier
|
||||
)
|
||||
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, get_request("/", user=AnonymousUser()), self.identifier, {}
|
||||
)
|
||||
request = get_request("/", user=AnonymousUser())
|
||||
flow_manager = OAuthSourceFlowManager(self.source, request, self.identifier, {})
|
||||
action, _ = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.AUTH)
|
||||
flow_manager.get_flow()
|
||||
response = flow_manager.get_flow()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
flow_plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
self.assertEqual(flow_plan.bindings[0].stage.view, PostSourceStage)
|
||||
|
||||
def test_authenticated_link(self):
|
||||
"""Test authenticated user linking"""
|
||||
user = User.objects.create(username="foo", email="foo@bar.baz")
|
||||
flow_manager = OAuthSourceFlowManager(
|
||||
self.source, get_request("/", user=user), self.identifier, {}
|
||||
)
|
||||
request = get_request("/", user=user)
|
||||
flow_manager = OAuthSourceFlowManager(self.source, request, self.identifier, {})
|
||||
action, connection = flow_manager.get_action()
|
||||
self.assertEqual(action, Action.LINK)
|
||||
self.assertIsNone(connection.pk)
|
||||
flow_manager.get_flow()
|
||||
response = flow_manager.get_flow()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url,
|
||||
reverse("authentik_core:if-user") + "#/settings;page-sources",
|
||||
)
|
||||
|
||||
def test_unauthenticated_link(self):
|
||||
"""Test un-authenticated user linking"""
|
||||
|
@ -13,9 +13,8 @@ from authentik.core.models import (
|
||||
USER_ATTRIBUTE_TOKEN_MAXIMUM_LIFETIME,
|
||||
Token,
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
@ -24,7 +23,7 @@ class TestTokenAPI(APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.user = User.objects.create(username="testuser")
|
||||
self.user = create_test_user()
|
||||
self.admin = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
@ -154,6 +153,24 @@ class TestTokenAPI(APITestCase):
|
||||
self.assertEqual(token.expiring, True)
|
||||
self.assertNotEqual(token.expires.timestamp(), expires.timestamp())
|
||||
|
||||
def test_token_change_user(self):
|
||||
"""Test creating a token and then changing the user"""
|
||||
ident = generate_id()
|
||||
response = self.client.post(reverse("authentik_api:token-list"), {"identifier": ident})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
token = Token.objects.get(identifier=ident)
|
||||
self.assertEqual(token.user, self.user)
|
||||
self.assertEqual(token.intent, TokenIntents.INTENT_API)
|
||||
self.assertEqual(token.expiring, True)
|
||||
self.assertTrue(self.user.has_perm("authentik_core.view_token_key", token))
|
||||
response = self.client.put(
|
||||
reverse("authentik_api:token-detail", kwargs={"identifier": ident}),
|
||||
data={"identifier": "user_token_poc_v3", "intent": "api", "user": self.admin.pk},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
token.refresh_from_db()
|
||||
self.assertEqual(token.user, self.user)
|
||||
|
||||
def test_list(self):
|
||||
"""Test Token List (Test normal authentication)"""
|
||||
Token.objects.all().delete()
|
||||
|
@ -36,6 +36,7 @@ from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -266,7 +267,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
],
|
||||
responses={200: CertificateDataSerializer(many=False)},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
|
||||
def view_certificate(self, request: Request, pk: str) -> Response:
|
||||
"""Return certificate-key pairs certificate and log access"""
|
||||
certificate: CertificateKeyPair = self.get_object()
|
||||
@ -296,7 +297,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
],
|
||||
responses={200: CertificateDataSerializer(many=False)},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
|
||||
def view_private_key(self, request: Request, pk: str) -> Response:
|
||||
"""Return certificate-key pairs private key and log access"""
|
||||
certificate: CertificateKeyPair = self.get_object()
|
||||
|
@ -214,6 +214,46 @@ class TestCrypto(APITestCase):
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertIn("Content-Disposition", response)
|
||||
|
||||
def test_certificate_download_denied(self):
|
||||
"""Test certificate export (download)"""
|
||||
self.client.logout()
|
||||
keypair = create_test_cert()
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-view-certificate",
|
||||
kwargs={"pk": keypair.pk},
|
||||
)
|
||||
)
|
||||
self.assertEqual(403, response.status_code)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-view-certificate",
|
||||
kwargs={"pk": keypair.pk},
|
||||
),
|
||||
data={"download": True},
|
||||
)
|
||||
self.assertEqual(403, response.status_code)
|
||||
|
||||
def test_private_key_download_denied(self):
|
||||
"""Test private_key export (download)"""
|
||||
self.client.logout()
|
||||
keypair = create_test_cert()
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-view-private-key",
|
||||
kwargs={"pk": keypair.pk},
|
||||
)
|
||||
)
|
||||
self.assertEqual(403, response.status_code)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-view-private-key",
|
||||
kwargs={"pk": keypair.pk},
|
||||
),
|
||||
data={"download": True},
|
||||
)
|
||||
self.assertEqual(403, response.status_code)
|
||||
|
||||
def test_used_by(self):
|
||||
"""Test used_by endpoint"""
|
||||
self.client.force_login(create_test_admin_user())
|
||||
@ -246,6 +286,26 @@ class TestCrypto(APITestCase):
|
||||
],
|
||||
)
|
||||
|
||||
def test_used_by_denied(self):
|
||||
"""Test used_by endpoint"""
|
||||
self.client.logout()
|
||||
keypair = create_test_cert()
|
||||
OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=keypair,
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-used-by",
|
||||
kwargs={"pk": keypair.pk},
|
||||
)
|
||||
)
|
||||
self.assertEqual(403, response.status_code)
|
||||
|
||||
def test_discovery(self):
|
||||
"""Test certificate discovery"""
|
||||
name = generate_id()
|
||||
|
@ -33,6 +33,7 @@ from authentik.lib.utils.file import (
|
||||
)
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -277,7 +278,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
||||
400: OpenApiResponse(description="Flow not applicable"),
|
||||
},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
|
||||
def execute(self, request: Request, slug: str):
|
||||
"""Execute flow for current user"""
|
||||
# Because we pre-plan the flow here, and not in the planner, we need to manually clear
|
||||
|
@ -203,7 +203,8 @@ class FlowPlanner:
|
||||
"f(plan): building plan",
|
||||
)
|
||||
plan = self._build_plan(user, request, default_context)
|
||||
cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT)
|
||||
if self.use_cache:
|
||||
cache.set(cache_key(self.flow, user), plan, CACHE_TIMEOUT)
|
||||
if not plan.bindings and not self.allow_empty_flows:
|
||||
raise EmptyFlowException()
|
||||
return plan
|
||||
|
@ -23,6 +23,7 @@ from authentik.outposts.models import (
|
||||
KubernetesServiceConnection,
|
||||
OutpostServiceConnection,
|
||||
)
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
|
||||
|
||||
class ServiceConnectionSerializer(ModelSerializer, MetaNameSerializer):
|
||||
@ -88,7 +89,7 @@ class ServiceConnectionViewSet(
|
||||
return Response(TypeCreateSerializer(data, many=True).data)
|
||||
|
||||
@extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)})
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
@action(detail=True, pagination_class=None, filter_backends=[ObjectFilter])
|
||||
def state(self, request: Request, pk: str) -> Response:
|
||||
"""Get the service connection's state"""
|
||||
connection = self.get_object()
|
||||
|
@ -4,9 +4,10 @@ from urllib.parse import urlencode
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_brand, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
@ -77,3 +78,23 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
+ "?"
|
||||
+ urlencode({QS_KEY_CODE: token.user_code}),
|
||||
)
|
||||
|
||||
def test_device_init_denied(self):
|
||||
"""Test device init"""
|
||||
group = Group.objects.create(name="foo")
|
||||
PolicyBinding.objects.create(
|
||||
group=group,
|
||||
target=self.application,
|
||||
order=0,
|
||||
)
|
||||
token = DeviceToken.objects.create(
|
||||
user_code="foo",
|
||||
provider=self.provider,
|
||||
)
|
||||
res = self.client.get(
|
||||
reverse("authentik_providers_oauth2_root:device-login")
|
||||
+ "?"
|
||||
+ urlencode({QS_KEY_CODE: token.user_code})
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn(b"Permission denied", res.content)
|
||||
|
@ -11,10 +11,11 @@ from django.views.decorators.csrf import csrf_exempt
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE, get_application
|
||||
from authentik.providers.oauth2.views.device_init import QS_KEY_CODE
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -37,7 +38,9 @@ class DeviceView(View):
|
||||
).first()
|
||||
if not provider:
|
||||
return HttpResponseBadRequest()
|
||||
if not get_application(provider):
|
||||
try:
|
||||
_ = provider.application
|
||||
except Application.DoesNotExist:
|
||||
return HttpResponseBadRequest()
|
||||
self.provider = provider
|
||||
self.client_id = client_id
|
||||
|
@ -1,8 +1,9 @@
|
||||
"""Device flow views"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
from structlog.stdlib import get_logger
|
||||
@ -16,7 +17,8 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO,
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.providers.oauth2.models import DeviceToken, OAuth2Provider
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.oauth2.models import DeviceToken
|
||||
from authentik.providers.oauth2.views.device_finish import (
|
||||
PLAN_CONTEXT_DEVICE,
|
||||
OAuthDeviceCodeFinishStage,
|
||||
@ -31,60 +33,52 @@ LOGGER = get_logger()
|
||||
QS_KEY_CODE = "code" # nosec
|
||||
|
||||
|
||||
def get_application(provider: OAuth2Provider) -> Application | None:
|
||||
"""Get application from provider"""
|
||||
try:
|
||||
app = provider.application
|
||||
if not app:
|
||||
class CodeValidatorView(PolicyAccessView):
|
||||
"""Helper to validate frontside token"""
|
||||
|
||||
def __init__(self, code: str, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.code = code
|
||||
|
||||
def resolve_provider_application(self):
|
||||
self.token = DeviceToken.objects.filter(user_code=self.code).first()
|
||||
if not self.token:
|
||||
raise Application.DoesNotExist
|
||||
self.provider = self.token.provider
|
||||
self.application = self.token.provider.application
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs):
|
||||
scope_descriptions = UserInfoView().get_scope_descriptions(self.token.scope, self.provider)
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
planner.use_cache = False
|
||||
try:
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_DEVICE: self.token,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": self.application.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
return None
|
||||
return app
|
||||
except Application.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def validate_code(code: int, request: HttpRequest) -> HttpResponse | None:
|
||||
"""Validate user token"""
|
||||
token = DeviceToken.objects.filter(
|
||||
user_code=code,
|
||||
).first()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
app = get_application(token.provider)
|
||||
if not app:
|
||||
return None
|
||||
|
||||
scope_descriptions = UserInfoView().get_scope_descriptions(token.scope, token.provider)
|
||||
planner = FlowPlanner(token.provider.authorization_flow)
|
||||
planner.allow_empty_flows = True
|
||||
planner.use_cache = False
|
||||
try:
|
||||
plan = planner.plan(
|
||||
request,
|
||||
{
|
||||
PLAN_CONTEXT_SSO: True,
|
||||
PLAN_CONTEXT_APPLICATION: app,
|
||||
# OAuth2 related params
|
||||
PLAN_CONTEXT_DEVICE: token,
|
||||
# Consent related params
|
||||
PLAN_CONTEXT_CONSENT_HEADER: _("You're about to sign into %(application)s.")
|
||||
% {"application": app.name},
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
|
||||
},
|
||||
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
request.GET,
|
||||
flow_slug=self.token.provider.authorization_flow.slug,
|
||||
)
|
||||
except FlowNonApplicableException:
|
||||
LOGGER.warning("Flow not applicable to user")
|
||||
return None
|
||||
plan.insert_stage(in_memory_stage(OAuthDeviceCodeFinishStage))
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
request.GET,
|
||||
flow_slug=token.provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
|
||||
class DeviceEntryView(View):
|
||||
class DeviceEntryView(PolicyAccessView):
|
||||
"""View used to initiate the device-code flow, url entered by endusers"""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
@ -94,7 +88,9 @@ class DeviceEntryView(View):
|
||||
LOGGER.info("Brand has no device code flow configured", brand=brand)
|
||||
return HttpResponse(status=404)
|
||||
if QS_KEY_CODE in request.GET:
|
||||
validation = validate_code(request.GET[QS_KEY_CODE], request)
|
||||
validation = CodeValidatorView(request.GET[QS_KEY_CODE], request=request).dispatch(
|
||||
request
|
||||
)
|
||||
if validation:
|
||||
return validation
|
||||
LOGGER.info("Got code from query parameter but no matching token found")
|
||||
@ -131,7 +127,7 @@ class OAuthDeviceCodeChallengeResponse(ChallengeResponse):
|
||||
|
||||
def validate_code(self, code: int) -> HttpResponse | None:
|
||||
"""Validate code and save the returned http response"""
|
||||
response = validate_code(code, self.stage.request)
|
||||
response = CodeValidatorView(code, request=self.stage.request).dispatch(self.stage.request)
|
||||
if not response:
|
||||
raise ValidationError(_("Invalid code"), "invalid")
|
||||
return response
|
||||
|
@ -25,7 +25,7 @@ class ObjectFilter(ObjectPermissionsFilter):
|
||||
# Outposts (which are the only objects using internal service accounts)
|
||||
# except requests to return an empty list when they have no objects
|
||||
# assigned
|
||||
if request.user.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
|
||||
if getattr(request.user, "type", UserTypes.INTERNAL) == UserTypes.INTERNAL_SERVICE_ACCOUNT:
|
||||
return queryset
|
||||
if not queryset.exists():
|
||||
# User doesn't have direct permission to all objects
|
||||
|
@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2024.4.2 Blueprint schema",
|
||||
"title": "authentik 2024.4.4 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
|
@ -32,7 +32,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.4.2}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.4.4}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -53,7 +53,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.4.2}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.4.4}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.4.2"
|
||||
const VERSION = "2024.4.4"
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2024.4.2"
|
||||
version = "2024.4.4"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2024.4.2
|
||||
version: 2024.4.4
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
|
@ -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 = "2024.4.2";
|
||||
export const VERSION = "2024.4.4";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
@ -2,8 +2,9 @@ import { AKElement } from "@goauthentik/elements/Base";
|
||||
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { PropertyValues } from "@lit/reactive-element/reactive-element";
|
||||
import { TemplateResult, css, html } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators.js";
|
||||
import { customElement, property, queryAll, state } from "lit/decorators.js";
|
||||
import { map } from "lit/directives/map.js";
|
||||
|
||||
import PFCheck from "@patternfly/patternfly/components/Check/check.css";
|
||||
@ -112,10 +113,14 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
|
||||
@queryAll('input[type="checkbox"]')
|
||||
checkboxes!: NodeListOf<HTMLInputElement>;
|
||||
|
||||
internals?: ElementInternals;
|
||||
@state()
|
||||
values: string[] = [];
|
||||
|
||||
get json() {
|
||||
return this.value;
|
||||
internals?: ElementInternals;
|
||||
doneFirstUpdate = false;
|
||||
|
||||
json() {
|
||||
return this.values;
|
||||
}
|
||||
|
||||
private get formValue() {
|
||||
@ -124,7 +129,7 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
|
||||
}
|
||||
const name = this.name;
|
||||
const entries = new FormData();
|
||||
this.value.forEach((v) => entries.append(name, v));
|
||||
this.values.forEach((v) => entries.append(name, v));
|
||||
return entries;
|
||||
}
|
||||
|
||||
@ -136,14 +141,14 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
|
||||
|
||||
onClick(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this.value = Array.from(this.checkboxes)
|
||||
this.values = Array.from(this.checkboxes)
|
||||
.filter((checkbox) => checkbox.checked)
|
||||
.map((checkbox) => checkbox.name);
|
||||
this.dispatchCustomEvent("change", this.value);
|
||||
this.dispatchCustomEvent("input", this.value);
|
||||
this.dispatchCustomEvent("change", this.values);
|
||||
this.dispatchCustomEvent("input", this.values);
|
||||
if (this.internals) {
|
||||
this.internals.setValidity({});
|
||||
if (this.required && this.value.length === 0) {
|
||||
if (this.required && this.values.length === 0) {
|
||||
this.internals.setValidity(
|
||||
{
|
||||
valueMissing: true,
|
||||
@ -154,6 +159,16 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
|
||||
}
|
||||
this.internals.setFormValue(this.formValue);
|
||||
}
|
||||
// Doing a write-back so anyone examining the checkbox.value field will get something
|
||||
// meaningful. Doesn't do anything for anyone, usually, but it's nice to have.
|
||||
this.value = this.values;
|
||||
}
|
||||
|
||||
willUpdate(changed: PropertyValues<this>) {
|
||||
if (changed.has("value") && !this.doneFirstUpdate) {
|
||||
this.doneFirstUpdate = true;
|
||||
this.values = this.value;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@ -183,7 +198,7 @@ export class CheckboxGroup extends AkElementWithCustomEvents {
|
||||
|
||||
render() {
|
||||
const renderOne = ([name, label]: CheckboxPr) => {
|
||||
const selected = this.value.includes(name);
|
||||
const selected = this.values.includes(name);
|
||||
const blockFwd = (e: Event) => {
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
|
@ -53,6 +53,9 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
|
||||
|
||||
private isLoading = false;
|
||||
|
||||
private doneFirstUpdate = false;
|
||||
private internalSelected: DualSelectPair[] = [];
|
||||
|
||||
private pagination?: Pagination;
|
||||
|
||||
constructor() {
|
||||
@ -69,6 +72,11 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
|
||||
}
|
||||
|
||||
willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("selected") && !this.doneFirstUpdate) {
|
||||
this.doneFirstUpdate = true;
|
||||
this.internalSelected = this.selected;
|
||||
}
|
||||
|
||||
if (changedProperties.has("searchDelay")) {
|
||||
this.doSearch = debounce(
|
||||
AkDualSelectProvider.prototype.doSearch.bind(this),
|
||||
@ -105,7 +113,8 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
|
||||
if (!(event instanceof CustomEvent)) {
|
||||
throw new Error(`Expecting a CustomEvent for change, received ${event} instead`);
|
||||
}
|
||||
this.selected = event.detail.value;
|
||||
this.internalSelected = event.detail.value;
|
||||
this.selected = this.internalSelected;
|
||||
}
|
||||
|
||||
onSearch(event: Event) {
|
||||
@ -124,12 +133,16 @@ export class AkDualSelectProvider extends CustomListenerElement(AKElement) {
|
||||
return this.dualSelector.value!.selected.map(([k, _]) => k);
|
||||
}
|
||||
|
||||
json() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<ak-dual-select
|
||||
${ref(this.dualSelector)}
|
||||
.options=${this.options}
|
||||
.pages=${this.pagination}
|
||||
.selected=${this.selected}
|
||||
.selected=${this.internalSelected}
|
||||
available-label=${this.availableLabel}
|
||||
selected-label=${this.selectedLabel}
|
||||
></ak-dual-select>`;
|
||||
|
@ -80,7 +80,7 @@ export function serializeForm<T extends KeyUnknown>(
|
||||
}
|
||||
|
||||
if ("akControl" in inputElement.dataset) {
|
||||
assignValue(element, inputElement.value, json);
|
||||
assignValue(element, (inputElement as unknown as AkControlElement).json(), json);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -235,6 +235,14 @@ helm upgrade authentik authentik/authentik -f values.yaml --version ^2024.4
|
||||
- web/flows: fix missing fallback for flow logo (cherry-pick #9487) (#9492)
|
||||
- web: Add missing integrity hashes to package-lock.json (#9527)
|
||||
|
||||
## Fixed in 2024.4.3
|
||||
|
||||
- core: fix source flow_manager not always appending save stage (cherry-pick #9659) (#9662)
|
||||
- security: fix [CVE-2024-37905](../../security/CVE-2024-37905.md), reported by [@m2a2](https://github.com/m2a2) (cherry-pick #10230) (#10236)
|
||||
- security: fix [CVE-2024-38371](../../security/CVE-2024-38371.md), reported by Stefan Zwanenburg (cherry-pick #10229) (#10233)
|
||||
- sources/saml: fix FlowPlanner error due to pickle (cherry-pick #9708) (#9709)
|
||||
- web: fix value handling inside controlled components (cherry-pick #9648) (#9685)
|
||||
|
||||
## API Changes
|
||||
|
||||
#### What's New
|
||||
|
27
website/docs/security/CVE-2024-37905.md
Normal file
27
website/docs/security/CVE-2024-37905.md
Normal file
@ -0,0 +1,27 @@
|
||||
# CVE-2024-37905
|
||||
|
||||
_Reported by [@m2a2](https://github.com/m2a2)_
|
||||
|
||||
## Improper Authorization for Token modification
|
||||
|
||||
### Summary
|
||||
|
||||
Due to insufficient permission checks it was possible for any authenticated user to elevate their permissions to a superuser by creating an API token and changing the user the token belonged to.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.6.0, 2024.4.3 and 2024.2.4 fix this issue, for other versions the workaround can be used.
|
||||
|
||||
### Details
|
||||
|
||||
By setting a token's user ID to the ID of a higher privileged user, the token will inherit the higher privileged access to the API. This can be used to change the password of the affected user or to modify the authentik configuration in a potentially malicious way.
|
||||
|
||||
### Workarounds
|
||||
|
||||
As a workaround it is possible to block any requests to `/api/v3/core/tokens*` at the reverse-proxy/load-balancer level. Doing so prevents this issue from being exploited.
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
23
website/docs/security/CVE-2024-38371.md
Normal file
23
website/docs/security/CVE-2024-38371.md
Normal file
@ -0,0 +1,23 @@
|
||||
# CVE-2024-38371
|
||||
|
||||
_Reported by Stefan Zwanenburg_
|
||||
|
||||
## Insufficient access control for OAuth2 Device Code flow
|
||||
|
||||
### Impact
|
||||
|
||||
Due to a bug, access restrictions assigned to an application were not checked when using the OAuth2 Device code flow. This could potentially allow users without the correct authorization to get OAuth tokens for an application, and access the application.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.6.0, 2024.4.3 and 2024.2.4 fix this issue, for other versions the workaround can be used.
|
||||
|
||||
### Workarounds
|
||||
|
||||
As authentik flows are still used as part of the OAuth2 Device code flow, it is possible to add access control to the configured flows.
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
31
website/docs/security/CVE-2024-42490.md
Normal file
31
website/docs/security/CVE-2024-42490.md
Normal file
@ -0,0 +1,31 @@
|
||||
# CVE-2024-42490
|
||||
|
||||
_Reported by [@m2a2](https://github.com/m2a2)_
|
||||
|
||||
## Improper Authorization for Token modification
|
||||
|
||||
### Summary
|
||||
|
||||
Several API endpoints can be accessed by users without correct authentication/authorization.
|
||||
|
||||
The main API endpoints affected by this:
|
||||
|
||||
- `/api/v3/crypto/certificatekeypairs/<uuid>/view_certificate/`
|
||||
- `/api/v3/crypto/certificatekeypairs/<uuid>/view_private_key/`
|
||||
- `/api/v3/.../used_by/`
|
||||
|
||||
Note that all of the affected API endpoints require the knowledge of the ID of an object, which especially for certificates is not accessible to an unprivileged user. Additionally the IDs for most objects are UUIDv4, meaning they are not easily guessable/enumerable.
|
||||
|
||||
### Patches
|
||||
|
||||
authentik 2024.4.4, 2024.6.4 and 2024.8.0 fix this issue.
|
||||
|
||||
### Workarounds
|
||||
|
||||
Access to the API endpoints can be blocked at a Reverse-proxy/Load balancer level to prevent this issue from being exploited.
|
||||
|
||||
### For more information
|
||||
|
||||
If you have any questions or comments about this advisory:
|
||||
|
||||
- Email us at [security@goauthentik.io](mailto:security@goauthentik.io)
|
@ -436,6 +436,9 @@ const docsSidebar = {
|
||||
},
|
||||
items: [
|
||||
"security/policy",
|
||||
"security/CVE-2024-42490",
|
||||
"security/CVE-2024-38371",
|
||||
"security/CVE-2024-37905",
|
||||
"security/CVE-2024-23647",
|
||||
"security/CVE-2024-21637",
|
||||
"security/CVE-2023-48228",
|
||||
|
Reference in New Issue
Block a user