Compare commits

..

6 Commits

Author SHA1 Message Date
def0a42bf1 release: 2022.10.4 2022-12-23 14:19:17 +01:00
727e55e44b web: backport API update
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-12-23 14:19:10 +01:00
cd88b91686 security: fix CVE 2022 23555 (#4274)
* add flow to invitation

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* show warning on invitation page

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add security advisory

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add tests

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-12-23 14:18:13 +01:00
8eb73d3a16 security: fix CVE 2022 46172 (#4275)
* fallback to current user in user_write, add flag to disable user creation

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* update api and web ui

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* update default flows

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add cve post to website

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

* add tests

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-12-23 14:18:09 +01:00
83f46f6ff1 release: 2022.10.3 2022-12-02 23:01:17 +02:00
0e7cc6da4c web: bump API version
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2022-12-02 22:51:09 +02:00
34 changed files with 440 additions and 110 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2022.10.2 current_version = 2022.10.4
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__ = "2022.10.2" __version__ = "2022.10.4"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -8,6 +8,7 @@ from rest_framework.viewsets import ModelViewSet
from authentik.core.api.groups import GroupMemberSerializer from authentik.core.api.groups import GroupMemberSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import is_dict from authentik.core.api.utils import is_dict
from authentik.flows.api.flows import FlowSerializer
from authentik.flows.api.stages import StageSerializer from authentik.flows.api.stages import StageSerializer
from authentik.stages.invitation.models import Invitation, InvitationStage from authentik.stages.invitation.models import Invitation, InvitationStage
@ -49,6 +50,7 @@ class InvitationSerializer(ModelSerializer):
created_by = GroupMemberSerializer(read_only=True) created_by = GroupMemberSerializer(read_only=True)
fixed_data = JSONField(validators=[is_dict], required=False) fixed_data = JSONField(validators=[is_dict], required=False)
flow_obj = FlowSerializer(read_only=True, required=False, source="flow")
class Meta: class Meta:
@ -60,6 +62,8 @@ class InvitationSerializer(ModelSerializer):
"fixed_data", "fixed_data",
"created_by", "created_by",
"single_use", "single_use",
"flow",
"flow_obj",
] ]
@ -69,8 +73,8 @@ class InvitationViewSet(UsedByMixin, ModelViewSet):
queryset = Invitation.objects.all() queryset = Invitation.objects.all()
serializer_class = InvitationSerializer serializer_class = InvitationSerializer
ordering = ["-expires"] ordering = ["-expires"]
search_fields = ["name", "created_by__username", "expires"] search_fields = ["name", "created_by__username", "expires", "flow__slug"]
filterset_fields = ["name", "created_by__username", "expires"] filterset_fields = ["name", "created_by__username", "expires", "flow__slug"]
def perform_create(self, serializer: InvitationSerializer): def perform_create(self, serializer: InvitationSerializer):
serializer.save(created_by=self.request.user) serializer.save(created_by=self.request.user)

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.4 on 2022-12-20 13:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0024_flow_authentication"),
("authentik_stages_invitation", "0001_squashed_0006_invitation_name"),
]
operations = [
migrations.AddField(
model_name="invitation",
name="flow",
field=models.ForeignKey(
default=None,
help_text="When set, only the configured flow can use this invitation.",
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_flows.flow",
),
),
]

View File

@ -55,6 +55,13 @@ class Invitation(SerializerModel, ExpiringModel):
name = models.SlugField() name = models.SlugField()
flow = models.ForeignKey(
"authentik_flows.Flow",
default=None,
null=True,
on_delete=models.SET_DEFAULT,
help_text=_("When set, only the configured flow can use this invitation."),
)
single_use = models.BooleanField( single_use = models.BooleanField(
default=False, default=False,
help_text=_("When enabled, the invitation will be deleted after usage."), help_text=_("When enabled, the invitation will be deleted after usage."),

View File

@ -37,22 +37,30 @@ class InvitationStageView(StageView):
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY_CONTEXT] return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY_CONTEXT]
return None return None
def get_invite(self) -> Optional[Invitation]:
"""Check the token, find the invite and check it's flow"""
token = self.get_token()
if not token:
return None
invite: Invitation = Invitation.objects.filter(pk=token).first()
if not invite:
self.logger.debug("invalid invitation", token=token)
return None
if invite.flow and invite.flow.pk != self.executor.plan.flow_pk:
self.logger.debug("invite for incorrect flow", expected=invite.flow.slug)
return None
return invite
def get(self, request: HttpRequest) -> HttpResponse: def get(self, request: HttpRequest) -> HttpResponse:
"""Apply data to the current flow based on a URL""" """Apply data to the current flow based on a URL"""
stage: InvitationStage = self.executor.current_stage stage: InvitationStage = self.executor.current_stage
token = self.get_token()
if not token: invite = self.get_invite()
# No Invitation was given, raise error or continue if not invite:
if stage.continue_flow_without_invitation: if stage.continue_flow_without_invitation:
return self.executor.stage_ok() return self.executor.stage_ok()
return self.executor.stage_invalid() return self.executor.stage_invalid()
invite: Invitation = Invitation.objects.filter(pk=token).first()
if not invite:
self.logger.debug("invalid invitation", token=token)
if stage.continue_flow_without_invitation:
return self.executor.stage_ok()
return self.executor.stage_invalid()
self.executor.plan.context[INVITATION_IN_EFFECT] = True self.executor.plan.context[INVITATION_IN_EFFECT] = True
self.executor.plan.context[INVITATION] = invite self.executor.plan.context[INVITATION] = invite

