Compare commits
6 Commits
version/20
...
version-20
Author | SHA1 | Date | |
---|---|---|---|
def0a42bf1 | |||
727e55e44b | |||
cd88b91686 | |||
8eb73d3a16 | |||
83f46f6ff1 | |||
0e7cc6da4c |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2022.10.2
|
||||
current_version = 2022.10.4
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||
|
@ -2,7 +2,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2022.10.2"
|
||||
__version__ = "2022.10.4"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -8,6 +8,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import is_dict
|
||||
from authentik.flows.api.flows import FlowSerializer
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
from authentik.stages.invitation.models import Invitation, InvitationStage
|
||||
|
||||
@ -49,6 +50,7 @@ class InvitationSerializer(ModelSerializer):
|
||||
|
||||
created_by = GroupMemberSerializer(read_only=True)
|
||||
fixed_data = JSONField(validators=[is_dict], required=False)
|
||||
flow_obj = FlowSerializer(read_only=True, required=False, source="flow")
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -60,6 +62,8 @@ class InvitationSerializer(ModelSerializer):
|
||||
"fixed_data",
|
||||
"created_by",
|
||||
"single_use",
|
||||
"flow",
|
||||
"flow_obj",
|
||||
]
|
||||
|
||||
|
||||
@ -69,8 +73,8 @@ class InvitationViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = Invitation.objects.all()
|
||||
serializer_class = InvitationSerializer
|
||||
ordering = ["-expires"]
|
||||
search_fields = ["name", "created_by__username", "expires"]
|
||||
filterset_fields = ["name", "created_by__username", "expires"]
|
||||
search_fields = ["name", "created_by__username", "expires", "flow__slug"]
|
||||
filterset_fields = ["name", "created_by__username", "expires", "flow__slug"]
|
||||
|
||||
def perform_create(self, serializer: InvitationSerializer):
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
@ -55,6 +55,13 @@ class Invitation(SerializerModel, ExpiringModel):
|
||||
|
||||
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(
|
||||
default=False,
|
||||
help_text=_("When enabled, the invitation will be deleted after usage."),
|
||||
|
@ -37,22 +37,30 @@ class InvitationStageView(StageView):
|
||||
return self.executor.plan.context[PLAN_CONTEXT_PROMPT][INVITATION_TOKEN_KEY_CONTEXT]
|
||||
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:
|
||||
"""Apply data to the current flow based on a URL"""
|
||||
stage: InvitationStage = self.executor.current_stage
|
||||
token = self.get_token()
|
||||
if not token:
|
||||
# No Invitation was given, raise error or continue
|
||||
|
||||
invite = self.get_invite()
|
||||
if not invite:
|
||||
if stage.continue_flow_without_invitation:
|
||||
return self.executor.stage_ok()
|
||||
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] = invite
|
||||
|
||||
|
@ -23,7 +23,7 @@ from authentik.stages.password import BACKEND_INBUILT
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||
|
||||
|
||||
class TestUserLoginStage(FlowTestCase):
|
||||
class TestInvitationStage(FlowTestCase):
|
||||
"""Login tests"""
|
||||
|
||||
def setUp(self):
|
||||
@ -98,6 +98,33 @@ class TestUserLoginStage(FlowTestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
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):
|
||||
"""Test with invitation, check data in session"""
|
||||
data = {"foo": "bar"}
|
||||
|
@ -15,6 +15,7 @@ class UserWriteStageSerializer(StageSerializer):
|
||||
fields = StageSerializer.Meta.fields + [
|
||||
"create_users_as_inactive",
|
||||
"create_users_group",
|
||||
"can_create_users",
|
||||
"user_path_template",
|
||||
]
|
||||
|
||||
|
@ -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.",
|
||||
),
|
||||
),
|
||||
]
|
@ -13,6 +13,16 @@ class UserWriteStage(Stage):
|
||||
"""Writes currently pending data into the pending user, or if no user exists,
|
||||
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(
|
||||
default=False,
|
||||
help_text=_("When set, newly created users are inactive and cannot login."),
|
||||
|
@ -1,10 +1,9 @@
|
||||
"""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.db import transaction
|
||||
from django.db.utils import IntegrityError
|
||||
from django.db.utils import IntegrityError, InternalError
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@ -47,7 +46,7 @@ class UserWriteStageView(StageView):
|
||||
"""Wrapper for post requests"""
|
||||
return self.get(request)
|
||||
|
||||
def ensure_user(self) -> tuple[User, bool]:
|
||||
def ensure_user(self) -> tuple[Optional[User], bool]:
|
||||
"""Ensure a user exists"""
|
||||
user_created = False
|
||||
path = self.executor.plan.context.get(
|
||||
@ -55,7 +54,11 @@ class UserWriteStageView(StageView):
|
||||
)
|
||||
if 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 not self.executor.current_stage.can_create_users:
|
||||
return None, False
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User(
|
||||
is_active=not self.executor.current_stage.create_users_as_inactive,
|
||||
path=path,
|
||||
@ -110,11 +113,14 @@ class UserWriteStageView(StageView):
|
||||
a new user is created."""
|
||||
if PLAN_CONTEXT_PROMPT not in self.executor.plan.context:
|
||||
message = _("No Pending data.")
|
||||
messages.error(request, message)
|
||||
self.logger.debug(message)
|
||||
return self.executor.stage_invalid()
|
||||
return self.executor.stage_invalid(message)
|
||||
data = self.executor.plan.context[PLAN_CONTEXT_PROMPT]
|
||||
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
|
||||
# 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
|
||||
@ -137,9 +143,9 @@ class UserWriteStageView(StageView):
|
||||
user.ak_groups.add(self.executor.current_stage.create_users_group)
|
||||
if PLAN_CONTEXT_GROUPS in self.executor.plan.context:
|
||||
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)
|
||||
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)
|
||||
# Check if the password has been updated, and update the session auth hash
|
||||
if should_update_session:
|
||||
|
@ -1,6 +1,4 @@
|
||||
"""write tests"""
|
||||
import string
|
||||
from random import SystemRandom
|
||||
from unittest.mock import patch
|
||||
|
||||
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.test_executor import TO_STAGE_RESPONSE_MOCK
|
||||
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.user_write.models import UserWriteStage
|
||||
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.source = Source.objects.create(name="fake_source")
|
||||
self.user = create_test_admin_user()
|
||||
|
||||
def test_user_create(self):
|
||||
"""Test creation of user"""
|
||||
password = "".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(8)
|
||||
)
|
||||
password = generate_key()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PROMPT] = {
|
||||
@ -66,9 +64,7 @@ class TestUserWriteStage(FlowTestCase):
|
||||
|
||||
def test_user_update(self):
|
||||
"""Test update of existing user"""
|
||||
new_password = "".join(
|
||||
SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(8)
|
||||
)
|
||||
new_password = generate_key()
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = User.objects.create(
|
||||
username="unittest", email="test@goauthentik.io"
|
||||
@ -142,6 +138,49 @@ class TestUserWriteStage(FlowTestCase):
|
||||
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(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
|
@ -45,6 +45,8 @@ entries:
|
||||
name: default-password-change-write
|
||||
id: default-password-change-write
|
||||
model: authentik_stages_user_write.userwritestage
|
||||
attrs:
|
||||
can_create_users: false
|
||||
- identifiers:
|
||||
order: 0
|
||||
stage: !KeyOf default-password-change-prompt
|
||||
|
@ -57,6 +57,8 @@ entries:
|
||||
name: default-source-enrollment-write
|
||||
id: default-source-enrollment-write
|
||||
model: authentik_stages_user_write.userwritestage
|
||||
attrs:
|
||||
can_create_users: true
|
||||
- attrs:
|
||||
re_evaluate_policies: true
|
||||
identifiers:
|
||||
|
@ -109,6 +109,8 @@ entries:
|
||||
model: authentik_policies_expression.expressionpolicy
|
||||
- identifiers:
|
||||
name: default-user-settings-write
|
||||
attrs:
|
||||
can_create_users: false
|
||||
id: default-user-settings-write
|
||||
model: authentik_stages_user_write.userwritestage
|
||||
- attrs:
|
||||
|
@ -102,6 +102,8 @@ entries:
|
||||
identifiers:
|
||||
name: default-password-change-write
|
||||
model: authentik_stages_user_write.userwritestage
|
||||
attrs:
|
||||
can_create_users: false
|
||||
- attrs:
|
||||
evaluate_on_plan: true
|
||||
invalid_response_action: retry
|
||||
|
@ -95,7 +95,8 @@ entries:
|
||||
name: default-enrollment-user-write
|
||||
id: default-enrollment-user-write
|
||||
model: authentik_stages_user_write.userwritestage
|
||||
attrs: {}
|
||||
attrs:
|
||||
can_create_users: true
|
||||
- identifiers:
|
||||
target: !KeyOf flow
|
||||
stage: !KeyOf default-enrollment-prompt-first
|
||||
|
@ -114,6 +114,7 @@ entries:
|
||||
model: authentik_stages_user_write.userwritestage
|
||||
attrs:
|
||||
create_users_as_inactive: true
|
||||
can_create_users: true
|
||||
- identifiers:
|
||||
target: !KeyOf flow
|
||||
stage: !KeyOf default-enrollment-prompt-first
|
||||
|
@ -63,6 +63,8 @@ entries:
|
||||
name: default-recovery-user-write
|
||||
id: default-recovery-user-write
|
||||
model: authentik_stages_user_write.userwritestage
|
||||
attrs:
|
||||
can_create_users: false
|
||||
- identifiers:
|
||||
name: default-recovery-identification
|
||||
id: default-recovery-identification
|
||||
|
@ -32,7 +32,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
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
|
||||
command: server
|
||||
environment:
|
||||
@ -52,7 +52,7 @@ services:
|
||||
- "0.0.0.0:${AUTHENTIK_PORT_HTTP:-9000}:9000"
|
||||
- "0.0.0.0:${AUTHENTIK_PORT_HTTPS:-9443}:9443"
|
||||
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
|
||||
command: worker
|
||||
environment:
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2022.10.2"
|
||||
const VERSION = "2022.10.4"
|
||||
|
@ -100,7 +100,7 @@ addopts = "-p no:celery --junitxml=unittest.xml"
|
||||
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2022.10.2"
|
||||
version = "2022.10.4"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
42
schema.yml
42
schema.yml
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2022.10.2
|
||||
version: 2022.10.4
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@ -22277,6 +22277,10 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
- in: query
|
||||
name: flow__slug
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: name
|
||||
schema:
|
||||
@ -24520,6 +24524,10 @@ paths:
|
||||
operationId: stages_user_write_list
|
||||
description: UserWriteStage Viewset
|
||||
parameters:
|
||||
- in: query
|
||||
name: can_create_users
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: create_users_as_inactive
|
||||
schema:
|
||||
@ -28387,8 +28395,18 @@ components:
|
||||
single_use:
|
||||
type: boolean
|
||||
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:
|
||||
- created_by
|
||||
- flow_obj
|
||||
- name
|
||||
- pk
|
||||
InvitationRequest:
|
||||
@ -28409,6 +28427,11 @@ components:
|
||||
single_use:
|
||||
type: boolean
|
||||
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:
|
||||
- name
|
||||
InvitationStage:
|
||||
@ -33704,6 +33727,11 @@ components:
|
||||
single_use:
|
||||
type: boolean
|
||||
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:
|
||||
type: object
|
||||
description: InvitationStage Serializer
|
||||
@ -34907,6 +34935,10 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
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:
|
||||
type: string
|
||||
PatchedWebAuthnDeviceRequest:
|
||||
@ -38045,6 +38077,10 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
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:
|
||||
type: string
|
||||
required:
|
||||
@ -38073,6 +38109,10 @@ components:
|
||||
format: uuid
|
||||
nullable: true
|
||||
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:
|
||||
type: string
|
||||
required:
|
||||
|
14
web/package-lock.json
generated
14
web/package-lock.json
generated
@ -21,7 +21,7 @@
|
||||
"@codemirror/legacy-modes": "^6.2.0",
|
||||
"@formatjs/intl-listformat": "^7.1.3",
|
||||
"@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",
|
||||
"@lingui/cli": "^3.14.0",
|
||||
"@lingui/core": "^3.14.0",
|
||||
@ -1941,9 +1941,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@goauthentik/api": {
|
||||
"version": "2022.10.0-1666383274",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2022.10.0-1666383274.tgz",
|
||||
"integrity": "sha512-mwBT/bTpX4cSDxy6tQgaoHIzzWqpwIXSuv16knNqjk5emquckFOI1QhurB1D2MxbvrMETX8jfJWaE+LrE/cACA=="
|
||||
"version": "2022.11.3-1671801250",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2022.11.3-1671801250.tgz",
|
||||
"integrity": "sha512-8N6C0IPCuZQ9y5crIDJ/AeC0VlxAa3lW9mFRZ4f8g2T9FIoZUqLuxF4Uy5WW1k+IK0M0JOkuVaj2iHipaGb5Zw=="
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array": {
|
||||
"version": "0.11.6",
|
||||
@ -11620,9 +11620,9 @@
|
||||
"integrity": "sha512-CNR7qRIfCwWHNN7FnKUniva94edPdyQzil/zCwk3v6k4R6rR2Fr8i4s3PM7n/lyfPA6Zfko9z5WDzFxG9SW1uQ=="
|
||||
},
|
||||
"@goauthentik/api": {
|
||||
"version": "2022.10.0-1666383274",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2022.10.0-1666383274.tgz",
|
||||
"integrity": "sha512-mwBT/bTpX4cSDxy6tQgaoHIzzWqpwIXSuv16knNqjk5emquckFOI1QhurB1D2MxbvrMETX8jfJWaE+LrE/cACA=="
|
||||
"version": "2022.11.3-1671801250",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2022.11.3-1671801250.tgz",
|
||||
"integrity": "sha512-8N6C0IPCuZQ9y5crIDJ/AeC0VlxAa3lW9mFRZ4f8g2T9FIoZUqLuxF4Uy5WW1k+IK0M0JOkuVaj2iHipaGb5Zw=="
|
||||
},
|
||||
"@humanwhocodes/config-array": {
|
||||
"version": "0.11.6",
|
||||
|
@ -64,7 +64,7 @@
|
||||
"@codemirror/legacy-modes": "^6.2.0",
|
||||
"@formatjs/intl-listformat": "^7.1.3",
|
||||
"@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",
|
||||
"@lingui/cli": "^3.14.0",
|
||||
"@lingui/core": "^3.14.0",
|
||||
|
@ -9,8 +9,15 @@ import { t } from "@lingui/macro";
|
||||
|
||||
import { TemplateResult, html } from "lit";
|
||||
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")
|
||||
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()))}"
|
||||
/>
|
||||
</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-codemirror
|
||||
mode="yaml"
|
||||
|
@ -14,12 +14,12 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
|
||||
import PFFlex from "@patternfly/patternfly/layouts/Flex/flex.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")
|
||||
export class InvitationListLink extends AKElement {
|
||||
@property()
|
||||
invitation?: string;
|
||||
@property({ attribute: false })
|
||||
invitation?: Invitation;
|
||||
|
||||
@property()
|
||||
selectedFlow?: string;
|
||||
@ -29,60 +29,67 @@ export class InvitationListLink extends AKElement {
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
renderFlowSelector(): TemplateResult {
|
||||
return html`<div class="pf-c-description-list__group">
|
||||
<dt class="pf-c-description-list__term">
|
||||
<span class="pf-c-description-list__text">${t`Select an enrollment flow`}</span>
|
||||
</dt>
|
||||
<dd class="pf-c-description-list__description">
|
||||
<div class="pf-c-description-list__text">
|
||||
<select
|
||||
class="pf-c-form-control"
|
||||
@change=${(ev: Event) => {
|
||||
const current = (ev.target as HTMLInputElement).value;
|
||||
this.selectedFlow = current;
|
||||
}}
|
||||
>
|
||||
${until(
|
||||
new StagesApi(DEFAULT_CONFIG)
|
||||
.stagesInvitationStagesList({
|
||||
ordering: "name",
|
||||
noFlows: false,
|
||||
})
|
||||
.then((stages) => {
|
||||
if (
|
||||
!this.selectedFlow &&
|
||||
stages.results.length > 0 &&
|
||||
stages.results[0].flowSet
|
||||
) {
|
||||
this.selectedFlow = stages.results[0].flowSet[0].slug;
|
||||
}
|
||||
const seenFlowSlugs: string[] = [];
|
||||
return stages.results.map((stage) => {
|
||||
return stage.flowSet?.map((flow) => {
|
||||
if (seenFlowSlugs.includes(flow.slug)) {
|
||||
return html``;
|
||||
}
|
||||
seenFlowSlugs.push(flow.slug);
|
||||
return html`<option
|
||||
value=${flow.slug}
|
||||
?selected=${flow.slug === this.selectedFlow}
|
||||
>
|
||||
${flow.slug}
|
||||
</option>`;
|
||||
});
|
||||
});
|
||||
}),
|
||||
html`<option>${t`Loading...`}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</dd>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<dl class="pf-c-description-list pf-m-horizontal">
|
||||
<div class="pf-c-description-list__group">
|
||||
<dt class="pf-c-description-list__term">
|
||||
<span class="pf-c-description-list__text">${t`Select an enrollment flow`}</span>
|
||||
</dt>
|
||||
<dd class="pf-c-description-list__description">
|
||||
<div class="pf-c-description-list__text">
|
||||
<select
|
||||
class="pf-c-form-control"
|
||||
@change=${(ev: Event) => {
|
||||
const current = (ev.target as HTMLInputElement).value;
|
||||
this.selectedFlow = current;
|
||||
}}
|
||||
>
|
||||
${until(
|
||||
new StagesApi(DEFAULT_CONFIG)
|
||||
.stagesInvitationStagesList({
|
||||
ordering: "name",
|
||||
noFlows: false,
|
||||
})
|
||||
.then((stages) => {
|
||||
if (
|
||||
!this.selectedFlow &&
|
||||
stages.results.length > 0 &&
|
||||
stages.results[0].flowSet
|
||||
) {
|
||||
this.selectedFlow = stages.results[0].flowSet[0].slug;
|
||||
}
|
||||
const seenFlowSlugs: string[] = [];
|
||||
return stages.results.map((stage) => {
|
||||
return stage.flowSet?.map((flow) => {
|
||||
if (seenFlowSlugs.includes(flow.slug)) {
|
||||
return html``;
|
||||
}
|
||||
seenFlowSlugs.push(flow.slug);
|
||||
return html`<option
|
||||
value=${flow.slug}
|
||||
?selected=${flow.slug === this.selectedFlow}
|
||||
>
|
||||
${flow.slug}
|
||||
</option>`;
|
||||
});
|
||||
});
|
||||
}),
|
||||
html`<option>${t`Loading...`}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
${this.invitation?.flow === undefined ? this.renderFlowSelector() : html``}
|
||||
<div class="pf-c-description-list__group">
|
||||
<dt class="pf-c-description-list__term">
|
||||
<span class="pf-c-description-list__text"
|
||||
|
@ -2,6 +2,7 @@ import "@goauthentik/admin/stages/invitation/InvitationForm";
|
||||
import "@goauthentik/admin/stages/invitation/InvitationListLink";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { uiConfig } from "@goauthentik/common/ui/config";
|
||||
import { PFColor } from "@goauthentik/elements/Label";
|
||||
import "@goauthentik/elements/buttons/ModalButton";
|
||||
import "@goauthentik/elements/buttons/SpinnerButton";
|
||||
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 { Invitation, StagesApi } from "@goauthentik/api";
|
||||
import { FlowDesignationEnum, Invitation, StagesApi } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-stage-invitation-list")
|
||||
export class InvitationListPage extends TablePage<Invitation> {
|
||||
@ -49,12 +50,24 @@ export class InvitationListPage extends TablePage<Invitation> {
|
||||
@state()
|
||||
invitationStageExists = false;
|
||||
|
||||
@state()
|
||||
multipleEnrollmentFlows = false;
|
||||
|
||||
async apiEndpoint(page: number): Promise<PaginatedResponse<Invitation>> {
|
||||
// Check if any invitation stages exist
|
||||
const stages = await new StagesApi(DEFAULT_CONFIG).stagesInvitationStagesList({
|
||||
noFlows: false,
|
||||
});
|
||||
this.invitationStageExists = stages.pagination.count > 0;
|
||||
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({
|
||||
ordering: this.order,
|
||||
page: page,
|
||||
@ -96,7 +109,14 @@ export class InvitationListPage extends TablePage<Invitation> {
|
||||
|
||||
row(item: Invitation): TemplateResult[] {
|
||||
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.expires?.toLocaleString() || t`-`}`,
|
||||
html` <ak-forms-modal>
|
||||
@ -114,7 +134,7 @@ export class InvitationListPage extends TablePage<Invitation> {
|
||||
return html` <td role="cell" colspan="3">
|
||||
<div class="pf-c-table__expandable-row-content">
|
||||
<ak-stage-invitation-list-link
|
||||
invitation=${item.pk}
|
||||
.invitation=${item}
|
||||
></ak-stage-invitation-list-link>
|
||||
</div>
|
||||
</td>
|
||||
|
@ -59,6 +59,21 @@ export class UserWriteStageForm extends ModelForm<UserWriteStage, string> {
|
||||
<ak-form-group .expanded=${true}>
|
||||
<span slot="header"> ${t`Stage-specific settings`} </span>
|
||||
<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">
|
||||
<div class="pf-c-check">
|
||||
<input
|
||||
|
@ -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 = "2022.10.2";
|
||||
export const VERSION = "2022.10.4";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
@ -98,13 +98,6 @@ export class UserSettingsFlowExecutor extends AKElement implements StageHost {
|
||||
if (!this.flowSlug) {
|
||||
return;
|
||||
}
|
||||
new FlowsApi(DEFAULT_CONFIG)
|
||||
.flowsInstancesExecuteRetrieve({
|
||||
slug: this.flowSlug || "",
|
||||
})
|
||||
.then(() => {
|
||||
this.nextChallenge();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
29
website/docs/security/CVE-2022-23555.md
Normal file
29
website/docs/security/CVE-2022-23555.md
Normal 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)
|
25
website/docs/security/CVE-2022-46172.md
Normal file
25
website/docs/security/CVE-2022-46172.md
Normal 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)
|
@ -289,7 +289,12 @@ module.exports = {
|
||||
title: "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",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
Reference in New Issue
Block a user