View File

@ -23,7 +23,7 @@ from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
class TestUserLoginStage(FlowTestCase): class TestInvitationStage(FlowTestCase):
"""Login tests""" """Login tests"""
def setUp(self): def setUp(self):
@ -98,6 +98,33 @@ class TestUserLoginStage(FlowTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
def test_invalid_flow(self):
"""Test with invitation, invalid flow limit"""
invalid_flow = create_test_flow(FlowDesignation.ENROLLMENT)
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
data = {"foo": "bar"}
invite = Invitation.objects.create(
created_by=get_anonymous_user(), fixed_data=data, flow=invalid_flow
)
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):
base_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
args = urlencode({INVITATION_TOKEN_KEY: invite.pk.hex})
response = self.client.get(base_url + f"?query={args}")
session = self.client.session
plan: FlowPlan = session[SESSION_KEY_PLAN]
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-access-denied",
)
def test_with_invitation_prompt_data(self): def test_with_invitation_prompt_data(self):
"""Test with invitation, check data in session""" """Test with invitation, check data in session"""
data = {"foo": "bar"} data = {"foo": "bar"}

View File

@ -15,6 +15,7 @@ class UserWriteStageSerializer(StageSerializer):
fields = StageSerializer.Meta.fields + [ fields = StageSerializer.Meta.fields + [
"create_users_as_inactive", "create_users_as_inactive",
"create_users_group", "create_users_group",
"can_create_users",
"user_path_template", "user_path_template",
] ]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.1.4 on 2022-12-22 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_user_write", "0005_userwritestage_user_path_template"),
]
operations = [
migrations.AddField(
model_name="userwritestage",
name="can_create_users",
field=models.BooleanField(
default=True,
help_text="When set, this stage can create users. If not enabled and no user is available, stage will fail.",
),
),
]

View File

@ -13,6 +13,16 @@ class UserWriteStage(Stage):
"""Writes currently pending data into the pending user, or if no user exists, """Writes currently pending data into the pending user, or if no user exists,
creates a new user with the data.""" creates a new user with the data."""
can_create_users = models.BooleanField(
default=True,
help_text=_(
(
"When set, this stage can create users. "
"If not enabled and no user is available, stage will fail."
)
),
)
create_users_as_inactive = models.BooleanField( create_users_as_inactive = models.BooleanField(
default=False, default=False,
help_text=_("When set, newly created users are inactive and cannot login."), help_text=_("When set, newly created users are inactive and cannot login."),

View File

@ -1,10 +1,9 @@
"""Write stage logic""" """Write stage logic"""
from typing import Any from typing import Any, Optional
from django.contrib import messages
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.db import transaction from django.db import transaction
from django.db.utils import IntegrityError from django.db.utils import IntegrityError, InternalError
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -47,7 +46,7 @@ class UserWriteStageView(StageView):
"""Wrapper for post requests""" """Wrapper for post requests"""
return self.get(request) return self.get(request)
def ensure_user(self) -> tuple[User, bool]: def ensure_user(self) -> tuple[Optional[User], bool]:
"""Ensure a user exists""" """Ensure a user exists"""
user_created = False user_created = False
path = self.executor.plan.context.get( path = self.executor.plan.context.get(
@ -55,7 +54,11 @@ class UserWriteStageView(StageView):
) )
if path == "": if path == "":
path = User.default_path() path = User.default_path()
if not self.request.user.is_anonymous:
self.executor.plan.context.setdefault(PLAN_CONTEXT_PENDING_USER, self.request.user)
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context: if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
if not self.executor.current_stage.can_create_users:
return None, False
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User( self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User(
is_active=not self.executor.current_stage.create_users_as_inactive, is_active=not self.executor.current_stage.create_users_as_inactive,
path=path, path=path,
@ -110,11 +113,14 @@ class UserWriteStageView(StageView):
a new user is created.""" a new user is created."""
if PLAN_CONTEXT_PROMPT not in self.executor.plan.context: if PLAN_CONTEXT_PROMPT not in self.executor.plan.context:
message = _("No Pending data.") message = _("No Pending data.")
messages.error(request, message)
self.logger.debug(message) self.logger.debug(message)
return self.executor.stage_invalid() return self.executor.stage_invalid(message)
data = self.executor.plan.context[PLAN_CONTEXT_PROMPT] data = self.executor.plan.context[PLAN_CONTEXT_PROMPT]
user, user_created = self.ensure_user() user, user_created = self.ensure_user()
if not user:
message = _("No user found and can't create new user.")
self.logger.info(message)
return self.executor.stage_invalid(message)
# Before we change anything, check if the user is the same as in the request # Before we change anything, check if the user is the same as in the request
# and we're updating a password. In that case we need to update the session hash # and we're updating a password. In that case we need to update the session hash
# Also check that we're not currently impersonating, so we don't update the session # Also check that we're not currently impersonating, so we don't update the session
@ -137,9 +143,9 @@ class UserWriteStageView(StageView):
user.ak_groups.add(self.executor.current_stage.create_users_group) user.ak_groups.add(self.executor.current_stage.create_users_group)
if PLAN_CONTEXT_GROUPS in self.executor.plan.context: if PLAN_CONTEXT_GROUPS in self.executor.plan.context:
user.ak_groups.add(*self.executor.plan.context[PLAN_CONTEXT_GROUPS]) user.ak_groups.add(*self.executor.plan.context[PLAN_CONTEXT_GROUPS])
except (IntegrityError, ValueError, TypeError) as exc: except (IntegrityError, ValueError, TypeError, InternalError) as exc:
self.logger.warning("Failed to save user", exc=exc) self.logger.warning("Failed to save user", exc=exc)
return self.executor.stage_invalid() return self.executor.stage_invalid(_("Failed to save user"))
user_write.send(sender=self, request=request, user=user, data=data, created=user_created) user_write.send(sender=self, request=request, user=user, data=data, created=user_created)
# Check if the password has been updated, and update the session auth hash # Check if the password has been updated, and update the session auth hash
if should_update_session: if should_update_session:

View File

@ -1,6 +1,4 @@
"""write tests""" """write tests"""
import string
from random import SystemRandom
from unittest.mock import patch from unittest.mock import patch
from django.urls import reverse from django.urls import reverse
@ -14,6 +12,7 @@ from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase from authentik.flows.tests import FlowTestCase
from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK from authentik.flows.tests.test_executor import TO_STAGE_RESPONSE_MOCK
from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_key
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_write.models import UserWriteStage from authentik.stages.user_write.models import UserWriteStage
from authentik.stages.user_write.stage import PLAN_CONTEXT_GROUPS, UserWriteStageView from authentik.stages.user_write.stage import PLAN_CONTEXT_GROUPS, UserWriteStageView
@ -32,12 +31,11 @@ class TestUserWriteStage(FlowTestCase):
) )
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
self.source = Source.objects.create(name="fake_source") self.source = Source.objects.create(name="fake_source")
self.user = create_test_admin_user()
def test_user_create(self): def test_user_create(self):
"""Test creation of user""" """Test creation of user"""
password = "".join( password = generate_key()
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(8)
)
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()])
plan.context[PLAN_CONTEXT_PROMPT] = { plan.context[PLAN_CONTEXT_PROMPT] = {
@ -66,9 +64,7 @@ class TestUserWriteStage(FlowTestCase):
def test_user_update(self): def test_user_update(self):
"""Test update of existing user""" """Test update of existing user"""
new_password = "".join( new_password = generate_key()
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(8)
)
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()])
plan.context[PLAN_CONTEXT_PENDING_USER] = User.objects.create( plan.context[PLAN_CONTEXT_PENDING_USER] = User.objects.create(
username="unittest", email="test@goauthentik.io" username="unittest", email="test@goauthentik.io"
@ -142,6 +138,49 @@ class TestUserWriteStage(FlowTestCase):
component="ak-stage-access-denied", component="ak-stage-access-denied",
) )
def test_authenticated_no_user(self):
"""Test user in session and none in plan"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
self.client.force_login(self.user)
session = self.client.session
plan.context[PLAN_CONTEXT_PROMPT] = {
"username": "foo",
"attribute_some-custom-attribute": "test",
"some_ignored_attribute": "bar",
}
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.user.refresh_from_db()
self.assertEqual(self.user.username, "foo")
def test_no_create(self):
"""Test can_create_users set to false"""
self.stage.can_create_users = False
self.stage.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
session = self.client.session
plan.context[PLAN_CONTEXT_PROMPT] = {
"username": "foo",
"attribute_some-custom-attribute": "test",
"some_ignored_attribute": "bar",
}
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
self.assertStageResponse(
response,
self.flow,
component="ak-stage-access-denied",
)
@patch( @patch(
"authentik.flows.views.executor.to_stage_response", "authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK, TO_STAGE_RESPONSE_MOCK,

View File

@ -45,6 +45,8 @@ entries:
name: default-password-change-write name: default-password-change-write
id: default-password-change-write id: default-password-change-write
model: authentik_stages_user_write.userwritestage model: authentik_stages_user_write.userwritestage
attrs:
can_create_users: false
- identifiers: - identifiers:
order: 0 order: 0
stage: !KeyOf default-password-change-prompt stage: !KeyOf default-password-change-prompt

View File

@ -57,6 +57,8 @@ entries:
name: default-source-enrollment-write name: default-source-enrollment-write
id: default-source-enrollment-write id: default-source-enrollment-write
model: authentik_stages_user_write.userwritestage model: authentik_stages_user_write.userwritestage
attrs:
can_create_users: true
- attrs: - attrs:
re_evaluate_policies: true re_evaluate_policies: true
identifiers: identifiers:

View File

@ -109,6 +109,8 @@ entries:
model: authentik_policies_expression.expressionpolicy model: authentik_policies_expression.expressionpolicy
- identifiers: - identifiers:
name: default-user-settings-write name: default-user-settings-write
attrs:
can_create_users: false
id: default-user-settings-write id: default-user-settings-write
model: authentik_stages_user_write.userwritestage model: authentik_stages_user_write.userwritestage
- attrs: - attrs:

View File

@ -102,6 +102,8 @@ entries:
identifiers: identifiers:
name: default-password-change-write name: default-password-change-write
model: authentik_stages_user_write.userwritestage model: authentik_stages_user_write.userwritestage
attrs:
can_create_users: false
- attrs: - attrs:
evaluate_on_plan: true evaluate_on_plan: true
invalid_response_action: retry invalid_response_action: retry

View File

@ -95,7 +95,8 @@ entries:
name: default-enrollment-user-write name: default-enrollment-user-write
id: default-enrollment-user-write id: default-enrollment-user-write
model: authentik_stages_user_write.userwritestage model: authentik_stages_user_write.userwritestage
attrs: {} attrs:
can_create_users: true
- identifiers: - identifiers:
target: !KeyOf flow target: !KeyOf flow
stage: !KeyOf default-enrollment-prompt-first stage: !KeyOf default-enrollment-prompt-first

View File

@ -114,6 +114,7 @@ entries:
model: authentik_stages_user_write.userwritestage model: authentik_stages_user_write.userwritestage
attrs: attrs:
create_users_as_inactive: true create_users_as_inactive: true
can_create_users: true
- identifiers: - identifiers:
target: !KeyOf flow target: !KeyOf flow
stage: !KeyOf default-enrollment-prompt-first stage: !KeyOf default-enrollment-prompt-first

View File

@ -63,6 +63,8 @@ entries:
name: default-recovery-user-write name: default-recovery-user-write
id: default-recovery-user-write id: default-recovery-user-write
model: authentik_stages_user_write.userwritestage model: authentik_stages_user_write.userwritestage
attrs:
can_create_users: false
- identifiers: - identifiers:
name: default-recovery-identification name: default-recovery-identification
id: default-recovery-identification id: default-recovery-identification

View File

@ -32,7 +32,7 @@ services:
volumes: volumes:
- redis:/data - redis:/data
server: server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.10.2} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.10.4}
restart: unless-stopped restart: unless-stopped
command: server command: server
environment: environment:
@ -52,7 +52,7 @@ services:
- "0.0.0.0:${AUTHENTIK_PORT_HTTP:-9000}:9000" - "0.0.0.0:${AUTHENTIK_PORT_HTTP:-9000}:9000"
- "0.0.0.0:${AUTHENTIK_PORT_HTTPS:-9443}:9443" - "0.0.0.0:${AUTHENTIK_PORT_HTTPS:-9443}:9443"
worker: worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.10.2} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2022.10.4}
restart: unless-stopped restart: unless-stopped
command: worker command: worker
environment: environment:

View File

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

View File

@ -100,7 +100,7 @@ addopts = "-p no:celery --junitxml=unittest.xml"
[tool.poetry] [tool.poetry]
name = "authentik" name = "authentik"
version = "2022.10.2" version = "2022.10.4"
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: 2022.10.2 version: 2022.10.4
description: Making authentication simple. description: Making authentication simple.
contact: contact:
email: hello@goauthentik.io email: hello@goauthentik.io
@ -22277,6 +22277,10 @@ paths:
schema: schema:
type: string type: string
format: date-time format: date-time
- in: query
name: flow__slug
schema:
type: string
- in: query - in: query
name: name name: name
schema: schema:
@ -24520,6 +24524,10 @@ paths:
operationId: stages_user_write_list operationId: stages_user_write_list
description: UserWriteStage Viewset description: UserWriteStage Viewset
parameters: parameters:
- in: query
name: can_create_users
schema:
type: boolean
- in: query - in: query
name: create_users_as_inactive name: create_users_as_inactive
schema: schema:
@ -28387,8 +28395,18 @@ components:
single_use: single_use:
type: boolean type: boolean
description: When enabled, the invitation will be deleted after usage. description: When enabled, the invitation will be deleted after usage.
flow:
type: string
format: uuid
nullable: true
description: When set, only the configured flow can use this invitation.
flow_obj:
allOf:
- $ref: '#/components/schemas/Flow'
readOnly: true
required: required:
- created_by - created_by
- flow_obj
- name - name
- pk - pk
InvitationRequest: InvitationRequest:
@ -28409,6 +28427,11 @@ components:
single_use: single_use:
type: boolean type: boolean
description: When enabled, the invitation will be deleted after usage. description: When enabled, the invitation will be deleted after usage.
flow:
type: string
format: uuid
nullable: true
description: When set, only the configured flow can use this invitation.
required: required:
- name - name
InvitationStage: InvitationStage:
@ -33704,6 +33727,11 @@ components:
single_use: single_use:
type: boolean type: boolean
description: When enabled, the invitation will be deleted after usage. description: When enabled, the invitation will be deleted after usage.
flow:
type: string
format: uuid
nullable: true
description: When set, only the configured flow can use this invitation.
PatchedInvitationStageRequest: PatchedInvitationStageRequest:
type: object type: object
description: InvitationStage Serializer description: InvitationStage Serializer
@ -34907,6 +34935,10 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Optionally add newly created users to this group. description: Optionally add newly created users to this group.
can_create_users:
type: boolean
description: When set, this stage can create users. If not enabled and no
user is available, stage will fail.
user_path_template: user_path_template:
type: string type: string
PatchedWebAuthnDeviceRequest: PatchedWebAuthnDeviceRequest:
@ -38045,6 +38077,10 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Optionally add newly created users to this group. description: Optionally add newly created users to this group.
can_create_users:
type: boolean
description: When set, this stage can create users. If not enabled and no
user is available, stage will fail.
user_path_template: user_path_template:
type: string type: string
required: required:
@ -38073,6 +38109,10 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: Optionally add newly created users to this group. description: Optionally add newly created users to this group.
can_create_users:
type: boolean
description: When set, this stage can create users. If not enabled and no
user is available, stage will fail.
user_path_template: user_path_template:
type: string type: string
required: required:

14
web/package-lock.json generated
View File

@ -21,7 +21,7 @@
"@codemirror/legacy-modes": "^6.2.0", "@codemirror/legacy-modes": "^6.2.0",
"@formatjs/intl-listformat": "^7.1.3", "@formatjs/intl-listformat": "^7.1.3",
"@fortawesome/fontawesome-free": "^6.2.0", "@fortawesome/fontawesome-free": "^6.2.0",
"@goauthentik/api": "^2022.10.0-1666383274", "@goauthentik/api": "^2022.11.3-1671801250",
"@jackfranklin/rollup-plugin-markdown": "^0.4.0", "@jackfranklin/rollup-plugin-markdown": "^0.4.0",
"@lingui/cli": "^3.14.0", "@lingui/cli": "^3.14.0",
"@lingui/core": "^3.14.0", "@lingui/core": "^3.14.0",
@ -1941,9 +1941,9 @@
} }
}, },
"node_modules/@goauthentik/api": { "node_modules/@goauthentik/api": {
"version": "2022.10.0-1666383274", "version": "2022.11.3-1671801250",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2022.10.0-1666383274.tgz", "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2022.11.3-1671801250.tgz",
"integrity": "sha512-mwBT/bTpX4cSDxy6tQgaoHIzzWqpwIXSuv16knNqjk5emquckFOI1QhurB1D2MxbvrMETX8jfJWaE+LrE/cACA==" "integrity": "sha512-8N6C0IPCuZQ9y5crIDJ/AeC0VlxAa3lW9mFRZ4f8g2T9FIoZUqLuxF4Uy5WW1k+IK0M0JOkuVaj2iHipaGb5Zw=="
}, },
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.6", "version": "0.11.6",
@ -11620,9 +11620,9 @@
"integrity": "sha512-CNR7qRIfCwWHNN7FnKUniva94edPdyQzil/zCwk3v6k4R6rR2Fr8i4s3PM7n/lyfPA6Zfko9z5WDzFxG9SW1uQ==" "integrity": "sha512-CNR7qRIfCwWHNN7FnKUniva94edPdyQzil/zCwk3v6k4R6rR2Fr8i4s3PM7n/lyfPA6Zfko9z5WDzFxG9SW1uQ=="
}, },
"@goauthentik/api": { "@goauthentik/api": {
"version": "2022.10.0-1666383274", "version": "2022.11.3-1671801250",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2022.10.0-1666383274.tgz", "resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2022.11.3-1671801250.tgz",
"integrity": "sha512-mwBT/bTpX4cSDxy6tQgaoHIzzWqpwIXSuv16knNqjk5emquckFOI1QhurB1D2MxbvrMETX8jfJWaE+LrE/cACA==" "integrity": "sha512-8N6C0IPCuZQ9y5crIDJ/AeC0VlxAa3lW9mFRZ4f8g2T9FIoZUqLuxF4Uy5WW1k+IK0M0JOkuVaj2iHipaGb5Zw=="
}, },
"@humanwhocodes/config-array": { "@humanwhocodes/config-array": {
"version": "0.11.6", "version": "0.11.6",

View File

@ -64,7 +64,7 @@
"@codemirror/legacy-modes": "^6.2.0", "@codemirror/legacy-modes": "^6.2.0",
"@formatjs/intl-listformat": "^7.1.3", "@formatjs/intl-listformat": "^7.1.3",
"@fortawesome/fontawesome-free": "^6.2.0", "@fortawesome/fontawesome-free": "^6.2.0",
"@goauthentik/api": "^2022.10.0-1666383274", "@goauthentik/api": "^2022.11.3-1671801250",
"@jackfranklin/rollup-plugin-markdown": "^0.4.0", "@jackfranklin/rollup-plugin-markdown": "^0.4.0",
"@lingui/cli": "^3.14.0", "@lingui/cli": "^3.14.0",
"@lingui/core": "^3.14.0", "@lingui/core": "^3.14.0",

View File

@ -9,8 +9,15 @@ import { t } from "@lingui/macro";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { until } from "lit/directives/until.js";
import { Invitation, StagesApi } from "@goauthentik/api"; import {
FlowsApi,
FlowsInstancesListDesignationEnum,
Invitation,
StagesApi,
} from "@goauthentik/api";
@customElement("ak-invitation-form") @customElement("ak-invitation-form")
export class InvitationForm extends ModelForm<Invitation, string> { export class InvitationForm extends ModelForm<Invitation, string> {
@ -66,6 +73,34 @@ export class InvitationForm extends ModelForm<Invitation, string> {
value="${dateTimeLocal(first(this.instance?.expires, new Date()))}" value="${dateTimeLocal(first(this.instance?.expires, new Date()))}"
/> />
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Flow`} ?required=${true} name="flow">
<select class="pf-c-form-control">
<option value="" ?selected=${this.instance?.flow === undefined}>
---------
</option>
${until(
new FlowsApi(DEFAULT_CONFIG)
.flowsInstancesList({
ordering: "slug",
designation: FlowsInstancesListDesignationEnum.Enrollment,
})
.then((flows) => {
return flows.results.map((flow) => {
return html`<option
value=${ifDefined(flow.pk)}
?selected=${this.instance?.flow === flow.pk}
>
${flow.name} (${flow.slug})
</option>`;
});
}),
html`<option>${t`Loading...`}</option>`,
)}
</select>
<p class="pf-c-form__helper-text">
${t`When selected, the invite will only be usable with the flow. By default the invite is accepted on all flows with invitation stages.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${t`Attributes`} name="fixedData"> <ak-form-element-horizontal label=${t`Attributes`} name="fixedData">
<ak-codemirror <ak-codemirror
mode="yaml" mode="yaml"

View File

@ -14,12 +14,12 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css"; import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { StagesApi } from "@goauthentik/api"; import { Invitation, StagesApi } from "@goauthentik/api";
@customElement("ak-stage-invitation-list-link") @customElement("ak-stage-invitation-list-link")
export class InvitationListLink extends AKElement { export class InvitationListLink extends AKElement {
@property() @property({ attribute: false })
invitation?: string; invitation?: Invitation;
@property() @property()
selectedFlow?: string; selectedFlow?: string;
@ -29,12 +29,14 @@ export class InvitationListLink extends AKElement {
} }
renderLink(): string { renderLink(): string {
return `${window.location.protocol}//${window.location.host}/if/flow/${this.selectedFlow}/?itoken=${this.invitation}`; if (this.invitation?.flowObj) {
this.selectedFlow = this.invitation.flowObj?.slug;
}
return `${window.location.protocol}//${window.location.host}/if/flow/${this.selectedFlow}/?itoken=${this.invitation?.pk}`;
} }
render(): TemplateResult { renderFlowSelector(): TemplateResult {
return html`<dl class="pf-c-description-list pf-m-horizontal"> return html`<div class="pf-c-description-list__group">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text">${t`Select an enrollment flow`}</span> <span class="pf-c-description-list__text">${t`Select an enrollment flow`}</span>
</dt> </dt>
@ -82,7 +84,12 @@ export class InvitationListLink extends AKElement {
</select> </select>
</div> </div>
</dd> </dd>
</div> </div>`;
}
render(): TemplateResult {
return html`<dl class="pf-c-description-list pf-m-horizontal">
${this.invitation?.flow === undefined ? this.renderFlowSelector() : html``}
<div class="pf-c-description-list__group"> <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term"> <dt class="pf-c-description-list__term">
<span class="pf-c-description-list__text" <span class="pf-c-description-list__text"

View File

@ -2,6 +2,7 @@ import "@goauthentik/admin/stages/invitation/InvitationForm";
import "@goauthentik/admin/stages/invitation/InvitationListLink"; import "@goauthentik/admin/stages/invitation/InvitationListLink";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config"; import { uiConfig } from "@goauthentik/common/ui/config";
import { PFColor } from "@goauthentik/elements/Label";
import "@goauthentik/elements/buttons/ModalButton"; import "@goauthentik/elements/buttons/ModalButton";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
@ -18,7 +19,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import { Invitation, StagesApi } from "@goauthentik/api"; import { FlowDesignationEnum, Invitation, StagesApi } from "@goauthentik/api";
@customElement("ak-stage-invitation-list") @customElement("ak-stage-invitation-list")
export class InvitationListPage extends TablePage<Invitation> { export class InvitationListPage extends TablePage<Invitation> {
@ -49,12 +50,24 @@ export class InvitationListPage extends TablePage<Invitation> {
@state() @state()
invitationStageExists = false; invitationStageExists = false;
@state()
multipleEnrollmentFlows = false;
async apiEndpoint(page: number): Promise<PaginatedResponse<Invitation>> { async apiEndpoint(page: number): Promise<PaginatedResponse<Invitation>> {
// Check if any invitation stages exist
const stages = await new StagesApi(DEFAULT_CONFIG).stagesInvitationStagesList({ const stages = await new StagesApi(DEFAULT_CONFIG).stagesInvitationStagesList({
noFlows: false, noFlows: false,
}); });
this.invitationStageExists = stages.pagination.count > 0; this.invitationStageExists = stages.pagination.count > 0;
this.expandable = this.invitationStageExists; this.expandable = this.invitationStageExists;
stages.results.forEach((stage) => {
const enrollmentFlows = (stage.flowSet || []).filter(
(flow) => flow.designation === FlowDesignationEnum.Enrollment,
);
if (enrollmentFlows.length > 1) {
this.multipleEnrollmentFlows = true;
}
});
return new StagesApi(DEFAULT_CONFIG).stagesInvitationInvitationsList({ return new StagesApi(DEFAULT_CONFIG).stagesInvitationInvitationsList({
ordering: this.order, ordering: this.order,
page: page, page: page,
@ -96,7 +109,14 @@ export class InvitationListPage extends TablePage<Invitation> {
row(item: Invitation): TemplateResult[] { row(item: Invitation): TemplateResult[] {
return [ return [
html`${item.name}`, html`<div>${item.name}</div>
${!item.flowObj && this.multipleEnrollmentFlows
? html`
<ak-label color=${PFColor.Orange}>
${t`Invitation not limited to any flow, and can be used with any enrollment flow.`}
</ak-label>
`
: html``}`,
html`${item.createdBy?.username}`, html`${item.createdBy?.username}`,
html`${item.expires?.toLocaleString() || t`-`}`, html`${item.expires?.toLocaleString() || t`-`}`,
html` <ak-forms-modal> html` <ak-forms-modal>
@ -114,7 +134,7 @@ export class InvitationListPage extends TablePage<Invitation> {
return html` <td role="cell" colspan="3"> return html` <td role="cell" colspan="3">
<div class="pf-c-table__expandable-row-content"> <div class="pf-c-table__expandable-row-content">
<ak-stage-invitation-list-link <ak-stage-invitation-list-link
invitation=${item.pk} .invitation=${item}
></ak-stage-invitation-list-link> ></ak-stage-invitation-list-link>
</div> </div>
</td> </td>

View File

@ -59,6 +59,21 @@ export class UserWriteStageForm extends ModelForm<UserWriteStage, string> {
<ak-form-group .expanded=${true}> <ak-form-group .expanded=${true}>
<span slot="header"> ${t`Stage-specific settings`} </span> <span slot="header"> ${t`Stage-specific settings`} </span>
<div slot="body" class="pf-c-form"> <div slot="body" class="pf-c-form">
<ak-form-element-horizontal name="canCreateUsers">
<div class="pf-c-check">
<input
type="checkbox"
class="pf-c-check__input"
?checked=${first(this.instance?.canCreateUsers, false)}
/>
<label class="pf-c-check__label">
${t`Can create users`}
</label>
</div>
<p class="pf-c-form__helper-text">
${t`When enabled, this stage has the ability to create new users. If no user is available in the flow with this disabled, the stage will fail.`}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="createUsersAsInactive"> <ak-form-element-horizontal name="createUsersAsInactive">
<div class="pf-c-check"> <div class="pf-c-check">
<input <input

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 = "2022.10.2"; export const VERSION = "2022.10.4";
export const TITLE_DEFAULT = "authentik"; export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";"; export const ROUTE_SEPARATOR = ";";

View File

@ -98,13 +98,6 @@ export class UserSettingsFlowExecutor extends AKElement implements StageHost {
if (!this.flowSlug) { if (!this.flowSlug) {
return; return;
} }
new FlowsApi(DEFAULT_CONFIG)
.flowsInstancesExecuteRetrieve({
slug: this.flowSlug || "",
})
.then(() => {
this.nextChallenge();
});
}); });
} }

View File

@ -0,0 +1,29 @@
# CVE-2022-23555
## Token reuse in invitation URLs leads to access control bypass via the use of a different enrollment flow
### Summary
Token reuse in invitation URLs leads to access control bypass via the use of a different enrollment flow than in the one provided.
### Patches
authentik 2022.11.4, 2022.10.4 and 2022.12.0 fix this issue, for other versions the workaround can be used.
### Impact
Only configurations using both invitations and have multiple enrollment flows with invitation stages that grant different permissions are affected. The default configuration is not vulnerable, and neither are configurations with a single enrollment flow.
### Details
The vulnerability allows an attacker that knows different invitation flows names (e.g. `enrollment-invitation-test` and `enrollment-invitation-admin`) via either different invite links or via brute forcing to signup via a single invitation url for any valid invite link received (it can even be a url for a third flow as long as it's a valid invite) as the token used in the `Invitations` section of the Admin interface does NOT change when a different `enrollment flow` is selected via the interface and it is NOT bound to the selected flow, so it will be valid for any flow when used.
### Workarounds
As a workaround, fixed data can be added to invitations which can be checked in the flow to deny requests. Alternatively, an identifier with high entropy (like a UUID) can be used as flow slug, mitigating the attack vector by exponentially decreasing the possibility of discovering other flows.
### 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

@ -0,0 +1,25 @@
# CVE-2022-46172
## Existing Authenticated Users can Create Arbitrary Accounts
### Summary
Any authenticated user can create an arbitrary number of accounts through the default flows. This would circumvent any policy in a situation where it is undesirable for users to create new accounts by themselves. This may also have carry over consequences to other applications being how these new basic accounts would exist throughout the SSO infrastructure. By default the newly created accounts cannot be logged into as no password reset exists by default. However password resets are likely to be enabled by most installations.
### Patches
authentik 2022.11.4, 2022.10.4 and 2022.12.0 fix this issue.
### Impact
This vulnerability could make it much easier for name and email collisions to occur, making it harder for user to log in. This also makes it more difficult for admins to properly administer users since more and more confusing users will exist. This paired with password reset flows if enabled would mean a circumvention of on-boarding policies. Say for instance a company wanted to invite a limited number of beta testers, those beta testers would be able to create an arbitrary number of accounts themselves.
### Details
This vulnerability has already been submitted over email, this security advisory serves as formalization towards broader information dissemination. This vulnerability pertains to the user context used in the default-user-settings-flow. /api/v3/flows/instances/default-user-settings-flow/execute/
### 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

@ -289,7 +289,12 @@ module.exports = {
title: "Security", title: "Security",
slug: "security", slug: "security",
}, },
items: ["security/policy", "security/CVE-2022-46145"], items: [
"security/policy",
"security/CVE-2022-46145",
"security/CVE-2022-46172",
"security/CVE-2022-23555",
],
}, },
], ],
}; };