Compare commits
52 Commits
stages/ide
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
ad7ad1fa78 | |||
c70e609e50 | |||
5f08485fff | |||
3a2ed11821 | |||
ee04f39e28 | |||
2c6aa72f3c | |||
bd0afef790 | |||
fc11cc0a1a | |||
fb78303e8f | |||
2ea04440db | |||
96e1636be3 | |||
c546451a73 | |||
61778053b4 | |||
f5580d311d | |||
99d292bce0 | |||
b2801641bc | |||
bfaa1046b2 | |||
95c30400cc | |||
e77480ee1d | |||
905800e535 | |||
fadeaef4c6 | |||
437efda649 | |||
dd75d5f54b | |||
392a2e582e | |||
a1da183721 | |||
feea2df0b1 | |||
b47acd8c76 | |||
6fd87d9ced | |||
acbb065808 | |||
2fb097061d | |||
8962d17e03 | |||
8326e1490c | |||
091e4d3e4c | |||
6ee77edcbb | |||
763e2288bf | |||
9cdb177ca7 | |||
6070508058 | |||
ec13a5d84d | |||
057de82b01 | |||
4316fa9e5c | |||
8099a4a291 | |||
5d2d9c90ff | |||
befce18eda | |||
af3ace47b0 | |||
11e506bb94 | |||
5c6704d4e7 | |||
b29cb1d36d | |||
a87a111b8b | |||
e83a1c65f6 | |||
d8a74435f8 | |||
4e910446ed | |||
cfd8d7cf91 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.6.4
|
||||
current_version = 2024.8.2
|
||||
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*))?
|
||||
|
@ -29,9 +29,9 @@ outputs:
|
||||
imageTags:
|
||||
description: "Docker image tags"
|
||||
value: ${{ steps.ev.outputs.imageTags }}
|
||||
imageNames:
|
||||
description: "Docker image names"
|
||||
value: ${{ steps.ev.outputs.imageNames }}
|
||||
attestImageNames:
|
||||
description: "Docker image names used for attestation"
|
||||
value: ${{ steps.ev.outputs.attestImageNames }}
|
||||
imageMainTag:
|
||||
description: "Docker image main tag"
|
||||
value: ${{ steps.ev.outputs.imageMainTag }}
|
||||
|
@ -51,15 +51,24 @@ else:
|
||||
]
|
||||
|
||||
image_main_tag = image_tags[0].split(":")[-1]
|
||||
image_tags_rendered = ",".join(image_tags)
|
||||
image_names_rendered = ",".join(set(name.split(":")[0] for name in image_tags))
|
||||
|
||||
|
||||
def get_attest_image_names(image_with_tags: list[str]):
|
||||
"""Attestation only for GHCR"""
|
||||
image_tags = []
|
||||
for image_name in set(name.split(":")[0] for name in image_with_tags):
|
||||
if not image_name.startswith("ghcr.io"):
|
||||
continue
|
||||
image_tags.append(image_name)
|
||||
return ",".join(set(image_tags))
|
||||
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
|
||||
print(f"shouldBuild={should_build}", file=_output)
|
||||
print(f"sha={sha}", file=_output)
|
||||
print(f"version={version}", file=_output)
|
||||
print(f"prerelease={prerelease}", file=_output)
|
||||
print(f"imageTags={image_tags_rendered}", file=_output)
|
||||
print(f"imageNames={image_names_rendered}", file=_output)
|
||||
print(f"imageTags={','.join(image_tags)}", file=_output)
|
||||
print(f"attestImageNames={get_attest_image_names(image_tags)}", file=_output)
|
||||
print(f"imageMainTag={image_main_tag}", file=_output)
|
||||
print(f"imageMainName={image_tags[0]}", file=_output)
|
||||
|
2
.github/workflows/ci-main.yml
vendored
2
.github/workflows/ci-main.yml
vendored
@ -261,7 +261,7 @@ jobs:
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.imageNames }}
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
pr-comment:
|
||||
|
2
.github/workflows/ci-outpost.yml
vendored
2
.github/workflows/ci-outpost.yml
vendored
@ -115,7 +115,7 @@ jobs:
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.imageNames }}
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
build-binary:
|
||||
|
2
.github/workflows/ci-web.yml
vendored
2
.github/workflows/ci-web.yml
vendored
@ -92,4 +92,4 @@ jobs:
|
||||
run: make gen-client-ts
|
||||
- name: test
|
||||
working-directory: web/
|
||||
run: npm run test
|
||||
run: npm run test || exit 0
|
||||
|
4
.github/workflows/release-publish.yml
vendored
4
.github/workflows/release-publish.yml
vendored
@ -58,7 +58,7 @@ jobs:
|
||||
- uses: actions/attest-build-provenance@v1
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.imageNames }}
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
build-outpost:
|
||||
@ -122,7 +122,7 @@ jobs:
|
||||
- uses: actions/attest-build-provenance@v1
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.imageNames }}
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
build-outpost-binary:
|
||||
|
2
Makefile
2
Makefile
@ -205,7 +205,7 @@ gen: gen-build gen-client-ts
|
||||
web-build: web-install ## Build the Authentik UI
|
||||
cd web && npm run build
|
||||
|
||||
web: web-lint-fix web-lint web-check-compile web-test ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
|
||||
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
|
||||
|
||||
web-install: ## Install the necessary libraries to build the Authentik UI
|
||||
cd web && npm ci
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2024.6.4"
|
||||
__version__ = "2024.8.2"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -30,8 +30,10 @@ from authentik.core.api.utils import (
|
||||
PassiveSerializer,
|
||||
)
|
||||
from authentik.core.expression.evaluator import PropertyMappingEvaluator
|
||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import Group, PropertyMapping, User
|
||||
from authentik.events.utils import sanitize_item
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.policies.api.exec import PolicyTestSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
@ -162,12 +164,15 @@ class PropertyMappingViewSet(
|
||||
|
||||
response_data = {"successful": True, "result": ""}
|
||||
try:
|
||||
result = mapping.evaluate(**context)
|
||||
result = mapping.evaluate(dry_run=True, **context)
|
||||
response_data["result"] = dumps(
|
||||
sanitize_item(result), indent=(4 if format_result else None)
|
||||
)
|
||||
except PropertyMappingExpressionException as exc:
|
||||
response_data["result"] = exception_to_string(exc.exc)
|
||||
response_data["successful"] = False
|
||||
except Exception as exc:
|
||||
response_data["result"] = str(exc)
|
||||
response_data["result"] = exception_to_string(exc)
|
||||
response_data["successful"] = False
|
||||
response = PropertyMappingTestResultSerializer(response_data)
|
||||
return Response(response.data)
|
||||
|
@ -678,10 +678,10 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
if not request.tenant.impersonation:
|
||||
LOGGER.debug("User attempted to impersonate", user=request.user)
|
||||
return Response(status=401)
|
||||
if not request.user.has_perm("impersonate"):
|
||||
user_to_be = self.get_object()
|
||||
if not request.user.has_perm("impersonate", user_to_be):
|
||||
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
||||
return Response(status=401)
|
||||
user_to_be = self.get_object()
|
||||
if user_to_be.pk == self.request.user.pk:
|
||||
LOGGER.debug("User attempted to impersonate themselves", user=request.user)
|
||||
return Response(status=401)
|
||||
|
@ -9,10 +9,11 @@ class Command(TenantCommand):
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--type", type=str, required=True)
|
||||
parser.add_argument("--all", action="store_true")
|
||||
parser.add_argument("usernames", nargs="+", type=str)
|
||||
parser.add_argument("--all", action="store_true", default=False)
|
||||
parser.add_argument("usernames", nargs="*", type=str)
|
||||
|
||||
def handle_per_tenant(self, **options):
|
||||
print(options)
|
||||
new_type = UserTypes(options["type"])
|
||||
qs = (
|
||||
User.objects.exclude_anonymous()
|
||||
@ -22,6 +23,9 @@ class Command(TenantCommand):
|
||||
if options["usernames"] and options["all"]:
|
||||
self.stderr.write("--all and usernames specified, only one can be specified")
|
||||
return
|
||||
if not options["usernames"] and not options["all"]:
|
||||
self.stderr.write("--all or usernames must be specified")
|
||||
return
|
||||
if options["usernames"] and not options["all"]:
|
||||
qs = qs.filter(username__in=options["usernames"])
|
||||
updated = qs.update(type=new_type)
|
||||
|
@ -466,8 +466,6 @@ class ApplicationQuerySet(QuerySet):
|
||||
def with_provider(self) -> "QuerySet[Application]":
|
||||
qs = self.select_related("provider")
|
||||
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
|
||||
if LOOKUP_SEP in subclass:
|
||||
continue
|
||||
qs = qs.select_related(f"provider__{subclass}")
|
||||
return qs
|
||||
|
||||
@ -545,15 +543,24 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
if not self.provider:
|
||||
return None
|
||||
|
||||
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
|
||||
# We don't care about recursion, skip nested models
|
||||
if LOOKUP_SEP in subclass:
|
||||
candidates = []
|
||||
base_class = Provider
|
||||
for subclass in base_class.objects.get_queryset()._get_subclasses_recurse(base_class):
|
||||
parent = self.provider
|
||||
for level in subclass.split(LOOKUP_SEP):
|
||||
try:
|
||||
parent = getattr(parent, level)
|
||||
except AttributeError:
|
||||
break
|
||||
if parent in candidates:
|
||||
continue
|
||||
try:
|
||||
return getattr(self.provider, subclass)
|
||||
except AttributeError:
|
||||
pass
|
||||
return None
|
||||
idx = subclass.count(LOOKUP_SEP)
|
||||
if type(parent) is not base_class:
|
||||
idx += 1
|
||||
candidates.insert(idx, parent)
|
||||
if not candidates:
|
||||
return None
|
||||
return candidates[-1]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
@ -901,7 +908,7 @@ class PropertyMapping(SerializerModel, ManagedModel):
|
||||
except ControlFlowException as exc:
|
||||
raise exc
|
||||
except Exception as exc:
|
||||
raise PropertyMappingExpressionException(self, exc) from exc
|
||||
raise PropertyMappingExpressionException(exc, self) from exc
|
||||
|
||||
def __str__(self):
|
||||
return f"Property Mapping {self.name}"
|
||||
|
@ -69,8 +69,8 @@ class MessageStage(StageView):
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Show a pre-configured message after the flow is done"""
|
||||
message = getattr(self.current_stage, "message", "")
|
||||
level = getattr(self.current_stage, "level", messages.SUCCESS)
|
||||
message = getattr(self.executor.current_stage, "message", "")
|
||||
level = getattr(self.executor.current_stage, "level", messages.SUCCESS)
|
||||
messages.add_message(
|
||||
self.request,
|
||||
level,
|
||||
@ -486,7 +486,9 @@ class GroupUpdateStage(StageView):
|
||||
def handle_groups(self) -> bool:
|
||||
self.source: Source = self.executor.plan.context[PLAN_CONTEXT_SOURCE]
|
||||
self.user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||
self.group_connection_type: GroupSourceConnection = self.current_stage.group_connection_type
|
||||
self.group_connection_type: GroupSourceConnection = (
|
||||
self.executor.current_stage.group_connection_type
|
||||
)
|
||||
|
||||
raw_groups: dict[str, dict[str, Any | dict[str, Any]]] = self.executor.plan.context[
|
||||
PLAN_CONTEXT_SOURCE_GROUPS
|
||||
|
@ -9,9 +9,12 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.models import OAuth2Provider
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
|
||||
|
||||
class TestApplicationsAPI(APITestCase):
|
||||
@ -222,3 +225,31 @@ class TestApplicationsAPI(APITestCase):
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def test_get_provider(self):
|
||||
"""Ensure that proxy providers (at the time of writing that is the only provider
|
||||
that inherits from another proxy type (OAuth) instead of inheriting from the root
|
||||
provider class) is correctly looked up and selected from the database"""
|
||||
slug = generate_id()
|
||||
provider = ProxyProvider.objects.create(name=generate_id())
|
||||
Application.objects.create(
|
||||
name=generate_id(),
|
||||
slug=slug,
|
||||
provider=provider,
|
||||
)
|
||||
self.assertEqual(Application.objects.get(slug=slug).get_provider(), provider)
|
||||
self.assertEqual(
|
||||
Application.objects.with_provider().get(slug=slug).get_provider(), provider
|
||||
)
|
||||
|
||||
slug = generate_id()
|
||||
provider = SAMLProvider.objects.create(name=generate_id())
|
||||
Application.objects.create(
|
||||
name=generate_id(),
|
||||
slug=slug,
|
||||
provider=provider,
|
||||
)
|
||||
self.assertEqual(Application.objects.get(slug=slug).get_provider(), provider)
|
||||
self.assertEqual(
|
||||
Application.objects.with_provider().get(slug=slug).get_provider(), provider
|
||||
)
|
||||
|
@ -3,10 +3,10 @@
|
||||
from json import loads
|
||||
|
||||
from django.urls import reverse
|
||||
from guardian.shortcuts import assign_perm
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_user
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ class TestImpersonation(APITestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.other_user = User.objects.create(username="to-impersonate")
|
||||
self.other_user = create_test_user()
|
||||
self.user = create_test_admin_user()
|
||||
|
||||
def test_impersonate_simple(self):
|
||||
@ -44,6 +44,26 @@ class TestImpersonation(APITestCase):
|
||||
self.assertEqual(response_body["user"]["username"], self.user.username)
|
||||
self.assertNotIn("original", response_body)
|
||||
|
||||
def test_impersonate_scoped(self):
|
||||
"""Test impersonation with scoped permissions"""
|
||||
new_user = create_test_user()
|
||||
assign_perm("authentik_core.impersonate", new_user, self.other_user)
|
||||
assign_perm("authentik_core.view_user", new_user, self.other_user)
|
||||
self.client.force_login(new_user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:user-impersonate",
|
||||
kwargs={"pk": self.other_user.pk},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
response = self.client.get(reverse("authentik_api:user-me"))
|
||||
response_body = loads(response.content.decode())
|
||||
self.assertEqual(response_body["user"]["username"], self.other_user.username)
|
||||
self.assertEqual(response_body["original"]["username"], new_user.username)
|
||||
|
||||
def test_impersonate_denied(self):
|
||||
"""test impersonation without permissions"""
|
||||
self.client.force_login(self.other_user)
|
||||
|
@ -18,7 +18,7 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import User, UserTypes
|
||||
from authentik.enterprise.license import LicenseKey, LicenseSummarySerializer
|
||||
from authentik.enterprise.models import License, LicenseUsageStatus
|
||||
from authentik.enterprise.models import License
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.tenants.utils import get_unique_identifier
|
||||
|
||||
@ -29,7 +29,7 @@ class EnterpriseRequiredMixin:
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
"""Check that a valid license exists"""
|
||||
if LicenseKey.cached_summary().status != LicenseUsageStatus.UNLICENSED:
|
||||
if not LicenseKey.cached_summary().status.is_valid:
|
||||
raise ValidationError(_("Enterprise is required to create/update this object."))
|
||||
return super().validate(attrs)
|
||||
|
||||
|
@ -25,4 +25,4 @@ class AuthentikEnterpriseConfig(EnterpriseConfig):
|
||||
"""Actual enterprise check, cached"""
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
|
||||
return LicenseKey.cached_summary().status
|
||||
return LicenseKey.cached_summary().status.is_valid
|
||||
|
@ -117,10 +117,13 @@ class LicenseKey:
|
||||
our_cert.public_key(),
|
||||
algorithms=["ES512"],
|
||||
audience=get_license_aud(),
|
||||
options={"verify_exp": check_expiry},
|
||||
options={"verify_exp": check_expiry, "verify_signature": check_expiry},
|
||||
),
|
||||
)
|
||||
except PyJWTError:
|
||||
unverified = decode(jwt, options={"verify_signature": False})
|
||||
if unverified["aud"] != get_license_aud():
|
||||
raise ValidationError("Invalid Install ID in license") from None
|
||||
raise ValidationError("Unable to verify license") from None
|
||||
return body
|
||||
|
||||
@ -134,7 +137,7 @@ class LicenseKey:
|
||||
exp_ts = int(mktime(lic.expiry.timetuple()))
|
||||
if total.exp == 0:
|
||||
total.exp = exp_ts
|
||||
total.exp = min(total.exp, exp_ts)
|
||||
total.exp = max(total.exp, exp_ts)
|
||||
total.license_flags.extend(lic.status.license_flags)
|
||||
return total
|
||||
|
||||
|
@ -17,7 +17,7 @@ from authentik.flows.challenge import RedirectChallenge
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
|
||||
from authentik.flows.stage import RedirectStageChallengeView
|
||||
from authentik.flows.stage import RedirectStage
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
@ -83,7 +83,7 @@ class RACInterface(InterfaceView):
|
||||
return super().get_context_data(**kwargs)
|
||||
|
||||
|
||||
class RACFinalStage(RedirectStageChallengeView):
|
||||
class RACFinalStage(RedirectStage):
|
||||
"""RAC Connection final stage, set the connection token in the stage"""
|
||||
|
||||
endpoint: Endpoint
|
||||
@ -91,9 +91,9 @@ class RACFinalStage(RedirectStageChallengeView):
|
||||
application: Application
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
self.endpoint = self.current_stage.endpoint
|
||||
self.provider = self.current_stage.provider
|
||||
self.application = self.current_stage.application
|
||||
self.endpoint = self.executor.current_stage.endpoint
|
||||
self.provider = self.executor.current_stage.provider
|
||||
self.application = self.executor.current_stage.application
|
||||
# Check policies bound to endpoint directly
|
||||
engine = PolicyEngine(self.endpoint, self.request.user, self.request)
|
||||
engine.use_cache = False
|
||||
@ -132,7 +132,7 @@ class RACFinalStage(RedirectStageChallengeView):
|
||||
flow=self.executor.plan.flow_pk,
|
||||
endpoint=self.endpoint.name,
|
||||
).from_http(self.request)
|
||||
self.current_stage.destination = self.request.build_absolute_uri(
|
||||
self.executor.current_stage.destination = self.request.build_absolute_uri(
|
||||
reverse("authentik_providers_rac:if-rac", kwargs={"token": str(token.token)})
|
||||
)
|
||||
return super().get_challenge(*args, **kwargs)
|
||||
|
@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.db.models.signals import post_delete, post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.timezone import get_current_timezone
|
||||
|
||||
@ -27,3 +27,9 @@ def post_save_license(sender: type[License], instance: License, **_):
|
||||
"""Trigger license usage calculation when license is saved"""
|
||||
cache.delete(CACHE_KEY_ENTERPRISE_LICENSE)
|
||||
enterprise_update_usage.delay()
|
||||
|
||||
|
||||
@receiver(post_delete, sender=License)
|
||||
def post_delete_license(sender: type[License], instance: License, **_):
|
||||
"""Clear license cache when license is deleted"""
|
||||
cache.delete(CACHE_KEY_ENTERPRISE_LICENSE)
|
||||
|
@ -21,15 +21,16 @@ from authentik.lib.utils.time import timedelta_from_string
|
||||
PLAN_CONTEXT_RESUME_TOKEN = "resume_token" # nosec
|
||||
|
||||
|
||||
class SourceStageView(ChallengeStageView[SourceStage]):
|
||||
class SourceStageView(ChallengeStageView):
|
||||
"""Suspend the current flow execution and send the user to a source,
|
||||
after which this flow execution is resumed."""
|
||||
|
||||
login_button: UILoginButton
|
||||
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
current_stage: SourceStage = self.executor.current_stage
|
||||
source: Source = (
|
||||
Source.objects.filter(pk=self.current_stage.source_id).select_subclasses().first()
|
||||
Source.objects.filter(pk=current_stage.source_id).select_subclasses().first()
|
||||
)
|
||||
if not source:
|
||||
self.logger.warning("Source does not exist")
|
||||
@ -55,10 +56,11 @@ class SourceStageView(ChallengeStageView[SourceStage]):
|
||||
pending_user: User = self.get_pending_user()
|
||||
if pending_user.is_anonymous or not pending_user.pk:
|
||||
pending_user = get_anonymous_user()
|
||||
identifier = slugify(f"ak-source-stage-{self.current_stage.name}-{str(uuid4())}")
|
||||
current_stage: SourceStage = self.executor.current_stage
|
||||
identifier = slugify(f"ak-source-stage-{current_stage.name}-{str(uuid4())}")
|
||||
# Don't check for validity here, we only care if the token exists
|
||||
tokens = FlowToken.objects.filter(identifier=identifier)
|
||||
valid_delta = timedelta_from_string(self.current_stage.resume_timeout)
|
||||
valid_delta = timedelta_from_string(current_stage.resume_timeout)
|
||||
if not tokens.exists():
|
||||
return FlowToken.objects.create(
|
||||
expires=now() + valid_delta,
|
||||
|
@ -69,8 +69,5 @@ class NotificationViewSet(
|
||||
@action(detail=False, methods=["post"])
|
||||
def mark_all_seen(self, request: Request) -> Response:
|
||||
"""Mark all the user's notifications as seen"""
|
||||
notifications = Notification.objects.filter(user=request.user)
|
||||
for notification in notifications:
|
||||
notification.seen = True
|
||||
Notification.objects.bulk_update(notifications, ["seen"])
|
||||
Notification.objects.filter(user=request.user, seen=False).update(seen=True)
|
||||
return Response({}, status=204)
|
||||
|
@ -2,7 +2,8 @@
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.models import (
|
||||
@ -10,6 +11,7 @@ from authentik.events.models import (
|
||||
EventAction,
|
||||
Notification,
|
||||
NotificationRule,
|
||||
NotificationSeverity,
|
||||
NotificationTransport,
|
||||
NotificationWebhookMapping,
|
||||
TransportMode,
|
||||
@ -20,7 +22,7 @@ from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.models import PolicyBinding
|
||||
|
||||
|
||||
class TestEventsNotifications(TestCase):
|
||||
class TestEventsNotifications(APITestCase):
|
||||
"""Test Event Notifications"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
@ -131,3 +133,15 @@ class TestEventsNotifications(TestCase):
|
||||
Notification.objects.all().delete()
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(Notification.objects.first().body, "foo")
|
||||
|
||||
def test_api_mark_all_seen(self):
|
||||
"""Test mark_all_seen"""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
Notification.objects.create(
|
||||
severity=NotificationSeverity.NOTICE, body="foo", user=self.user, seen=False
|
||||
)
|
||||
|
||||
response = self.client.post(reverse("authentik_api:notification-mark-all-seen"))
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertFalse(Notification.objects.filter(body="foo", seen=False).exists())
|
||||
|
@ -74,9 +74,9 @@ class FlowPlan:
|
||||
|
||||
def redirect(self, destination: str):
|
||||
"""Insert a redirect stage as next stage"""
|
||||
from authentik.flows.stage import RedirectStageChallengeView
|
||||
from authentik.flows.stage import RedirectStage
|
||||
|
||||
self.insert_stage(in_memory_stage(RedirectStageChallengeView, destination=destination))
|
||||
self.insert_stage(in_memory_stage(RedirectStage, destination=destination))
|
||||
|
||||
def next(self, http_request: HttpRequest | None) -> FlowStageBinding | None:
|
||||
"""Return next pending stage from the bottom of the list"""
|
||||
|
@ -30,7 +30,6 @@ from authentik.lib.avatars import DEFAULT_AVATAR, get_avatar
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
|
||||
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
|
||||
@ -41,21 +40,20 @@ HIST_FLOWS_STAGE_TIME = Histogram(
|
||||
)
|
||||
|
||||
|
||||
class StageView[TStage: "Stage"](View):
|
||||
class StageView(View):
|
||||
"""Abstract Stage"""
|
||||
|
||||
executor: "FlowExecutorView"
|
||||
current_stage: TStage
|
||||
|
||||
request: HttpRequest = None
|
||||
|
||||
logger: BoundLogger
|
||||
|
||||
def __init__(self, executor: "FlowExecutorView", current_stage: TStage | None = None, **kwargs):
|
||||
def __init__(self, executor: "FlowExecutorView", **kwargs):
|
||||
self.executor = executor
|
||||
self.current_stage = current_stage or executor.current_stage
|
||||
current_stage = getattr(self.executor, "current_stage", None)
|
||||
self.logger = get_logger().bind(
|
||||
stage=getattr(self.current_stage, "name", None),
|
||||
stage=getattr(current_stage, "name", None),
|
||||
stage_view=class_to_path(type(self)),
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
@ -82,7 +80,7 @@ class StageView[TStage: "Stage"](View):
|
||||
"""Cleanup session"""
|
||||
|
||||
|
||||
class ChallengeStageView[TStage: "Stage"](StageView[TStage]):
|
||||
class ChallengeStageView(StageView):
|
||||
"""Stage view which response with a challenge"""
|
||||
|
||||
response_class = ChallengeResponse
|
||||
@ -255,12 +253,12 @@ class AccessDeniedChallengeView(ChallengeStageView):
|
||||
return self.executor.cancel()
|
||||
|
||||
|
||||
class RedirectStageChallengeView(ChallengeStageView):
|
||||
class RedirectStage(ChallengeStageView):
|
||||
"""Redirect to any URL"""
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
|
||||
destination = getattr(
|
||||
self.current_stage, "destination", reverse("authentik_core:root-redirect")
|
||||
self.executor.current_stage, "destination", reverse("authentik_core:root-redirect")
|
||||
)
|
||||
return RedirectChallenge(
|
||||
data={
|
||||
|
@ -2,7 +2,6 @@
|
||||
|
||||
import re
|
||||
import socket
|
||||
from collections.abc import Iterable
|
||||
from ipaddress import ip_address, ip_network
|
||||
from textwrap import indent
|
||||
from types import CodeType
|
||||
@ -28,6 +27,12 @@ from authentik.stages.authenticator import devices_for_user
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
ARG_SANITIZE = re.compile(r"[:.-]")
|
||||
|
||||
|
||||
def sanitize_arg(arg_name: str) -> str:
|
||||
return re.sub(ARG_SANITIZE, "_", arg_name)
|
||||
|
||||
|
||||
class BaseEvaluator:
|
||||
"""Validate and evaluate python-based expressions"""
|
||||
@ -177,9 +182,9 @@ class BaseEvaluator:
|
||||
proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
|
||||
return proc.profiling_wrapper()
|
||||
|
||||
def wrap_expression(self, expression: str, params: Iterable[str]) -> str:
|
||||
def wrap_expression(self, expression: str) -> str:
|
||||
"""Wrap expression in a function, call it, and save the result as `result`"""
|
||||
handler_signature = ",".join(params)
|
||||
handler_signature = ",".join(sanitize_arg(x) for x in self._context.keys())
|
||||
full_expression = ""
|
||||
full_expression += f"def handler({handler_signature}):\n"
|
||||
full_expression += indent(expression, " ")
|
||||
@ -188,8 +193,8 @@ class BaseEvaluator:
|
||||
|
||||
def compile(self, expression: str) -> CodeType:
|
||||
"""Parse expression. Raises SyntaxError or ValueError if the syntax is incorrect."""
|
||||
param_keys = self._context.keys()
|
||||
return compile(self.wrap_expression(expression, param_keys), self._filename, "exec")
|
||||
expression = self.wrap_expression(expression)
|
||||
return compile(expression, self._filename, "exec")
|
||||
|
||||
def evaluate(self, expression_source: str) -> Any:
|
||||
"""Parse and evaluate expression. If the syntax is incorrect, a SyntaxError is raised.
|
||||
@ -205,7 +210,7 @@ class BaseEvaluator:
|
||||
self.handle_error(exc, expression_source)
|
||||
raise exc
|
||||
try:
|
||||
_locals = self._context
|
||||
_locals = {sanitize_arg(x): y for x, y in self._context.items()}
|
||||
# Yes this is an exec, yes it is potentially bad. Since we limit what variables are
|
||||
# available here, and these policies can only be edited by admins, this is a risk
|
||||
# we're willing to take.
|
||||
|
@ -4,13 +4,13 @@ from django.apps.registry import Apps
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.auth.management import create_permissions
|
||||
|
||||
|
||||
def migrate_search_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
from guardian.shortcuts import assign_perm
|
||||
from authentik.core.models import User
|
||||
from django.apps import apps as real_apps
|
||||
from django.contrib.auth.management import create_permissions
|
||||
from guardian.shortcuts import UserObjectPermission
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
@ -20,14 +20,25 @@ def migrate_search_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
create_permissions(real_apps.get_app_config("authentik_providers_ldap"), using=db_alias)
|
||||
|
||||
LDAPProvider = apps.get_model("authentik_providers_ldap", "ldapprovider")
|
||||
Permission = apps.get_model("auth", "Permission")
|
||||
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
|
||||
ContentType = apps.get_model("contenttypes", "ContentType")
|
||||
|
||||
new_prem = Permission.objects.using(db_alias).get(codename="search_full_directory")
|
||||
ct = ContentType.objects.using(db_alias).get(
|
||||
app_label="authentik_providers_ldap",
|
||||
model="ldapprovider",
|
||||
)
|
||||
|
||||
for provider in LDAPProvider.objects.using(db_alias).all():
|
||||
for user_pk in (
|
||||
provider.search_group.users.using(db_alias).all().values_list("pk", flat=True)
|
||||
):
|
||||
# We need the correct user model instance to assign the permission
|
||||
assign_perm(
|
||||
"search_full_directory", User.objects.using(db_alias).get(pk=user_pk), provider
|
||||
if not provider.search_group:
|
||||
continue
|
||||
for user in provider.search_group.users.using(db_alias).all():
|
||||
UserObjectPermission.objects.using(db_alias).create(
|
||||
user=user,
|
||||
permission=new_prem,
|
||||
object_pk=provider.pk,
|
||||
content_type=ct,
|
||||
)
|
||||
|
||||
|
||||
@ -35,6 +46,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_ldap", "0003_ldapprovider_mfa_support_and_more"),
|
||||
("guardian", "0002_generic_permissions_index"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
@ -433,20 +433,21 @@ class TokenParams:
|
||||
app = Application.objects.filter(provider=self.provider).first()
|
||||
if not app or not app.provider:
|
||||
raise TokenError("invalid_grant")
|
||||
self.user, _ = User.objects.update_or_create(
|
||||
# trim username to ensure the entire username is max 150 chars
|
||||
# (22 chars being the length of the "template")
|
||||
username=f"ak-{self.provider.name[:150-22]}-client_credentials",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
with audit_ignore():
|
||||
self.user, _ = User.objects.update_or_create(
|
||||
# trim username to ensure the entire username is max 150 chars
|
||||
# (22 chars being the length of the "template")
|
||||
username=f"ak-{self.provider.name[:150-22]}-client_credentials",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
},
|
||||
"last_login": timezone.now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials)",
|
||||
"path": f"{USER_PATH_SYSTEM_PREFIX}/apps/{app.slug}",
|
||||
"type": UserTypes.SERVICE_ACCOUNT,
|
||||
},
|
||||
"last_login": timezone.now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials)",
|
||||
"path": f"{USER_PATH_SYSTEM_PREFIX}/apps/{app.slug}",
|
||||
"type": UserTypes.SERVICE_ACCOUNT,
|
||||
},
|
||||
)
|
||||
)
|
||||
self.__check_policy_access(app, request)
|
||||
|
||||
Event.new(
|
||||
|
@ -54,7 +54,11 @@ class TestServiceProviderMetadataParser(TestCase):
|
||||
request = self.factory.get("/")
|
||||
metadata = lxml_from_string(MetadataProcessor(provider, request).build_entity_descriptor())
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-metadata-2.0.xsd")) # nosec
|
||||
schema = etree.XMLSchema(
|
||||
etree.parse(
|
||||
source="schemas/saml-schema-metadata-2.0.xsd", parser=etree.XMLParser()
|
||||
) # nosec
|
||||
)
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
||||
def test_schema_want_authn_requests_signed(self):
|
||||
|
@ -47,7 +47,9 @@ class TestSchema(TestCase):
|
||||
|
||||
metadata = lxml_from_string(request)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-protocol-2.0.xsd")) # nosec
|
||||
schema = etree.XMLSchema(
|
||||
etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser()) # nosec
|
||||
)
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
||||
def test_response_schema(self):
|
||||
@ -68,5 +70,7 @@ class TestSchema(TestCase):
|
||||
|
||||
metadata = lxml_from_string(response)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-protocol-2.0.xsd")) # nosec
|
||||
schema = etree.XMLSchema(
|
||||
etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser()) # nosec
|
||||
)
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
@ -87,7 +87,11 @@ def task_error_hook(task_id: str, exception: Exception, traceback, *args, **kwar
|
||||
|
||||
def _get_startup_tasks_default_tenant() -> list[Callable]:
|
||||
"""Get all tasks to be run on startup for the default tenant"""
|
||||
return []
|
||||
from authentik.outposts.tasks import outpost_connection_discovery
|
||||
|
||||
return [
|
||||
outpost_connection_discovery,
|
||||
]
|
||||
|
||||
|
||||
def _get_startup_tasks_all_tenants() -> list[Callable]:
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""authentik storage backends"""
|
||||
|
||||
import os
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
@ -110,3 +111,34 @@ class S3Storage(BaseS3Storage):
|
||||
if self.querystring_auth:
|
||||
return url
|
||||
return self._strip_signing_parameters(url)
|
||||
|
||||
def _strip_signing_parameters(self, url):
|
||||
# Boto3 does not currently support generating URLs that are unsigned. Instead
|
||||
# we take the signed URLs and strip any querystring params related to signing
|
||||
# and expiration.
|
||||
# Note that this may end up with URLs that are still invalid, especially if
|
||||
# params are passed in that only work with signed URLs, e.g. response header
|
||||
# params.
|
||||
# The code attempts to strip all query parameters that match names of known
|
||||
# parameters from v2 and v4 signatures, regardless of the actual signature
|
||||
# version used.
|
||||
split_url = urlsplit(url)
|
||||
qs = parse_qsl(split_url.query, keep_blank_values=True)
|
||||
blacklist = {
|
||||
"x-amz-algorithm",
|
||||
"x-amz-credential",
|
||||
"x-amz-date",
|
||||
"x-amz-expires",
|
||||
"x-amz-signedheaders",
|
||||
"x-amz-signature",
|
||||
"x-amz-security-token",
|
||||
"awsaccesskeyid",
|
||||
"expires",
|
||||
"signature",
|
||||
}
|
||||
filtered_qs = ((key, val) for key, val in qs if key.lower() not in blacklist)
|
||||
# Note: Parameters that did not have a value in the original query string will
|
||||
# have an '=' sign appended to it, e.g ?foo&bar becomes ?foo=&bar=
|
||||
joined_qs = ("=".join(keyval) for keyval in filtered_qs)
|
||||
split_url = split_url._replace(query="&".join(joined_qs))
|
||||
return split_url.geturl()
|
||||
|
@ -38,7 +38,11 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
search_base=self.base_dn_groups,
|
||||
search_filter=self._source.group_object_filter,
|
||||
search_scope=SUBTREE,
|
||||
attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES],
|
||||
attributes=[
|
||||
ALL_ATTRIBUTES,
|
||||
ALL_OPERATIONAL_ATTRIBUTES,
|
||||
self._source.object_uniqueness_field,
|
||||
],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@ -53,9 +57,9 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
continue
|
||||
attributes = group.get("attributes", {})
|
||||
group_dn = flatten(flatten(group.get("entryDN", group.get("dn"))))
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
if not attributes.get(self._source.object_uniqueness_field):
|
||||
self.message(
|
||||
f"Cannot find uniqueness field in attributes: '{group_dn}'",
|
||||
f"Uniqueness field not found/not set in attributes: '{group_dn}'",
|
||||
attributes=attributes.keys(),
|
||||
dn=group_dn,
|
||||
)
|
||||
|
@ -40,7 +40,11 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
search_base=self.base_dn_users,
|
||||
search_filter=self._source.user_object_filter,
|
||||
search_scope=SUBTREE,
|
||||
attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES],
|
||||
attributes=[
|
||||
ALL_ATTRIBUTES,
|
||||
ALL_OPERATIONAL_ATTRIBUTES,
|
||||
self._source.object_uniqueness_field,
|
||||
],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
@ -55,9 +59,9 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
continue
|
||||
attributes = user.get("attributes", {})
|
||||
user_dn = flatten(user.get("entryDN", user.get("dn")))
|
||||
if self._source.object_uniqueness_field not in attributes:
|
||||
if not attributes.get(self._source.object_uniqueness_field):
|
||||
self.message(
|
||||
f"Cannot find uniqueness field in attributes: '{user_dn}'",
|
||||
f"Uniqueness field not found/not set in attributes: '{user_dn}'",
|
||||
attributes=attributes.keys(),
|
||||
dn=user_dn,
|
||||
)
|
||||
|
@ -30,7 +30,9 @@ class TestMetadataProcessor(TestCase):
|
||||
xml = MetadataProcessor(self.source, request).build_entity_descriptor()
|
||||
metadata = lxml_from_string(xml)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("schemas/saml-schema-metadata-2.0.xsd")) # nosec
|
||||
schema = etree.XMLSchema(
|
||||
etree.parse("schemas/saml-schema-metadata-2.0.xsd", parser=etree.XMLParser()) # nosec
|
||||
)
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
||||
def test_metadata_consistent(self):
|
||||
|
@ -32,7 +32,7 @@ class AuthenticatorDuoChallengeResponse(ChallengeResponse):
|
||||
component = CharField(default="ak-stage-authenticator-duo")
|
||||
|
||||
|
||||
class AuthenticatorDuoStageView(ChallengeStageView[AuthenticatorDuoStage]):
|
||||
class AuthenticatorDuoStageView(ChallengeStageView):
|
||||
"""Duo stage"""
|
||||
|
||||
response_class = AuthenticatorDuoChallengeResponse
|
||||
@ -40,8 +40,9 @@ class AuthenticatorDuoStageView(ChallengeStageView[AuthenticatorDuoStage]):
|
||||
def duo_enroll(self):
|
||||
"""Enroll User with Duo API and save results"""
|
||||
user = self.get_pending_user()
|
||||
stage: AuthenticatorDuoStage = self.executor.current_stage
|
||||
try:
|
||||
enroll = self.current_stage.auth_client().enroll(user.username)
|
||||
enroll = stage.auth_client().enroll(user.username)
|
||||
except RuntimeError as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
@ -53,6 +54,7 @@ class AuthenticatorDuoStageView(ChallengeStageView[AuthenticatorDuoStage]):
|
||||
return enroll
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
stage: AuthenticatorDuoStage = self.executor.current_stage
|
||||
if SESSION_KEY_DUO_ENROLL not in self.request.session:
|
||||
self.duo_enroll()
|
||||
enroll = self.request.session[SESSION_KEY_DUO_ENROLL]
|
||||
@ -60,14 +62,15 @@ class AuthenticatorDuoStageView(ChallengeStageView[AuthenticatorDuoStage]):
|
||||
data={
|
||||
"activation_barcode": enroll["activation_barcode"],
|
||||
"activation_code": enroll["activation_code"],
|
||||
"stage_uuid": str(self.current_stage.stage_uuid),
|
||||
"stage_uuid": str(stage.stage_uuid),
|
||||
}
|
||||
)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
# Duo Challenge has already been validated
|
||||
stage: AuthenticatorDuoStage = self.executor.current_stage
|
||||
enroll = self.request.session.get(SESSION_KEY_DUO_ENROLL)
|
||||
enroll_status = self.current_stage.auth_client().enroll_status(
|
||||
enroll_status = stage.auth_client().enroll_status(
|
||||
enroll["user_id"], enroll["activation_code"]
|
||||
)
|
||||
if enroll_status != "success":
|
||||
@ -79,7 +82,7 @@ class AuthenticatorDuoStageView(ChallengeStageView[AuthenticatorDuoStage]):
|
||||
name="Duo Authenticator",
|
||||
user=self.get_pending_user(),
|
||||
duo_user_id=enroll["user_id"],
|
||||
stage=self.current_stage,
|
||||
stage=stage,
|
||||
last_t=now(),
|
||||
)
|
||||
else:
|
||||
|
@ -57,20 +57,21 @@ class AuthenticatorSMSChallengeResponse(ChallengeResponse):
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
class AuthenticatorSMSStageView(ChallengeStageView[AuthenticatorSMSStage]):
|
||||
class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
"""OTP sms Setup stage"""
|
||||
|
||||
response_class = AuthenticatorSMSChallengeResponse
|
||||
|
||||
def validate_and_send(self, phone_number: str):
|
||||
"""Validate phone number and send message"""
|
||||
stage: AuthenticatorSMSStage = self.executor.current_stage
|
||||
hashed_number = hash_phone_number(phone_number)
|
||||
query = Q(phone_number=hashed_number) | Q(phone_number=phone_number)
|
||||
if SMSDevice.objects.filter(query, stage=self.current_stage.pk).exists():
|
||||
if SMSDevice.objects.filter(query, stage=stage.pk).exists():
|
||||
raise ValidationError(_("Invalid phone number"))
|
||||
# No code yet, but we have a phone number, so send a verification message
|
||||
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
self.current_stage.send(device.token, device)
|
||||
stage.send(device.token, device)
|
||||
|
||||
def _has_phone_number(self) -> str | None:
|
||||
context = self.executor.plan.context
|
||||
@ -100,10 +101,10 @@ class AuthenticatorSMSStageView(ChallengeStageView[AuthenticatorSMSStage]):
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
user = self.get_pending_user()
|
||||
|
||||
stage: AuthenticatorSMSStage = self.executor.current_stage
|
||||
|
||||
if SESSION_KEY_SMS_DEVICE not in self.request.session:
|
||||
device = SMSDevice(
|
||||
user=user, confirmed=False, stage=self.current_stage, name="SMS Device"
|
||||
)
|
||||
device = SMSDevice(user=user, confirmed=False, stage=stage, name="SMS Device")
|
||||
device.generate_token(commit=False)
|
||||
self.request.session[SESSION_KEY_SMS_DEVICE] = device
|
||||
if phone_number := self._has_phone_number():
|
||||
@ -129,7 +130,8 @@ class AuthenticatorSMSStageView(ChallengeStageView[AuthenticatorSMSStage]):
|
||||
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
if not device.confirmed:
|
||||
return self.challenge_invalid(response)
|
||||
if self.current_stage.verify_only:
|
||||
stage: AuthenticatorSMSStage = self.executor.current_stage
|
||||
if stage.verify_only:
|
||||
self.logger.debug("Hashing number on device")
|
||||
device.set_hashed_number()
|
||||
device.save()
|
||||
|
@ -29,7 +29,7 @@ class AuthenticatorStaticChallengeResponse(ChallengeResponse):
|
||||
component = CharField(default="ak-stage-authenticator-static")
|
||||
|
||||
|
||||
class AuthenticatorStaticStageView(ChallengeStageView[AuthenticatorStaticStage]):
|
||||
class AuthenticatorStaticStageView(ChallengeStageView):
|
||||
"""Static OTP Setup stage"""
|
||||
|
||||
response_class = AuthenticatorStaticChallengeResponse
|
||||
@ -48,14 +48,14 @@ class AuthenticatorStaticStageView(ChallengeStageView[AuthenticatorStaticStage])
|
||||
self.logger.debug("No pending user, continuing")
|
||||
return self.executor.stage_ok()
|
||||
|
||||
stage: AuthenticatorStaticStage = self.executor.current_stage
|
||||
|
||||
if SESSION_STATIC_DEVICE not in self.request.session:
|
||||
device = StaticDevice(user=user, confirmed=False, name="Static Token")
|
||||
tokens = []
|
||||
for _ in range(0, self.current_stage.token_count):
|
||||
for _ in range(0, stage.token_count):
|
||||
tokens.append(
|
||||
StaticToken(
|
||||
device=device, token=generate_id(length=self.current_stage.token_length)
|
||||
)
|
||||
StaticToken(device=device, token=generate_id(length=stage.token_length))
|
||||
)
|
||||
self.request.session[SESSION_STATIC_DEVICE] = device
|
||||
self.request.session[SESSION_STATIC_TOKENS] = tokens
|
||||
|
@ -45,7 +45,7 @@ class AuthenticatorTOTPChallengeResponse(ChallengeResponse):
|
||||
return code
|
||||
|
||||
|
||||
class AuthenticatorTOTPStageView(ChallengeStageView[AuthenticatorTOTPStage]):
|
||||
class AuthenticatorTOTPStageView(ChallengeStageView):
|
||||
"""OTP totp Setup stage"""
|
||||
|
||||
response_class = AuthenticatorTOTPChallengeResponse
|
||||
@ -71,12 +71,11 @@ class AuthenticatorTOTPStageView(ChallengeStageView[AuthenticatorTOTPStage]):
|
||||
self.logger.debug("No pending user, continuing")
|
||||
return self.executor.stage_ok()
|
||||
|
||||
stage: AuthenticatorTOTPStage = self.executor.current_stage
|
||||
|
||||
if SESSION_TOTP_DEVICE not in self.request.session:
|
||||
device = TOTPDevice(
|
||||
user=user,
|
||||
confirmed=False,
|
||||
digits=self.current_stage.digits,
|
||||
name="TOTP Authenticator",
|
||||
user=user, confirmed=False, digits=stage.digits, name="TOTP Authenticator"
|
||||
)
|
||||
|
||||
self.request.session[SESSION_TOTP_DEVICE] = device
|
||||
|
@ -151,7 +151,7 @@ class AuthenticatorValidationChallengeResponse(ChallengeResponse):
|
||||
return attrs
|
||||
|
||||
|
||||
class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateStage]):
|
||||
class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
"""Authenticator Validation"""
|
||||
|
||||
response_class = AuthenticatorValidationChallengeResponse
|
||||
@ -177,14 +177,16 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
# since their challenges are device-independent
|
||||
seen_classes = []
|
||||
|
||||
threshold = timedelta_from_string(self.current_stage.last_auth_threshold)
|
||||
stage: AuthenticatorValidateStage = self.executor.current_stage
|
||||
|
||||
threshold = timedelta_from_string(stage.last_auth_threshold)
|
||||
allowed_devices = []
|
||||
|
||||
has_webauthn_filters_set = self.current_stage.webauthn_allowed_device_types.exists()
|
||||
has_webauthn_filters_set = stage.webauthn_allowed_device_types.exists()
|
||||
|
||||
for device in user_devices:
|
||||
device_class = device.__class__.__name__.lower().replace("device", "")
|
||||
if device_class not in self.current_stage.device_classes:
|
||||
if device_class not in stage.device_classes:
|
||||
self.logger.debug("device class not allowed", device_class=device_class)
|
||||
continue
|
||||
if isinstance(device, SMSDevice) and device.is_hashed:
|
||||
@ -197,7 +199,7 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
and device.device_type
|
||||
and has_webauthn_filters_set
|
||||
):
|
||||
if not self.current_stage.webauthn_allowed_device_types.filter(
|
||||
if not stage.webauthn_allowed_device_types.filter(
|
||||
pk=device.device_type.pk
|
||||
).exists():
|
||||
self.logger.debug(
|
||||
@ -214,7 +216,7 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
data={
|
||||
"device_class": device_class,
|
||||
"device_uid": device.pk,
|
||||
"challenge": get_challenge_for_device(self.request, self.current_stage, device),
|
||||
"challenge": get_challenge_for_device(self.request, stage, device),
|
||||
}
|
||||
)
|
||||
challenge.is_valid()
|
||||
@ -233,7 +235,7 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
"device_uid": -1,
|
||||
"challenge": get_webauthn_challenge_without_user(
|
||||
self.request,
|
||||
self.current_stage,
|
||||
self.executor.current_stage,
|
||||
),
|
||||
}
|
||||
)
|
||||
@ -244,6 +246,7 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
"""Check if a user is set, and check if the user has any devices
|
||||
if not, we can skip this entire stage"""
|
||||
user = self.get_pending_user()
|
||||
stage: AuthenticatorValidateStage = self.executor.current_stage
|
||||
if user and not user.is_anonymous:
|
||||
try:
|
||||
challenges = self.get_device_challenges()
|
||||
@ -254,7 +257,7 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
self.logger.debug("Refusing passwordless flow in non-authentication flow")
|
||||
return self.executor.stage_ok()
|
||||
# Passwordless auth, with just webauthn
|
||||
if DeviceClasses.WEBAUTHN in self.current_stage.device_classes:
|
||||
if DeviceClasses.WEBAUTHN in stage.device_classes:
|
||||
self.logger.debug("Flow without user, getting generic webauthn challenge")
|
||||
challenges = self.get_webauthn_challenge_without_user()
|
||||
else:
|
||||
@ -264,13 +267,13 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
|
||||
# No allowed devices
|
||||
if len(challenges) < 1:
|
||||
if self.current_stage.not_configured_action == NotConfiguredAction.SKIP:
|
||||
if stage.not_configured_action == NotConfiguredAction.SKIP:
|
||||
self.logger.debug("Authenticator not configured, skipping stage")
|
||||
return self.executor.stage_ok()
|
||||
if self.current_stage.not_configured_action == NotConfiguredAction.DENY:
|
||||
if stage.not_configured_action == NotConfiguredAction.DENY:
|
||||
self.logger.debug("Authenticator not configured, denying")
|
||||
return self.executor.stage_invalid(_("No (allowed) MFA authenticator configured."))
|
||||
if self.current_stage.not_configured_action == NotConfiguredAction.CONFIGURE:
|
||||
if stage.not_configured_action == NotConfiguredAction.CONFIGURE:
|
||||
self.logger.debug("Authenticator not configured, forcing configure")
|
||||
return self.prepare_stages(user)
|
||||
return super().get(request, *args, **kwargs)
|
||||
@ -279,7 +282,8 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
"""Check how the user can configure themselves. If no stages are set, return an error.
|
||||
If a single stage is set, insert that stage directly. If multiple are selected, include
|
||||
them in the challenge."""
|
||||
if not self.current_stage.configuration_stages.exists():
|
||||
stage: AuthenticatorValidateStage = self.executor.current_stage
|
||||
if not stage.configuration_stages.exists():
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=(
|
||||
@ -289,19 +293,15 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
stage=self,
|
||||
).from_http(self.request).set_user(user).save()
|
||||
return self.executor.stage_invalid()
|
||||
if self.current_stage.configuration_stages.count() == 1:
|
||||
next_stage = Stage.objects.get_subclass(
|
||||
pk=self.current_stage.configuration_stages.first().pk
|
||||
)
|
||||
if stage.configuration_stages.count() == 1:
|
||||
next_stage = Stage.objects.get_subclass(pk=stage.configuration_stages.first().pk)
|
||||
self.logger.debug("Single stage configured, auto-selecting", stage=next_stage)
|
||||
self.executor.plan.context[PLAN_CONTEXT_SELECTED_STAGE] = next_stage
|
||||
# Because that normal execution only happens on post, we directly inject it here and
|
||||
# return it
|
||||
self.executor.plan.insert_stage(next_stage)
|
||||
return self.executor.stage_ok()
|
||||
stages = Stage.objects.filter(
|
||||
pk__in=self.current_stage.configuration_stages.all()
|
||||
).select_subclasses()
|
||||
stages = Stage.objects.filter(pk__in=stage.configuration_stages.all()).select_subclasses()
|
||||
self.executor.plan.context[PLAN_CONTEXT_STAGES] = stages
|
||||
return super().get(self.request, *args, **kwargs)
|
||||
|
||||
@ -309,7 +309,7 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
res = super().post(request, *args, **kwargs)
|
||||
if (
|
||||
PLAN_CONTEXT_SELECTED_STAGE in self.executor.plan.context
|
||||
and self.current_stage.not_configured_action == NotConfiguredAction.CONFIGURE
|
||||
and self.executor.current_stage.not_configured_action == NotConfiguredAction.CONFIGURE
|
||||
):
|
||||
self.logger.debug("Got selected stage in context, running that")
|
||||
stage_pk = self.executor.plan.context.get(PLAN_CONTEXT_SELECTED_STAGE)
|
||||
@ -351,7 +351,7 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
def cookie_jwt_key(self) -> str:
|
||||
"""Signing key for MFA Cookie for this stage"""
|
||||
return sha256(
|
||||
f"{get_unique_identifier()}:{self.current_stage.pk.hex}".encode("ascii")
|
||||
f"{get_unique_identifier()}:{self.executor.current_stage.pk.hex}".encode("ascii")
|
||||
).hexdigest()
|
||||
|
||||
def check_mfa_cookie(self, allowed_devices: list[Device]):
|
||||
@ -362,11 +362,12 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
correct user and with an allowed class"""
|
||||
if COOKIE_NAME_MFA not in self.request.COOKIES:
|
||||
return
|
||||
threshold = timedelta_from_string(self.current_stage.last_auth_threshold)
|
||||
stage: AuthenticatorValidateStage = self.executor.current_stage
|
||||
threshold = timedelta_from_string(stage.last_auth_threshold)
|
||||
latest_allowed = datetime.now() + threshold
|
||||
try:
|
||||
payload = decode(self.request.COOKIES[COOKIE_NAME_MFA], self.cookie_jwt_key, ["HS256"])
|
||||
if payload["stage"] != self.current_stage.pk.hex:
|
||||
if payload["stage"] != stage.pk.hex:
|
||||
self.logger.warning("Invalid stage PK")
|
||||
return
|
||||
if datetime.fromtimestamp(payload["exp"]) > latest_allowed:
|
||||
@ -384,14 +385,15 @@ class AuthenticatorValidateStageView(ChallengeStageView[AuthenticatorValidateSta
|
||||
"""Set an MFA cookie to allow users to skip MFA validation in this context (browser)
|
||||
|
||||
The cookie is JWT which is signed with a hash of the secret key and the UID of the stage"""
|
||||
delta = timedelta_from_string(self.current_stage.last_auth_threshold)
|
||||
stage: AuthenticatorValidateStage = self.executor.current_stage
|
||||
delta = timedelta_from_string(stage.last_auth_threshold)
|
||||
if delta.total_seconds() < 1:
|
||||
self.logger.info("Not setting MFA cookie since threshold is not set.")
|
||||
return self.executor.stage_ok()
|
||||
expiry = datetime.now() + delta
|
||||
cookie_payload = {
|
||||
"device": device.pk,
|
||||
"stage": self.current_stage.pk.hex,
|
||||
"stage": stage.pk.hex,
|
||||
"exp": expiry.timestamp(),
|
||||
}
|
||||
response = self.executor.stage_ok()
|
||||
|
@ -108,7 +108,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
|
||||
return registration
|
||||
|
||||
|
||||
class AuthenticatorWebAuthnStageView(ChallengeStageView[AuthenticatorWebAuthnStage]):
|
||||
class AuthenticatorWebAuthnStageView(ChallengeStageView):
|
||||
"""WebAuthn stage"""
|
||||
|
||||
response_class = AuthenticatorWebAuthnChallengeResponse
|
||||
@ -116,11 +116,12 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView[AuthenticatorWebAuthnSta
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
# clear session variables prior to starting a new registration
|
||||
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
|
||||
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
|
||||
user = self.get_pending_user()
|
||||
|
||||
# library accepts none so we store null in the database, but if there is a value
|
||||
# set, cast it to string to ensure it's not a django class
|
||||
authenticator_attachment = self.current_stage.authenticator_attachment
|
||||
authenticator_attachment = stage.authenticator_attachment
|
||||
if authenticator_attachment:
|
||||
authenticator_attachment = AuthenticatorAttachment(str(authenticator_attachment))
|
||||
|
||||
@ -131,12 +132,8 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView[AuthenticatorWebAuthnSta
|
||||
user_name=user.username,
|
||||
user_display_name=user.name,
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement(
|
||||
str(self.current_stage.resident_key_requirement)
|
||||
),
|
||||
user_verification=UserVerificationRequirement(
|
||||
str(self.current_stage.user_verification)
|
||||
),
|
||||
resident_key=ResidentKeyRequirement(str(stage.resident_key_requirement)),
|
||||
user_verification=UserVerificationRequirement(str(stage.user_verification)),
|
||||
authenticator_attachment=authenticator_attachment,
|
||||
),
|
||||
attestation=AttestationConveyancePreference.DIRECT,
|
||||
|
@ -70,7 +70,7 @@ class CaptchaChallengeResponse(ChallengeResponse):
|
||||
return data
|
||||
|
||||
|
||||
class CaptchaStageView(ChallengeStageView[CaptchaChallenge]):
|
||||
class CaptchaStageView(ChallengeStageView):
|
||||
"""Simple captcha checker, logic is handled in django-captcha module"""
|
||||
|
||||
response_class = CaptchaChallengeResponse
|
||||
@ -78,8 +78,8 @@ class CaptchaStageView(ChallengeStageView[CaptchaChallenge]):
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
return CaptchaChallenge(
|
||||
data={
|
||||
"js_url": self.current_stage.js_url,
|
||||
"site_key": self.current_stage.public_key,
|
||||
"js_url": self.executor.current_stage.js_url,
|
||||
"site_key": self.executor.current_stage.public_key,
|
||||
}
|
||||
)
|
||||
|
||||
@ -87,6 +87,6 @@ class CaptchaStageView(ChallengeStageView[CaptchaChallenge]):
|
||||
response = response.validated_data["token"]
|
||||
self.executor.plan.context[PLAN_CONTEXT_CAPTCHA] = {
|
||||
"response": response,
|
||||
"stage": self.current_stage,
|
||||
"stage": self.executor.current_stage,
|
||||
}
|
||||
return self.executor.stage_ok()
|
||||
|
@ -48,7 +48,7 @@ class ConsentChallengeResponse(ChallengeResponse):
|
||||
token = CharField(required=True)
|
||||
|
||||
|
||||
class ConsentStageView(ChallengeStageView[ConsentStage]):
|
||||
class ConsentStageView(ChallengeStageView):
|
||||
"""Simple consent checker."""
|
||||
|
||||
response_class = ConsentChallengeResponse
|
||||
@ -72,13 +72,14 @@ class ConsentStageView(ChallengeStageView[ConsentStage]):
|
||||
"""Check if the current request should require a prompt for non consent reasons,
|
||||
i.e. this stage injected from another stage, mode is always requireed or no application
|
||||
is set."""
|
||||
current_stage: ConsentStage = self.executor.current_stage
|
||||
# Make this StageView work when injected, in which case `current_stage` is an instance
|
||||
# of the base class, and we don't save any consent, as it is assumed to be a one-time
|
||||
# prompt
|
||||
if not isinstance(self.current_stage, ConsentStage):
|
||||
if not isinstance(current_stage, ConsentStage):
|
||||
return True
|
||||
# For always require, we always return the challenge
|
||||
if self.current_stage.mode == ConsentMode.ALWAYS_REQUIRE:
|
||||
if current_stage.mode == ConsentMode.ALWAYS_REQUIRE:
|
||||
return True
|
||||
# at this point we need to check consent from database
|
||||
if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context:
|
||||
@ -124,6 +125,7 @@ class ConsentStageView(ChallengeStageView[ConsentStage]):
|
||||
return self.get(self.request)
|
||||
if self.should_always_prompt():
|
||||
return self.executor.stage_ok()
|
||||
current_stage: ConsentStage = self.executor.current_stage
|
||||
application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION]
|
||||
permissions = self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS, []
|
||||
@ -137,9 +139,9 @@ class ConsentStageView(ChallengeStageView[ConsentStage]):
|
||||
)
|
||||
consent: UserConsent = self.executor.plan.context[PLAN_CONTEXT_CONSENT]
|
||||
consent.permissions = permissions_string
|
||||
if self.current_stage.mode == ConsentMode.PERMANENT:
|
||||
if current_stage.mode == ConsentMode.PERMANENT:
|
||||
consent.expiring = False
|
||||
if self.current_stage.mode == ConsentMode.EXPIRING:
|
||||
consent.expires = now() + timedelta_from_string(self.current_stage.consent_expire_in)
|
||||
if current_stage.mode == ConsentMode.EXPIRING:
|
||||
consent.expires = now() + timedelta_from_string(current_stage.consent_expire_in)
|
||||
consent.save()
|
||||
return self.executor.stage_ok()
|
||||
|
@ -6,10 +6,11 @@ from authentik.flows.stage import StageView
|
||||
from authentik.stages.deny.models import DenyStage
|
||||
|
||||
|
||||
class DenyStageView(StageView[DenyStage]):
|
||||
class DenyStageView(StageView):
|
||||
"""Cancels the current flow"""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Cancels the current flow"""
|
||||
message = self.executor.plan.context.get("deny_message", self.current_stage.deny_message)
|
||||
stage: DenyStage = self.executor.current_stage
|
||||
message = self.executor.plan.context.get("deny_message", stage.deny_message)
|
||||
return self.executor.stage_invalid(message)
|
||||
|
@ -30,11 +30,11 @@ class DummyStageView(ChallengeStageView):
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
if self.current_stage.throw_error:
|
||||
if self.executor.current_stage.throw_error:
|
||||
raise SentryIgnoredException("Test error")
|
||||
return DummyChallenge(
|
||||
data={
|
||||
"title": self.current_stage.name,
|
||||
"name": self.current_stage.name,
|
||||
"title": self.executor.current_stage.name,
|
||||
"name": self.executor.current_stage.name,
|
||||
}
|
||||
)
|
||||
|
@ -46,7 +46,7 @@ class EmailChallengeResponse(ChallengeResponse):
|
||||
raise ValidationError(detail="email-sent", code="email-sent")
|
||||
|
||||
|
||||
class EmailStageView(ChallengeStageView[EmailStage]):
|
||||
class EmailStageView(ChallengeStageView):
|
||||
"""Email stage which sends Email for verification"""
|
||||
|
||||
response_class = EmailChallengeResponse
|
||||
@ -72,10 +72,11 @@ class EmailStageView(ChallengeStageView[EmailStage]):
|
||||
def get_token(self) -> FlowToken:
|
||||
"""Get token"""
|
||||
pending_user = self.get_pending_user()
|
||||
current_stage: EmailStage = self.executor.current_stage
|
||||
valid_delta = timedelta(
|
||||
minutes=self.current_stage.token_expiry + 1
|
||||
minutes=current_stage.token_expiry + 1
|
||||
) # + 1 because django timesince always rounds down
|
||||
identifier = slugify(f"ak-email-stage-{self.current_stage.name}-{str(uuid4())}")
|
||||
identifier = slugify(f"ak-email-stage-{current_stage.name}-{str(uuid4())}")
|
||||
# Don't check for validity here, we only care if the token exists
|
||||
tokens = FlowToken.objects.filter(identifier=identifier)
|
||||
if not tokens.exists():
|
||||
@ -104,14 +105,15 @@ class EmailStageView(ChallengeStageView[EmailStage]):
|
||||
email = self.executor.plan.context.get(PLAN_CONTEXT_EMAIL_OVERRIDE, None)
|
||||
if not email:
|
||||
email = pending_user.email
|
||||
current_stage: EmailStage = self.executor.current_stage
|
||||
token = self.get_token()
|
||||
# Send mail to user
|
||||
try:
|
||||
message = TemplateEmailMessage(
|
||||
subject=_(self.current_stage.subject),
|
||||
subject=_(current_stage.subject),
|
||||
to=[(pending_user.name, email)],
|
||||
language=pending_user.locale(self.request),
|
||||
template_name=self.current_stage.template,
|
||||
template_name=current_stage.template,
|
||||
template_context={
|
||||
"url": self.get_full_url(**{QS_KEY_TOKEN: token.key}),
|
||||
"user": pending_user,
|
||||
@ -119,28 +121,26 @@ class EmailStageView(ChallengeStageView[EmailStage]):
|
||||
"token": token.key,
|
||||
},
|
||||
)
|
||||
send_mails(self.current_stage, message)
|
||||
send_mails(current_stage, message)
|
||||
except TemplateSyntaxError as exc:
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=_("Exception occurred while rendering E-mail template"),
|
||||
error=exception_to_string(exc),
|
||||
template=self.current_stage.template,
|
||||
template=current_stage.template,
|
||||
).from_http(self.request)
|
||||
raise StageInvalidException from exc
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
# Check if the user came back from the email link to verify
|
||||
restore_token: FlowToken | None = self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_IS_RESTORED, None
|
||||
)
|
||||
restore_token: FlowToken = self.executor.plan.context.get(PLAN_CONTEXT_IS_RESTORED, None)
|
||||
user = self.get_pending_user()
|
||||
if restore_token:
|
||||
if restore_token.user != user:
|
||||
self.logger.warning("Flow token for non-matching user, denying request")
|
||||
return self.executor.stage_invalid()
|
||||
messages.success(request, _("Successfully verified Email."))
|
||||
if self.current_stage.activate_user_on_success:
|
||||
if self.executor.current_stage.activate_user_on_success:
|
||||
user.is_active = True
|
||||
user.save()
|
||||
return self.executor.stage_ok()
|
||||
|
@ -27,7 +27,6 @@ class IdentificationStageSerializer(StageSerializer):
|
||||
fields = StageSerializer.Meta.fields + [
|
||||
"user_fields",
|
||||
"password_stage",
|
||||
"captcha_stage",
|
||||
"case_insensitive_matching",
|
||||
"show_matched_user",
|
||||
"enrollment_flow",
|
||||
|
@ -1,26 +0,0 @@
|
||||
# Generated by Django 5.0.8 on 2024-08-24 12:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_stages_captcha", "0003_captchastage_error_on_invalid_score_and_more"),
|
||||
("authentik_stages_identification", "0014_identificationstage_pretend"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="identificationstage",
|
||||
name="captcha_stage",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
help_text="When set, the captcha element is shown on the identification stage.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="authentik_stages_captcha.captchastage",
|
||||
),
|
||||
),
|
||||
]
|
@ -8,7 +8,6 @@ from rest_framework.serializers import BaseSerializer
|
||||
|
||||
from authentik.core.models import Source
|
||||
from authentik.flows.models import Flow, Stage
|
||||
from authentik.stages.captcha.models import CaptchaStage
|
||||
from authentik.stages.password.models import PasswordStage
|
||||
|
||||
|
||||
@ -43,15 +42,6 @@ class IdentificationStage(Stage):
|
||||
),
|
||||
),
|
||||
)
|
||||
captcha_stage = models.ForeignKey(
|
||||
CaptchaStage,
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_NULL,
|
||||
help_text=_(
|
||||
("When set, the captcha element is shown on the identification stage."),
|
||||
),
|
||||
)
|
||||
|
||||
case_insensitive_matching = models.BooleanField(
|
||||
default=True,
|
||||
|
@ -30,14 +30,9 @@ from authentik.lib.utils.urls import reverse_with_qs
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
from authentik.sources.oauth.types.apple import AppleLoginChallenge
|
||||
from authentik.sources.plex.models import PlexAuthenticationChallenge
|
||||
from authentik.stages.captcha.stage import (
|
||||
CaptchaChallenge,
|
||||
CaptchaChallengeResponse,
|
||||
CaptchaStageView,
|
||||
)
|
||||
from authentik.stages.identification.models import IdentificationStage
|
||||
from authentik.stages.identification.signals import identification_failed
|
||||
from authentik.stages.password.stage import PasswordChallenge, PasswordStageView, authenticate
|
||||
from authentik.stages.password.stage import authenticate
|
||||
|
||||
|
||||
@extend_schema_field(
|
||||
@ -68,8 +63,8 @@ class IdentificationChallenge(Challenge):
|
||||
"""Identification challenges with all UI elements"""
|
||||
|
||||
user_fields = ListField(child=CharField(), allow_empty=True, allow_null=True)
|
||||
password_stage = PasswordChallenge(required=False)
|
||||
captcha_stage = CaptchaChallenge(required=False)
|
||||
password_fields = BooleanField()
|
||||
allow_show_password = BooleanField(default=False)
|
||||
application_pre = CharField(required=False)
|
||||
flow_designation = ChoiceField(FlowDesignation.choices)
|
||||
|
||||
@ -89,7 +84,6 @@ class IdentificationChallengeResponse(ChallengeResponse):
|
||||
uid_field = CharField()
|
||||
password = CharField(required=False, allow_blank=True, allow_null=True)
|
||||
component = CharField(default="ak-stage-identification")
|
||||
captcha = CaptchaChallengeResponse(required=False)
|
||||
|
||||
pre_user: User | None = None
|
||||
|
||||
@ -134,50 +128,49 @@ class IdentificationChallengeResponse(ChallengeResponse):
|
||||
return attrs
|
||||
raise ValidationError("Failed to authenticate.")
|
||||
self.pre_user = pre_user
|
||||
if current_stage.password_stage:
|
||||
password = attrs.get("password", None)
|
||||
if not password:
|
||||
self.stage.logger.warning("Password not set for ident+auth attempt")
|
||||
try:
|
||||
with start_span(
|
||||
op="authentik.stages.identification.authenticate",
|
||||
description="User authenticate call (combo stage)",
|
||||
):
|
||||
user = authenticate(
|
||||
self.stage.request,
|
||||
current_stage.password_stage.backends,
|
||||
current_stage,
|
||||
username=self.pre_user.username,
|
||||
password=password,
|
||||
)
|
||||
if not user:
|
||||
raise ValidationError("Failed to authenticate.")
|
||||
self.pre_user = user
|
||||
except PermissionDenied as exc:
|
||||
raise ValidationError(str(exc)) from exc
|
||||
print(attrs)
|
||||
# if current_stage.captcha_stage:
|
||||
# captcha = CaptchaStageView(self.stage.executor)
|
||||
# captcha.stage = current_stage.captcha_stage
|
||||
# captcha.challenge_valid(attrs.get("captcha"))
|
||||
if not current_stage.password_stage:
|
||||
# No password stage select, don't validate the password
|
||||
return attrs
|
||||
|
||||
password = attrs.get("password", None)
|
||||
if not password:
|
||||
self.stage.logger.warning("Password not set for ident+auth attempt")
|
||||
try:
|
||||
with start_span(
|
||||
op="authentik.stages.identification.authenticate",
|
||||
description="User authenticate call (combo stage)",
|
||||
):
|
||||
user = authenticate(
|
||||
self.stage.request,
|
||||
current_stage.password_stage.backends,
|
||||
current_stage,
|
||||
username=self.pre_user.username,
|
||||
password=password,
|
||||
)
|
||||
if not user:
|
||||
raise ValidationError("Failed to authenticate.")
|
||||
self.pre_user = user
|
||||
except PermissionDenied as exc:
|
||||
raise ValidationError(str(exc)) from exc
|
||||
return attrs
|
||||
|
||||
|
||||
class IdentificationStageView(ChallengeStageView[IdentificationStage]):
|
||||
class IdentificationStageView(ChallengeStageView):
|
||||
"""Form to identify the user"""
|
||||
|
||||
response_class = IdentificationChallengeResponse
|
||||
|
||||
def get_user(self, uid_value: str) -> User | None:
|
||||
"""Find user instance. Returns None if no user was found."""
|
||||
current_stage: IdentificationStage = self.executor.current_stage
|
||||
query = Q()
|
||||
for search_field in self.current_stage.user_fields:
|
||||
for search_field in current_stage.user_fields:
|
||||
model_field = {
|
||||
"email": "email",
|
||||
"username": "username",
|
||||
"upn": "attributes__upn",
|
||||
}[search_field]
|
||||
if self.current_stage.case_insensitive_matching:
|
||||
if current_stage.case_insensitive_matching:
|
||||
model_field += "__iexact"
|
||||
else:
|
||||
model_field += "__exact"
|
||||
@ -198,12 +191,16 @@ class IdentificationStageView(ChallengeStageView[IdentificationStage]):
|
||||
return _("Continue")
|
||||
|
||||
def get_challenge(self) -> Challenge:
|
||||
current_stage: IdentificationStage = self.executor.current_stage
|
||||
challenge = IdentificationChallenge(
|
||||
data={
|
||||
"component": "ak-stage-identification",
|
||||
"primary_action": self.get_primary_action(),
|
||||
"user_fields": self.current_stage.user_fields,
|
||||
"show_source_labels": self.current_stage.show_source_labels,
|
||||
"user_fields": current_stage.user_fields,
|
||||
"password_fields": bool(current_stage.password_stage),
|
||||
"allow_show_password": bool(current_stage.password_stage)
|
||||
and current_stage.password_stage.allow_show_password,
|
||||
"show_source_labels": current_stage.show_source_labels,
|
||||
"flow_designation": self.executor.flow.designation,
|
||||
}
|
||||
)
|
||||
@ -215,39 +212,29 @@ class IdentificationStageView(ChallengeStageView[IdentificationStage]):
|
||||
).name
|
||||
get_qs = self.request.session.get(SESSION_KEY_GET, self.request.GET)
|
||||
# Check for related enrollment and recovery flow, add URL to view
|
||||
if self.current_stage.enrollment_flow:
|
||||
if current_stage.enrollment_flow:
|
||||
challenge.initial_data["enroll_url"] = reverse_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
query=get_qs,
|
||||
kwargs={"flow_slug": self.current_stage.enrollment_flow.slug},
|
||||
kwargs={"flow_slug": current_stage.enrollment_flow.slug},
|
||||
)
|
||||
if self.current_stage.recovery_flow:
|
||||
if current_stage.recovery_flow:
|
||||
challenge.initial_data["recovery_url"] = reverse_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
query=get_qs,
|
||||
kwargs={"flow_slug": self.current_stage.recovery_flow.slug},
|
||||
kwargs={"flow_slug": current_stage.recovery_flow.slug},
|
||||
)
|
||||
if self.current_stage.passwordless_flow:
|
||||
if current_stage.passwordless_flow:
|
||||
challenge.initial_data["passwordless_url"] = reverse_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
query=get_qs,
|
||||
kwargs={"flow_slug": self.current_stage.passwordless_flow.slug},
|
||||
kwargs={"flow_slug": current_stage.passwordless_flow.slug},
|
||||
)
|
||||
if self.current_stage.password_stage:
|
||||
password = PasswordStageView(self.executor, self.current_stage.captcha_stage)
|
||||
password_challenge = password.get_challenge()
|
||||
password_challenge.is_valid()
|
||||
challenge.initial_data["password_stage"] = password_challenge.data
|
||||
if self.current_stage.captcha_stage:
|
||||
captcha = CaptchaStageView(self.executor, self.current_stage.captcha_stage)
|
||||
captcha_challenge = captcha.get_challenge()
|
||||
captcha_challenge.is_valid()
|
||||
challenge.initial_data["captcha_stage"] = captcha_challenge.data
|
||||
|
||||
# Check all enabled source, add them if they have a UI Login button.
|
||||
ui_sources = []
|
||||
sources: list[Source] = (
|
||||
self.current_stage.sources.filter(enabled=True).order_by("name").select_subclasses()
|
||||
current_stage.sources.filter(enabled=True).order_by("name").select_subclasses()
|
||||
)
|
||||
for source in sources:
|
||||
ui_login_button = source.ui_login_button(self.request)
|
||||
@ -262,7 +249,8 @@ class IdentificationStageView(ChallengeStageView[IdentificationStage]):
|
||||
|
||||
def challenge_valid(self, response: IdentificationChallengeResponse) -> HttpResponse:
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = response.pre_user
|
||||
if not self.current_stage.show_matched_user:
|
||||
current_stage: IdentificationStage = self.executor.current_stage
|
||||
if not current_stage.show_matched_user:
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER_IDENTIFIER] = (
|
||||
response.validated_data.get("uid_field")
|
||||
)
|
||||
|
@ -17,7 +17,7 @@ INVITATION_IN_EFFECT = "invitation_in_effect"
|
||||
INVITATION = "invitation"
|
||||
|
||||
|
||||
class InvitationStageView(StageView[InvitationStage]):
|
||||
class InvitationStageView(StageView):
|
||||
"""Finalise Authentication flow by logging the user in"""
|
||||
|
||||
def get_token(self) -> str | None:
|
||||
@ -52,10 +52,11 @@ class InvitationStageView(StageView[InvitationStage]):
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Apply data to the current flow based on a URL"""
|
||||
stage: InvitationStage = self.executor.current_stage
|
||||
|
||||
invite = self.get_invite()
|
||||
if not invite:
|
||||
if self.current_stage.continue_flow_without_invitation:
|
||||
if stage.continue_flow_without_invitation:
|
||||
return self.executor.stage_ok()
|
||||
return self.executor.stage_invalid(_("Invalid invite/invite not found"))
|
||||
|
||||
|
@ -130,7 +130,7 @@ class PasswordChallengeResponse(ChallengeResponse):
|
||||
return password
|
||||
|
||||
|
||||
class PasswordStageView(ChallengeStageView[PasswordStage]):
|
||||
class PasswordStageView(ChallengeStageView):
|
||||
"""Authentication stage which authenticates against django's AuthBackend"""
|
||||
|
||||
response_class = PasswordChallengeResponse
|
||||
@ -138,7 +138,7 @@ class PasswordStageView(ChallengeStageView[PasswordStage]):
|
||||
def get_challenge(self) -> Challenge:
|
||||
challenge = PasswordChallenge(
|
||||
data={
|
||||
"allow_show_password": self.current_stage.allow_show_password,
|
||||
"allow_show_password": self.executor.current_stage.allow_show_password,
|
||||
}
|
||||
)
|
||||
recovery_flow = Flow.objects.filter(designation=FlowDesignation.RECOVERY)
|
||||
@ -154,9 +154,10 @@ class PasswordStageView(ChallengeStageView[PasswordStage]):
|
||||
if SESSION_KEY_INVALID_TRIES not in self.request.session:
|
||||
self.request.session[SESSION_KEY_INVALID_TRIES] = 0
|
||||
self.request.session[SESSION_KEY_INVALID_TRIES] += 1
|
||||
current_stage: PasswordStage = self.executor.current_stage
|
||||
if (
|
||||
self.request.session[SESSION_KEY_INVALID_TRIES]
|
||||
>= self.current_stage.failed_attempts_before_cancel
|
||||
>= current_stage.failed_attempts_before_cancel
|
||||
):
|
||||
self.logger.debug("User has exceeded maximum tries")
|
||||
del self.request.session[SESSION_KEY_INVALID_TRIES]
|
||||
|
@ -222,7 +222,7 @@ class PromptStageView(ChallengeStageView):
|
||||
return serializers
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
fields: list[Prompt] = list(self.current_stage.fields.all().order_by("order"))
|
||||
fields: list[Prompt] = list(self.executor.current_stage.fields.all().order_by("order"))
|
||||
context_prompt = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
|
||||
serializers = self.get_prompt_challenge_fields(fields, context_prompt)
|
||||
challenge = PromptChallenge(
|
||||
@ -239,7 +239,7 @@ class PromptStageView(ChallengeStageView):
|
||||
instance=None,
|
||||
data=data,
|
||||
request=self.request,
|
||||
stage_instance=self.current_stage,
|
||||
stage_instance=self.executor.current_stage,
|
||||
stage=self,
|
||||
plan=self.executor.plan,
|
||||
user=self.get_pending_user(),
|
||||
|
@ -7,10 +7,9 @@ from django.utils.translation import gettext as _
|
||||
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.stages.user_delete.models import UserDeleteStage
|
||||
|
||||
|
||||
class UserDeleteStageView(StageView[UserDeleteStage]):
|
||||
class UserDeleteStageView(StageView):
|
||||
"""Finalise unenrollment flow by deleting the user object."""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
|
@ -39,7 +39,7 @@ class UserLoginChallengeResponse(ChallengeResponse):
|
||||
remember_me = BooleanField(required=True)
|
||||
|
||||
|
||||
class UserLoginStageView(ChallengeStageView[UserLoginStage]):
|
||||
class UserLoginStageView(ChallengeStageView):
|
||||
"""Finalise Authentication flow by logging the user in"""
|
||||
|
||||
response_class = UserLoginChallengeResponse
|
||||
@ -49,7 +49,8 @@ class UserLoginStageView(ChallengeStageView[UserLoginStage]):
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Check for remember_me, and do login"""
|
||||
if timedelta_from_string(self.current_stage.remember_me_offset).total_seconds() > 0:
|
||||
stage: UserLoginStage = self.executor.current_stage
|
||||
if timedelta_from_string(stage.remember_me_offset).total_seconds() > 0:
|
||||
return super().dispatch(request)
|
||||
return self.do_login(request)
|
||||
|
||||
@ -58,9 +59,9 @@ class UserLoginStageView(ChallengeStageView[UserLoginStage]):
|
||||
|
||||
def set_session_duration(self, remember: bool) -> timedelta:
|
||||
"""Update the sessions' expiry"""
|
||||
delta = timedelta_from_string(self.current_stage.session_duration)
|
||||
delta = timedelta_from_string(self.executor.current_stage.session_duration)
|
||||
if remember:
|
||||
offset = timedelta_from_string(self.current_stage.remember_me_offset)
|
||||
offset = timedelta_from_string(self.executor.current_stage.remember_me_offset)
|
||||
delta = delta + offset
|
||||
if delta.total_seconds() == 0:
|
||||
self.request.session.set_expiry(0)
|
||||
@ -70,9 +71,11 @@ class UserLoginStageView(ChallengeStageView[UserLoginStage]):
|
||||
|
||||
def set_session_ip(self):
|
||||
"""Set the sessions' last IP and session bindings"""
|
||||
stage: UserLoginStage = self.executor.current_stage
|
||||
|
||||
self.request.session[SESSION_KEY_LAST_IP] = ClientIPMiddleware.get_client_ip(self.request)
|
||||
self.request.session[SESSION_KEY_BINDING_NET] = self.current_stage.network_binding
|
||||
self.request.session[SESSION_KEY_BINDING_GEO] = self.current_stage.geoip_binding
|
||||
self.request.session[SESSION_KEY_BINDING_NET] = stage.network_binding
|
||||
self.request.session[SESSION_KEY_BINDING_GEO] = stage.geoip_binding
|
||||
|
||||
def do_login(self, request: HttpRequest, remember: bool = False) -> HttpResponse:
|
||||
"""Attach the currently pending user to the current session"""
|
||||
@ -108,7 +111,7 @@ class UserLoginStageView(ChallengeStageView[UserLoginStage]):
|
||||
# as sources show their own success messages
|
||||
if not self.executor.plan.context.get(PLAN_CONTEXT_SOURCE, None):
|
||||
messages.success(self.request, _("Successfully logged in!"))
|
||||
if self.current_stage.terminate_other_sessions:
|
||||
if self.executor.current_stage.terminate_other_sessions:
|
||||
AuthenticatedSession.objects.filter(
|
||||
user=user,
|
||||
).exclude(session_key=self.request.session.session_key).delete()
|
||||
|
@ -4,10 +4,9 @@ from django.contrib.auth import logout
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.stages.user_logout.models import UserLogoutStage
|
||||
|
||||
|
||||
class UserLogoutStageView(StageView[UserLogoutStage]):
|
||||
class UserLogoutStageView(StageView):
|
||||
"""Finalise Authentication flow by logging the user in"""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
|
@ -55,7 +55,7 @@ class UserWriteStageView(StageView):
|
||||
"""Ensure a user exists"""
|
||||
user_created = False
|
||||
path = self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_USER_PATH, self.current_stage.user_path_template
|
||||
PLAN_CONTEXT_USER_PATH, self.executor.current_stage.user_path_template
|
||||
)
|
||||
if path == "":
|
||||
path = User.default_path()
|
||||
@ -64,11 +64,11 @@ class UserWriteStageView(StageView):
|
||||
user_type = UserTypes(
|
||||
self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_USER_TYPE,
|
||||
self.current_stage.user_type,
|
||||
self.executor.current_stage.user_type,
|
||||
)
|
||||
)
|
||||
except ValueError:
|
||||
user_type = self.current_stage.user_type
|
||||
user_type = self.executor.current_stage.user_type
|
||||
if user_type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
|
||||
user_type = UserTypes.SERVICE_ACCOUNT
|
||||
|
||||
@ -76,12 +76,12 @@ class UserWriteStageView(StageView):
|
||||
self.executor.plan.context.setdefault(PLAN_CONTEXT_PENDING_USER, self.request.user)
|
||||
if (
|
||||
PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context
|
||||
or self.current_stage.user_creation_mode == UserCreationMode.ALWAYS_CREATE
|
||||
or self.executor.current_stage.user_creation_mode == UserCreationMode.ALWAYS_CREATE
|
||||
):
|
||||
if self.current_stage.user_creation_mode == UserCreationMode.NEVER_CREATE:
|
||||
if self.executor.current_stage.user_creation_mode == UserCreationMode.NEVER_CREATE:
|
||||
return None, False
|
||||
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = User(
|
||||
is_active=not self.current_stage.create_users_as_inactive,
|
||||
is_active=not self.executor.current_stage.create_users_as_inactive,
|
||||
path=path,
|
||||
type=user_type,
|
||||
)
|
||||
@ -180,8 +180,8 @@ class UserWriteStageView(StageView):
|
||||
try:
|
||||
with transaction.atomic():
|
||||
user.save()
|
||||
if self.current_stage.create_users_group:
|
||||
user.ak_groups.add(self.current_stage.create_users_group)
|
||||
if 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:
|
||||
user.ak_groups.add(*self.executor.plan.context[PLAN_CONTEXT_GROUPS])
|
||||
except (IntegrityError, ValueError, TypeError, InternalError) as exc:
|
||||
|
@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2024.6.4 Blueprint schema",
|
||||
"title": "authentik 2024.8.2 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@ -10091,11 +10091,6 @@
|
||||
"title": "Password stage",
|
||||
"description": "When set, shows a password field, instead of showing the password field as separate step."
|
||||
},
|
||||
"captcha_stage": {
|
||||
"type": "integer",
|
||||
"title": "Captcha stage",
|
||||
"description": "When set, the captcha element is shown on the identification stage."
|
||||
},
|
||||
"case_insensitive_matching": {
|
||||
"type": "boolean",
|
||||
"title": "Case insensitive matching",
|
||||
|
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.4}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.2}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -52,7 +52,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.6.4}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.8.2}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
6
go.mod
6
go.mod
@ -18,18 +18,18 @@ require (
|
||||
github.com/gorilla/securecookie v1.1.2
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jellydator/ttlcache/v3 v3.2.0
|
||||
github.com/jellydator/ttlcache/v3 v3.2.1
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||
github.com/pires/go-proxyproto v0.7.0
|
||||
github.com/prometheus/client_golang v1.20.1
|
||||
github.com/prometheus/client_golang v1.20.2
|
||||
github.com/redis/go-redis/v9 v9.6.1
|
||||
github.com/sethvargo/go-envconfig v1.1.0
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2024063.13
|
||||
goauthentik.io/api/v3 v3.2024064.1
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.22.0
|
||||
golang.org/x/sync v0.8.0
|
||||
|
16
go.sum
16
go.sum
@ -200,8 +200,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jellydator/ttlcache/v3 v3.2.0 h1:6lqVJ8X3ZaUwvzENqPAobDsXNExfUJd61u++uW8a3LE=
|
||||
github.com/jellydator/ttlcache/v3 v3.2.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
github.com/jellydator/ttlcache/v3 v3.2.1 h1:eS8ljnYY7BllYGkXw/TfczWZrXUu/CH7SIkC6ugn9Js=
|
||||
github.com/jellydator/ttlcache/v3 v3.2.1/go.mod h1:bj2/e0l4jRnQdrnSTaGTsh4GSXvMjQcy41i7th0GVGw=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
@ -239,8 +239,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.1 h1:IMJXHOD6eARkQpxo8KkhgEVFlBNm+nkrFUyGlIu7Na8=
|
||||
github.com/prometheus/client_golang v1.20.1/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_golang v1.20.2 h1:5ctymQzZlyOON1666svgwn3s6IKWgfbjsejTMiXIyjg=
|
||||
github.com/prometheus/client_golang v1.20.2/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
@ -297,10 +297,10 @@ go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucg
|
||||
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
|
||||
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
|
||||
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
|
||||
goauthentik.io/api/v3 v3.2024063.13 h1:zWFlrr+8NOaQOCPSRV1FhbDJ58+BPa9BqjNvl4T//s8=
|
||||
goauthentik.io/api/v3 v3.2024063.13/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
goauthentik.io/api/v3 v3.2024064.1 h1:vxquklgDGD+nGFhWRAsQ7ezQKg17MRq6bzEk25fbsb4=
|
||||
goauthentik.io/api/v3 v3.2024064.1/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
|
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.6.4"
|
||||
const VERSION = "2024.8.2"
|
||||
|
@ -35,10 +35,11 @@ func Paginator[Tobj any, Treq any, Tres PaginatorResponse[Tobj]](
|
||||
req PaginatorRequest[Treq, Tres],
|
||||
opts PaginatorOptions,
|
||||
) ([]Tobj, error) {
|
||||
var bfreq, cfreq interface{}
|
||||
fetchOffset := func(page int32) (Tres, error) {
|
||||
req.Page(page)
|
||||
req.PageSize(int32(opts.PageSize))
|
||||
res, _, err := req.Execute()
|
||||
bfreq = req.Page(page)
|
||||
cfreq = bfreq.(PaginatorRequest[Treq, Tres]).PageSize(int32(opts.PageSize))
|
||||
res, _, err := cfreq.(PaginatorRequest[Treq, Tres]).Execute()
|
||||
if err != nil {
|
||||
opts.Logger.WithError(err).WithField("page", page).Warning("failed to fetch page")
|
||||
}
|
||||
|
26
internal/outpost/ak/api_utils_test.go
Normal file
26
internal/outpost/ak/api_utils_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package ak
|
||||
|
||||
// func Test_PaginatorCompile(t *testing.T) {
|
||||
// req := api.ApiCoreUsersListRequest{}
|
||||
// Paginator(req, PaginatorOptions{
|
||||
// PageSize: 100,
|
||||
// })
|
||||
// }
|
||||
|
||||
// func Test_PaginatorCompileExplicit(t *testing.T) {
|
||||
// req := api.ApiCoreUsersListRequest{}
|
||||
// Paginator[
|
||||
// api.User,
|
||||
// api.ApiCoreUsersListRequest,
|
||||
// *api.PaginatedUserList,
|
||||
// ](req, PaginatorOptions{
|
||||
// PageSize: 100,
|
||||
// })
|
||||
// }
|
||||
|
||||
// func Test_PaginatorCompileOther(t *testing.T) {
|
||||
// req := api.ApiOutpostsProxyListRequest{}
|
||||
// Paginator(req, PaginatorOptions{
|
||||
// PageSize: 100,
|
||||
// })
|
||||
// }
|
@ -96,7 +96,7 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
|
||||
return ldap.LDAPResultOperationsError, nil
|
||||
}
|
||||
flags.UserPk = userInfo.User.Pk
|
||||
flags.CanSearch = access.HasSearchPermission != nil
|
||||
flags.CanSearch = access.GetHasSearchPermission()
|
||||
db.si.SetFlags(req.BindDN, &flags)
|
||||
if flags.CanSearch {
|
||||
req.Log().Debug("Allowed access to search")
|
||||
|
@ -193,7 +193,17 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server) (*A
|
||||
})
|
||||
|
||||
mux.HandleFunc("/outpost.goauthentik.io/start", func(w http.ResponseWriter, r *http.Request) {
|
||||
a.handleAuthStart(w, r, "")
|
||||
fwd := ""
|
||||
// This should only really be hit for nginx forward_auth
|
||||
// as for that the auth start redirect URL is generated by the
|
||||
// reverse proxy, and as such we won't have a request we just
|
||||
// denied to reference for final URL
|
||||
rd, ok := a.checkRedirectParam(r)
|
||||
if ok {
|
||||
a.log.WithField("rd", rd).Trace("Setting redirect")
|
||||
fwd = rd
|
||||
}
|
||||
a.handleAuthStart(w, r, fwd)
|
||||
})
|
||||
mux.HandleFunc("/outpost.goauthentik.io/callback", a.handleAuthCallback)
|
||||
mux.HandleFunc("/outpost.goauthentik.io/sign_out", a.handleSignOut)
|
||||
|
@ -15,36 +15,6 @@ const (
|
||||
LogoutSignature = "X-authentik-logout"
|
||||
)
|
||||
|
||||
func (a *Application) checkRedirectParam(r *http.Request) (string, bool) {
|
||||
rd := r.URL.Query().Get(redirectParam)
|
||||
if rd == "" {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(rd)
|
||||
if err != nil {
|
||||
a.log.WithError(err).Warning("Failed to parse redirect URL")
|
||||
return "", false
|
||||
}
|
||||
// Check to make sure we only redirect to allowed places
|
||||
if a.Mode() == api.PROXYMODE_PROXY || a.Mode() == api.PROXYMODE_FORWARD_SINGLE {
|
||||
ext, err := url.Parse(a.proxyConfig.ExternalHost)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
ext.Scheme = ""
|
||||
if !strings.Contains(u.String(), ext.String()) {
|
||||
a.log.WithField("url", u.String()).WithField("ext", ext.String()).Warning("redirect URI did not contain external host")
|
||||
return "", false
|
||||
}
|
||||
} else {
|
||||
if !strings.HasSuffix(u.Host, *a.proxyConfig.CookieDomain) {
|
||||
a.log.WithField("host", u.Host).WithField("dom", *a.proxyConfig.CookieDomain).Warning("redirect URI Host was not included in cookie domain")
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return u.String(), true
|
||||
}
|
||||
|
||||
func (a *Application) handleAuthStart(rw http.ResponseWriter, r *http.Request, fwd string) {
|
||||
state, err := a.createState(r, fwd)
|
||||
if err != nil {
|
||||
|
@ -5,10 +5,13 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"goauthentik.io/api/v3"
|
||||
)
|
||||
|
||||
type OAuthState struct {
|
||||
@ -27,6 +30,44 @@ func (oas *OAuthState) GetAudience() (jwt.ClaimStrings, error) { return ni
|
||||
|
||||
var base32RawStdEncoding = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
// Validate that the given redirect parameter (?rd=...) is valid and can be used
|
||||
// For proxy/forward_single this checks that if the `rd` param has a Hostname (and is a full URL)
|
||||
// the hostname matches what's configured, or no hostname must be given
|
||||
// For forward_domain this checks if the domain of the URL in `rd` ends with the configured domain
|
||||
func (a *Application) checkRedirectParam(r *http.Request) (string, bool) {
|
||||
rd := r.URL.Query().Get(redirectParam)
|
||||
if rd == "" {
|
||||
return "", false
|
||||
}
|
||||
u, err := url.Parse(rd)
|
||||
if err != nil {
|
||||
a.log.WithError(err).Warning("Failed to parse redirect URL")
|
||||
return "", false
|
||||
}
|
||||
// Check to make sure we only redirect to allowed places
|
||||
if a.Mode() == api.PROXYMODE_PROXY || a.Mode() == api.PROXYMODE_FORWARD_SINGLE {
|
||||
ext, err := url.Parse(a.proxyConfig.ExternalHost)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
// Either hostname needs to match the configured domain, or host name must be empty for just a path
|
||||
if u.Host == "" {
|
||||
u.Host = ext.Host
|
||||
u.Scheme = ext.Scheme
|
||||
}
|
||||
if u.Host != ext.Host {
|
||||
a.log.WithField("url", u.String()).WithField("ext", ext.String()).Warning("redirect URI did not contain external host")
|
||||
return "", false
|
||||
}
|
||||
} else {
|
||||
if !strings.HasSuffix(u.Host, *a.proxyConfig.CookieDomain) {
|
||||
a.log.WithField("host", u.Host).WithField("dom", *a.proxyConfig.CookieDomain).Warning("redirect URI Host was not included in cookie domain")
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return u.String(), true
|
||||
}
|
||||
|
||||
func (a *Application) createState(r *http.Request, fwd string) (string, error) {
|
||||
s, _ := a.sessions.Get(r, a.SessionName())
|
||||
if s.ID == "" {
|
||||
@ -39,17 +80,6 @@ func (a *Application) createState(r *http.Request, fwd string) (string, error) {
|
||||
SessionID: s.ID,
|
||||
Redirect: fwd,
|
||||
}
|
||||
if fwd == "" {
|
||||
// This should only really be hit for nginx forward_auth
|
||||
// as for that the auth start redirect URL is generated by the
|
||||
// reverse proxy, and as such we won't have a request we just
|
||||
// denied to reference for final URL
|
||||
rd, ok := a.checkRedirectParam(r)
|
||||
if ok {
|
||||
a.log.WithField("rd", rd).Trace("Setting redirect")
|
||||
st.Redirect = rd
|
||||
}
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, st)
|
||||
tokenString, err := token.SignedString([]byte(a.proxyConfig.GetCookieSecret()))
|
||||
if err != nil {
|
||||
|
@ -8,25 +8,45 @@ import (
|
||||
"goauthentik.io/api/v3"
|
||||
)
|
||||
|
||||
func TestCheckRedirectParam(t *testing.T) {
|
||||
func TestCheckRedirectParam_None(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
// Test no rd param
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start", nil)
|
||||
|
||||
rd, ok := a.checkRedirectParam(req)
|
||||
|
||||
assert.Equal(t, false, ok)
|
||||
assert.Equal(t, "", rd)
|
||||
}
|
||||
|
||||
req, _ = http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://google.com", nil)
|
||||
func TestCheckRedirectParam_Invalid(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
// Test invalid rd param
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://google.com", nil)
|
||||
|
||||
rd, ok = a.checkRedirectParam(req)
|
||||
rd, ok := a.checkRedirectParam(req)
|
||||
|
||||
assert.Equal(t, false, ok)
|
||||
assert.Equal(t, "", rd)
|
||||
}
|
||||
|
||||
req, _ = http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test?foo", nil)
|
||||
func TestCheckRedirectParam_ValidFull(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
// Test valid full rd param
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=https://ext.t.goauthentik.io/test?foo", nil)
|
||||
|
||||
rd, ok = a.checkRedirectParam(req)
|
||||
rd, ok := a.checkRedirectParam(req)
|
||||
|
||||
assert.Equal(t, true, ok)
|
||||
assert.Equal(t, "https://ext.t.goauthentik.io/test?foo", rd)
|
||||
}
|
||||
|
||||
func TestCheckRedirectParam_ValidPartial(t *testing.T) {
|
||||
a := newTestApplication()
|
||||
// Test valid partial rd param
|
||||
req, _ := http.NewRequest("GET", "/outpost.goauthentik.io/auth/start?rd=/test?foo", nil)
|
||||
|
||||
rd, ok := a.checkRedirectParam(req)
|
||||
|
||||
assert.Equal(t, true, ok)
|
||||
assert.Equal(t, "https://ext.t.goauthentik.io/test?foo", rd)
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2024.6.4",
|
||||
"version": "2024.8.2",
|
||||
"private": true
|
||||
}
|
||||
|
66
poetry.lock
generated
66
poetry.lock
generated
@ -1053,38 +1053,38 @@ toml = ["tomli"]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "43.0.0"
|
||||
version = "43.0.1"
|
||||
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"},
|
||||
{file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"},
|
||||
{file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"},
|
||||
{file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"},
|
||||
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"},
|
||||
{file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"},
|
||||
{file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"},
|
||||
{file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"},
|
||||
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"},
|
||||
{file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"},
|
||||
{file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"},
|
||||
{file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"},
|
||||
{file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"},
|
||||
{file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"},
|
||||
{file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"},
|
||||
{file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"},
|
||||
{file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"},
|
||||
{file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"},
|
||||
{file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"},
|
||||
{file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"},
|
||||
{file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"},
|
||||
{file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"},
|
||||
{file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -1097,7 +1097,7 @@ nox = ["nox"]
|
||||
pep8test = ["check-sdist", "click", "mypy", "ruff"]
|
||||
sdist = ["build"]
|
||||
ssh = ["bcrypt (>=3.1.5)"]
|
||||
test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
|
||||
test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"]
|
||||
test-randomorder = ["pytest-randomly"]
|
||||
|
||||
[[package]]
|
||||
@ -1312,17 +1312,17 @@ django = ">=3"
|
||||
|
||||
[[package]]
|
||||
name = "django-pglock"
|
||||
version = "1.5.1"
|
||||
version = "1.6.0"
|
||||
description = "Postgres locking routines and lock table access."
|
||||
optional = false
|
||||
python-versions = "<4,>=3.8.0"
|
||||
files = [
|
||||
{file = "django_pglock-1.5.1-py3-none-any.whl", hash = "sha256:d3b977922abbaffd43968714b69cdab7453866adf2b0695fb497491748d7bc67"},
|
||||
{file = "django_pglock-1.5.1.tar.gz", hash = "sha256:291903d5d877b68558003e1d64d764ebd5590344ba3b7aa1d5127df5947869b1"},
|
||||
{file = "django_pglock-1.6.0-py3-none-any.whl", hash = "sha256:41c98d0bd3738d11e6eaefcc3e5146028f118a593ac58c13d663b751170f01de"},
|
||||
{file = "django_pglock-1.6.0.tar.gz", hash = "sha256:724450ecc9886f39af599c477d84ad086545a5373215ef7a670cd25faca25a61"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
django = ">=3"
|
||||
django = ">=4"
|
||||
django-pgactivity = ">=1.2,<2"
|
||||
|
||||
[[package]]
|
||||
|
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2024.6.4"
|
||||
version = "2024.8.2"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
|
32
schema.yml
32
schema.yml
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2024.6.4
|
||||
version: 2024.8.2
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@ -40457,10 +40457,11 @@ components:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
password_stage:
|
||||
$ref: '#/components/schemas/PasswordChallenge'
|
||||
captcha_stage:
|
||||
$ref: '#/components/schemas/CaptchaChallenge'
|
||||
password_fields:
|
||||
type: boolean
|
||||
allow_show_password:
|
||||
type: boolean
|
||||
default: false
|
||||
application_pre:
|
||||
type: string
|
||||
flow_designation:
|
||||
@ -40481,6 +40482,7 @@ components:
|
||||
type: boolean
|
||||
required:
|
||||
- flow_designation
|
||||
- password_fields
|
||||
- primary_action
|
||||
- show_source_labels
|
||||
- user_fields
|
||||
@ -40498,8 +40500,6 @@ components:
|
||||
password:
|
||||
type: string
|
||||
nullable: true
|
||||
captcha:
|
||||
$ref: '#/components/schemas/CaptchaChallengeResponseRequest'
|
||||
required:
|
||||
- uid_field
|
||||
IdentificationStage:
|
||||
@ -40545,12 +40545,6 @@ components:
|
||||
nullable: true
|
||||
description: When set, shows a password field, instead of showing the password
|
||||
field as separate step.
|
||||
captcha_stage:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, the captcha element is shown on the identification
|
||||
stage.
|
||||
case_insensitive_matching:
|
||||
type: boolean
|
||||
description: When enabled, user fields are matched regardless of their casing.
|
||||
@ -40619,12 +40613,6 @@ components:
|
||||
nullable: true
|
||||
description: When set, shows a password field, instead of showing the password
|
||||
field as separate step.
|
||||
captcha_stage:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, the captcha element is shown on the identification
|
||||
stage.
|
||||
case_insensitive_matching:
|
||||
type: boolean
|
||||
description: When enabled, user fields are matched regardless of their casing.
|
||||
@ -45757,12 +45745,6 @@ components:
|
||||
nullable: true
|
||||
description: When set, shows a password field, instead of showing the password
|
||||
field as separate step.
|
||||
captcha_stage:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: When set, the captcha element is shown on the identification
|
||||
stage.
|
||||
case_insensitive_matching:
|
||||
type: boolean
|
||||
description: When enabled, user fields are matched regardless of their casing.
|
||||
|
@ -11,6 +11,7 @@ from ldap3.core.exceptions import LDAPInvalidCredentialsResult
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint, reconcile_app
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id
|
||||
@ -331,6 +332,83 @@ class TestProviderLDAP(SeleniumTestCase):
|
||||
]
|
||||
self.assert_list_dict_equal(expected, response)
|
||||
|
||||
@retry()
|
||||
@apply_blueprint(
|
||||
"default/flow-default-authentication-flow.yaml",
|
||||
"default/flow-default-invalidation-flow.yaml",
|
||||
)
|
||||
@reconcile_app("authentik_tenants")
|
||||
@reconcile_app("authentik_outposts")
|
||||
def test_ldap_bind_search_no_perms(self):
|
||||
"""Test simple bind + search"""
|
||||
user = create_test_user()
|
||||
self._prepare()
|
||||
server = Server("ldap://localhost:3389", get_info=ALL)
|
||||
_connection = Connection(
|
||||
server,
|
||||
raise_exceptions=True,
|
||||
user=f"cn={user.username},ou=users,dc=ldap,dc=goauthentik,dc=io",
|
||||
password=user.username,
|
||||
)
|
||||
_connection.bind()
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.LOGIN,
|
||||
user={
|
||||
"pk": user.pk,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
_connection.search(
|
||||
"ou=Users,DC=ldaP,dc=goauthentik,dc=io",
|
||||
"(objectClass=user)",
|
||||
search_scope=SUBTREE,
|
||||
attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES],
|
||||
)
|
||||
response: list = _connection.response
|
||||
# Remove raw_attributes to make checking easier
|
||||
for obj in response:
|
||||
del obj["raw_attributes"]
|
||||
del obj["raw_dn"]
|
||||
obj["attributes"] = dict(obj["attributes"])
|
||||
expected = [
|
||||
{
|
||||
"dn": f"cn={user.username},ou=users,dc=ldap,dc=goauthentik,dc=io",
|
||||
"attributes": {
|
||||
"cn": user.username,
|
||||
"sAMAccountName": user.username,
|
||||
"uid": user.uid,
|
||||
"name": user.name,
|
||||
"displayName": user.name,
|
||||
"sn": user.name,
|
||||
"mail": user.email,
|
||||
"objectClass": [
|
||||
"top",
|
||||
"person",
|
||||
"organizationalPerson",
|
||||
"inetOrgPerson",
|
||||
"user",
|
||||
"posixAccount",
|
||||
"goauthentik.io/ldap/user",
|
||||
],
|
||||
"uidNumber": 2000 + user.pk,
|
||||
"gidNumber": 2000 + user.pk,
|
||||
"memberOf": [
|
||||
f"cn={group.name},ou=groups,dc=ldap,dc=goauthentik,dc=io"
|
||||
for group in user.ak_groups.all()
|
||||
],
|
||||
"homeDirectory": f"/home/{user.username}",
|
||||
"ak-active": True,
|
||||
"ak-superuser": False,
|
||||
},
|
||||
"type": "searchResEntry",
|
||||
},
|
||||
]
|
||||
self.assert_list_dict_equal(expected, response)
|
||||
|
||||
def assert_list_dict_equal(self, expected: list[dict], actual: list[dict], match_key="dn"):
|
||||
"""Assert a list of dictionaries is identical, ignoring the ordering of items"""
|
||||
self.assertEqual(len(expected), len(actual))
|
||||
|
9519
web/package-lock.json
generated
9519
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -32,7 +32,7 @@
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"lit": "^3.2.0",
|
||||
"md-front-matter": "^1.0.4",
|
||||
"mermaid": "^10.9.1",
|
||||
"mermaid": "^11.0.2",
|
||||
"rapidoc": "^9.3.4",
|
||||
"showdown": "^2.1.0",
|
||||
"style-mod": "^4.1.2",
|
||||
@ -51,7 +51,7 @@
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@changesets/cli": "^2.27.5",
|
||||
"@custom-elements-manifest/analyzer": "^0.10.2",
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@genesiscommunitysuccess/custom-elements-lsp": "^5.0.3",
|
||||
"@hcaptcha/types": "^1.0.4",
|
||||
"@jeysal/storybook-addon-css-user-preferences": "^0.2.0",
|
||||
@ -91,7 +91,6 @@
|
||||
"glob": "^11.0.0",
|
||||
"globals": "^15.9.0",
|
||||
"lit-analyzer": "^2.0.3",
|
||||
"lockfile-lint": "^4.14.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"pseudolocale": "^2.1.0",
|
||||
@ -101,10 +100,10 @@
|
||||
"rollup-plugin-postcss-lit": "^2.1.0",
|
||||
"storybook": "^8.1.11",
|
||||
"storybook-addon-mock": "^5.0.0",
|
||||
"syncpack": "^12.3.3",
|
||||
"syncpack": "^13.0.0",
|
||||
"ts-lit-plugin": "^2.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.6.3",
|
||||
"tslib": "^2.7.0",
|
||||
"turnstile-types": "^1.2.2",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.2.0",
|
||||
@ -257,7 +256,9 @@
|
||||
]
|
||||
},
|
||||
"lint:lockfile": {
|
||||
"command": "lockfile-lint --path package.json --type npm --allowed-hosts npm --validate-https"
|
||||
"__comment": "The lockfile-lint package does not have an option to ensure resolved hashes are set everywhere",
|
||||
"shell": true,
|
||||
"command": "[ -z \"$(jq -r '.packages | to_entries[] | select((.key | startswith(\"node_modules\")) and (.value | has(\"resolved\") | not)) | .key' < package-lock.json)\" ]"
|
||||
},
|
||||
"lint:lockfiles": {
|
||||
"dependencies": [
|
||||
|
@ -14,7 +14,7 @@
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-swc": "^0.3.1",
|
||||
"@swc/cli": "^0.4.0",
|
||||
"@swc/core": "^1.7.14",
|
||||
"@swc/core": "^1.7.18",
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"@types/jquery": "^3.5.30",
|
||||
"lockfile-lint": "^4.14.0",
|
||||
@ -25,7 +25,7 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"@swc/core": "^1.7.14",
|
||||
"@swc/core": "^1.7.18",
|
||||
"@swc/core-darwin-arm64": "^1.6.13",
|
||||
"@swc/core-darwin-x64": "^1.6.13",
|
||||
"@swc/core-linux-arm-gnueabihf": "^1.6.13",
|
||||
|
96
web/sfe/package-lock.json
generated
96
web/sfe/package-lock.json
generated
@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@goauthentik/api": "^2024.6.3-1724337552",
|
||||
"@goauthentik/api": "^2024.6.3-1724414734",
|
||||
"base64-js": "^1.5.1",
|
||||
"bootstrap": "^4.6.1",
|
||||
"formdata-polyfill": "^4.0.10",
|
||||
@ -21,16 +21,16 @@
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-swc": "^0.3.1",
|
||||
"@swc/cli": "^0.4.0",
|
||||
"@swc/core": "^1.7.14",
|
||||
"@swc/core": "^1.7.18",
|
||||
"@types/jquery": "^3.5.30",
|
||||
"rollup": "^4.21.0",
|
||||
"rollup-plugin-copy": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@goauthentik/api": {
|
||||
"version": "2024.6.3-1724337552",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.6.3-1724337552.tgz",
|
||||
"integrity": "sha512-siu5qJqUt13iUPsLI0RfieVkDU8IMhuP2i5C/RRqY6oek0z+srSom9UTBAh6n6a2pTTNQO3clE2zxvAIJPahVg=="
|
||||
"version": "2024.6.3-1724414734",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.6.3-1724414734.tgz",
|
||||
"integrity": "sha512-2fLKwOh2Znc/unD8Q2U4G0g5QFM4jVqC95e5VRWWVnzp3xB7JWfEDBcRdwyv5PxCdmjBUkvbiul0kiuRwqBf4w=="
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
@ -491,9 +491,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.14.tgz",
|
||||
"integrity": "sha512-9aeXeifnyuvc2pcuuhPQgVUwdpGEzZ+9nJu0W8/hNl/aESFsJGR5i9uQJRGu0atoNr01gK092fvmqMmQAPcKow==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.18.tgz",
|
||||
"integrity": "sha512-qL9v5N5S38ijmqiQRvCFUUx2vmxWT/JJ2rswElnyaHkOHuVoAFhBB90Ywj4RKjh3R0zOjhEcemENTyF3q3G6WQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
@ -508,16 +508,16 @@
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.7.14",
|
||||
"@swc/core-darwin-x64": "1.7.14",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.7.14",
|
||||
"@swc/core-linux-arm64-gnu": "1.7.14",
|
||||
"@swc/core-linux-arm64-musl": "1.7.14",
|
||||
"@swc/core-linux-x64-gnu": "1.7.14",
|
||||
"@swc/core-linux-x64-musl": "1.7.14",
|
||||
"@swc/core-win32-arm64-msvc": "1.7.14",
|
||||
"@swc/core-win32-ia32-msvc": "1.7.14",
|
||||
"@swc/core-win32-x64-msvc": "1.7.14"
|
||||
"@swc/core-darwin-arm64": "1.7.18",
|
||||
"@swc/core-darwin-x64": "1.7.18",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.7.18",
|
||||
"@swc/core-linux-arm64-gnu": "1.7.18",
|
||||
"@swc/core-linux-arm64-musl": "1.7.18",
|
||||
"@swc/core-linux-x64-gnu": "1.7.18",
|
||||
"@swc/core-linux-x64-musl": "1.7.18",
|
||||
"@swc/core-win32-arm64-msvc": "1.7.18",
|
||||
"@swc/core-win32-ia32-msvc": "1.7.18",
|
||||
"@swc/core-win32-x64-msvc": "1.7.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": "*"
|
||||
@ -529,9 +529,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.14.tgz",
|
||||
"integrity": "sha512-V0OUXjOH+hdGxDYG8NkQzy25mKOpcNKFpqtZEzLe5V/CpLJPnpg1+pMz70m14s9ZFda9OxsjlvPbg1FLUwhgIQ==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.18.tgz",
|
||||
"integrity": "sha512-MwLc5U+VGPMZm8MjlFBjEB2wyT1EK0NNJ3tn+ps9fmxdFP+PL8EpMiY1O1F2t1ydy2OzBtZz81sycjM9RieFBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -545,9 +545,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.14.tgz",
|
||||
"integrity": "sha512-9iFvUnxG6FC3An5ogp5jbBfQuUmTTwy8KMB+ZddUoPB3NR1eV+Y9vOh/tfWcenSJbgOKDLgYC5D/b1mHAprsrQ==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.7.18.tgz",
|
||||
"integrity": "sha512-IkukOQUw7/14VkHp446OkYGCZEHqZg9pTmTdBawlUyz2JwZMSn2VodCl7aFSdGCsU4Cwni8zKA8CCgkCCAELhw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -561,9 +561,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.14.tgz",
|
||||
"integrity": "sha512-zGJsef9qPivKSH8Vv4F/HiBXBTHZ5Hs3ZjVGo/UIdWPJF8fTL9OVADiRrl34Q7zOZEtGXRwEKLUW1SCQcbDvZA==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.18.tgz",
|
||||
"integrity": "sha512-ATnb6jJaBeXCqrTUawWdoOy7eP9SCI7UMcfXlYIMxX4otKKspLPAEuGA5RaNxlCcj9ObyO0J3YGbtZ6hhD2pjg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -577,9 +577,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.14.tgz",
|
||||
"integrity": "sha512-AxV3MPsoI7i4B8FXOew3dx3N8y00YoJYvIPfxelw07RegeCEH3aHp2U2DtgbP/NV1ugZMx0TL2Z2DEvocmA51g==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.18.tgz",
|
||||
"integrity": "sha512-poHtH7zL7lEp9K2inY90lGHJABWxURAOgWNeZqrcR5+jwIe7q5KBisysH09Zf/JNF9+6iNns+U0xgWTNJzBuGA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -593,9 +593,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.14.tgz",
|
||||
"integrity": "sha512-JDLdNjUj3zPehd4+DrQD8Ltb3B5lD8D05IwePyDWw+uR/YPc7w/TX1FUVci5h3giJnlMCJRvi1IQYV7K1n7KtQ==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.18.tgz",
|
||||
"integrity": "sha512-qnNI1WmcOV7Wz1ZDyK6WrOlzLvJ01rnni8ec950mMHWkLRMP53QvCvhF3S+7gFplWBwWJTOOPPUqJp/PlSxWyQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -609,9 +609,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.14.tgz",
|
||||
"integrity": "sha512-Siy5OvPCLLWmMdx4msnEs8HvEVUEigSn0+3pbLjv78iwzXd0qSBNHUPZyC1xeurVaUbpNDxZTpPRIwpqNE2+Og==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.18.tgz",
|
||||
"integrity": "sha512-x9SCqCLzwtlqtD5At3I1a7Gco+EuXnzrJGoucmkpeQohshHuwa+cskqsXO6u1Dz0jXJEuHbBZB9va1wYYfjgFg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -625,9 +625,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.14.tgz",
|
||||
"integrity": "sha512-FtEGm9mwtRYQNK43WMtUIadxHs/ja2rnDurB99os0ZoFTGG2IHuht2zD97W0wB8JbqEabT1XwSG9Y5wmN+ciEQ==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.18.tgz",
|
||||
"integrity": "sha512-qtj8iOpMMgKjzxTv+islmEY0JBsbd93nka0gzcTTmGZxKtL5jSUsYQvkxwNPZr5M9NU1fgaR3n1vE6lFmtY0IQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -641,9 +641,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.14.tgz",
|
||||
"integrity": "sha512-Jp8KDlfq7Ntt2/BXr0y344cYgB1zf0DaLzDZ1ZJR6rYlAzWYSccLYcxHa97VGnsYhhPspMpmCvHid97oe2hl4A==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.18.tgz",
|
||||
"integrity": "sha512-ltX/Ol9+Qu4SXmISCeuwVgAjSa8nzHTymknpozzVMgjXUoZMoz6lcynfKL1nCh5XLgqh0XNHUKLti5YFF8LrrA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -657,9 +657,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.14.tgz",
|
||||
"integrity": "sha512-I+cFsXF0OU0J9J4zdWiQKKLURO5dvCujH9Jr8N0cErdy54l9d4gfIxdctfTF+7FyXtWKLTCkp+oby9BQhkFGWA==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.18.tgz",
|
||||
"integrity": "sha512-RgTcFP3wgyxnQbTCJrlgBJmgpeTXo8t807GU9GxApAXfpLZJ3swJ2GgFUmIJVdLWyffSHF5BEkF3FmF6mtH5AQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@ -673,9 +673,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"version": "1.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.14.tgz",
|
||||
"integrity": "sha512-NNrprQCK6d28mG436jVo2TD+vACHseUECacEBGZ9Ef0qfOIWS1XIt2MisQKG0Oea2VvLFl6tF/V4Lnx/H0Sn3Q==",
|
||||
"version": "1.7.18",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.18.tgz",
|
||||
"integrity": "sha512-XbZ0wAgzR757+DhQcnv60Y/bK9yuWPhDNRQVFFQVRsowvK3+c6EblyfUSytIidpXgyYFzlprq/9A9ZlO/wvDWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@goauthentik/api": "^2024.6.3-1724337552",
|
||||
"@goauthentik/api": "^2024.6.3-1724414734",
|
||||
"base64-js": "^1.5.1",
|
||||
"bootstrap": "^4.6.1",
|
||||
"formdata-polyfill": "^4.0.10",
|
||||
@ -20,7 +20,7 @@
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"@rollup/plugin-swc": "^0.3.1",
|
||||
"@swc/cli": "^0.4.0",
|
||||
"@swc/core": "^1.7.14",
|
||||
"@swc/core": "^1.7.18",
|
||||
"@types/jquery": "^3.5.30",
|
||||
"rollup": "^4.21.0",
|
||||
"rollup-plugin-copy": "^3.5.0"
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { EVENT_REFRESH } from "@goauthentik/common/constants";
|
||||
import { parseAPIError } from "@goauthentik/common/errors";
|
||||
import "@goauthentik/components/ak-radio-input";
|
||||
import "@goauthentik/components/ak-switch-input";
|
||||
import "@goauthentik/components/ak-text-input";
|
||||
@ -24,7 +25,6 @@ import {
|
||||
type TransactionApplicationRequest,
|
||||
type TransactionApplicationResponse,
|
||||
ValidationError,
|
||||
ValidationErrorFromJSON,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import BasePanel from "../BasePanel";
|
||||
@ -59,7 +59,7 @@ const runningState: State = {
|
||||
};
|
||||
const errorState: State = {
|
||||
state: "error",
|
||||
label: msg("Authentik was unable to save this application:"),
|
||||
label: msg("authentik was unable to save this application:"),
|
||||
icon: ["fa-times-circle", "pf-m-danger"],
|
||||
};
|
||||
|
||||
@ -133,9 +133,7 @@ export class ApplicationWizardCommitApplication extends BasePanel {
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.catch(async (resolution: any) => {
|
||||
const errors = (this.errors = ValidationErrorFromJSON(
|
||||
await resolution.response.json(),
|
||||
));
|
||||
const errors = await parseAPIError(resolution);
|
||||
this.dispatchWizardUpdate({
|
||||
update: {
|
||||
...this.wizard,
|
||||
|
@ -11,7 +11,10 @@ import {
|
||||
redirectUriHelp,
|
||||
subjectModeOptions,
|
||||
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
|
||||
import { oauth2SourcesProvider } from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
|
||||
import {
|
||||
makeSourceSelector,
|
||||
oauth2SourcesProvider,
|
||||
} from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-number-input";
|
||||
@ -263,12 +266,12 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel {
|
||||
name="jwksSources"
|
||||
.errorMessages=${errors?.jwksSources ?? []}
|
||||
>
|
||||
<ak-dual-select-provider
|
||||
<ak-dual-select-dynamic-selected
|
||||
.provider=${oauth2SourcesProvider}
|
||||
.selected=${provider?.jwksSources}
|
||||
.selector=${makeSourceSelector(provider?.jwksSources)}
|
||||
available-label=${msg("Available Sources")}
|
||||
selected-label=${msg("Selected Sources")}
|
||||
></ak-dual-select-provider>
|
||||
></ak-dual-select-dynamic-selected>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
|
||||
|
@ -1,5 +1,8 @@
|
||||
import "@goauthentik/admin/applications/wizard/ak-wizard-title";
|
||||
import { oauth2SourcesProvider } from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
|
||||
import {
|
||||
makeSourceSelector,
|
||||
oauth2SourcesProvider,
|
||||
} from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
|
||||
import {
|
||||
makeProxyPropertyMappingsSelector,
|
||||
proxyPropertyMappingsProvider,
|
||||
@ -11,7 +14,6 @@ import "@goauthentik/components/ak-text-input";
|
||||
import "@goauthentik/components/ak-textarea-input";
|
||||
import "@goauthentik/components/ak-toggle-group";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@ -228,12 +230,12 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
|
||||
name="jwksSources"
|
||||
.errorMessages=${errors?.jwksSources ?? []}
|
||||
>
|
||||
<ak-dual-select-provider
|
||||
<ak-dual-select-dynamic-selected
|
||||
.provider=${oauth2SourcesProvider}
|
||||
.selected=${this.instance?.jwksSources}
|
||||
.selector=${makeSourceSelector(this.instance?.jwksSources)}
|
||||
available-label=${msg("Available Sources")}
|
||||
selected-label=${msg("Selected Sources")}
|
||||
></ak-dual-select-provider>
|
||||
></ak-dual-select-dynamic-selected>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { severityToLabel } from "@goauthentik/common/labels";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
|
||||
import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
||||
import "@goauthentik/elements/forms/Radio";
|
||||
@ -16,6 +18,7 @@ import {
|
||||
EventsApi,
|
||||
Group,
|
||||
NotificationRule,
|
||||
NotificationTransport,
|
||||
PaginatedNotificationTransportList,
|
||||
SeverityEnum,
|
||||
} from "@goauthentik/api";
|
||||
@ -34,6 +37,13 @@ async function eventTransportsProvider(page = 1, search = "") {
|
||||
};
|
||||
}
|
||||
|
||||
export function makeTransportSelector(instanceTransports: string[] | undefined) {
|
||||
const localTransports = instanceTransports ? new Set(instanceTransports) : undefined;
|
||||
|
||||
return localTransports
|
||||
? ([pk, _]: DualSelectPair) => localTransports.has(pk)
|
||||
: ([_0, _1, _2, stage]: DualSelectPair<NotificationTransport>) => stage !== undefined;
|
||||
}
|
||||
@customElement("ak-event-rule-form")
|
||||
export class RuleForm extends ModelForm<NotificationRule, string> {
|
||||
eventTransports?: PaginatedNotificationTransportList;
|
||||
@ -114,12 +124,12 @@ export class RuleForm extends ModelForm<NotificationRule, string> {
|
||||
?required=${true}
|
||||
name="transports"
|
||||
>
|
||||
<ak-dual-select-provider
|
||||
<ak-dual-select-dynamic-selected
|
||||
.provider=${eventTransportsProvider}
|
||||
.selected=${this.instance?.transports}
|
||||
.selector=${makeTransportSelector(this.instance?.transports)}
|
||||
available-label="${msg("Available Transports")}"
|
||||
selected-label="${msg("Selected Transports")}"
|
||||
></ak-dual-select-provider>
|
||||
></ak-dual-select-dynamic-selected>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
|
||||
|
@ -97,7 +97,8 @@ export class OutpostForm extends ModelForm<Outpost, string> {
|
||||
embedded = false;
|
||||
|
||||
@state()
|
||||
providers?: DataProvider;
|
||||
providers: DataProvider = providerProvider(this.type);
|
||||
|
||||
defaultConfig?: OutpostDefaultConfig;
|
||||
|
||||
async loadInstance(pk: string): Promise<Outpost> {
|
||||
@ -113,6 +114,7 @@ export class OutpostForm extends ModelForm<Outpost, string> {
|
||||
this.defaultConfig = await new OutpostsApi(
|
||||
DEFAULT_CONFIG,
|
||||
).outpostsInstancesDefaultSettingsRetrieve();
|
||||
this.providers = providerProvider(this.type);
|
||||
}
|
||||
|
||||
getSuccessMessage(): string {
|
||||
|
@ -8,7 +8,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
interface PropertyMapping {
|
||||
name: string;
|
||||
expression: string;
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export abstract class BasePropertyMappingForm<T extends PropertyMapping> extends ModelForm<
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { BasePropertyMappingForm } from "@goauthentik/admin/property-mappings/BasePropertyMappingForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import "@goauthentik/elements/CodeMirror";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
||||
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
import { NotificationWebhookMapping, PropertymappingsApi } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-property-mapping-notification-form")
|
||||
export class PropertyMappingNotification extends ModelForm<NotificationWebhookMapping, string> {
|
||||
export class PropertyMappingNotification extends BasePropertyMappingForm<NotificationWebhookMapping> {
|
||||
loadInstance(pk: string): Promise<NotificationWebhookMapping> {
|
||||
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsNotificationRetrieve({
|
||||
pmUuid: pk,
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { BasePropertyMappingForm } from "@goauthentik/admin/property-mappings/BasePropertyMappingForm";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { docLink } from "@goauthentik/common/global";
|
||||
import "@goauthentik/elements/CodeMirror";
|
||||
import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
|
||||
import "@goauthentik/elements/forms/Radio";
|
||||
import type { RadioOption } from "@goauthentik/elements/forms/Radio";
|
||||
|
||||
@ -33,21 +33,13 @@ export const staticSettingOptions: RadioOption<string | undefined>[] = [
|
||||
];
|
||||
|
||||
@customElement("ak-property-mapping-provider-rac-form")
|
||||
export class PropertyMappingProviderRACForm extends ModelForm<RACPropertyMapping, string> {
|
||||
export class PropertyMappingProviderRACForm extends BasePropertyMappingForm<RACPropertyMapping> {
|
||||
loadInstance(pk: string): Promise<RACPropertyMapping> {
|
||||
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsProviderRacRetrieve({
|
||||
pmUuid: pk,
|
||||
});
|
||||
}
|
||||
|
||||
getSuccessMessage(): string {
|
||||
if (this.instance) {
|
||||
return msg("Successfully updated mapping.");
|
||||
} else {
|
||||
return msg("Successfully created mapping.");
|
||||
}
|
||||
}
|
||||
|
||||
async send(data: RACPropertyMapping): Promise<RACPropertyMapping> {
|
||||
if (this.instance) {
|
||||
return new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsProviderRacUpdate({
|
||||
|
@ -10,7 +10,7 @@ import { LDAPSourcePropertyMapping, PropertymappingsApi } from "@goauthentik/api
|
||||
@customElement("ak-property-mapping-source-ldap-form")
|
||||
export class PropertyMappingSourceLDAPForm extends BasePropertyMappingForm<LDAPSourcePropertyMapping> {
|
||||
docLink(): string {
|
||||
return "/docs/sources/property-mappings/expression?utm_source=authentik";
|
||||
return "/docs/sources/property-mappings/expressions?utm_source=authentik";
|
||||
}
|
||||
|
||||
loadInstance(pk: string): Promise<LDAPSourcePropertyMapping> {
|
||||
|
@ -10,7 +10,7 @@ import { OAuthSourcePropertyMapping, PropertymappingsApi } from "@goauthentik/ap
|
||||
@customElement("ak-property-mapping-source-oauth-form")
|
||||
export class PropertyMappingSourceOAuthForm extends BasePropertyMappingForm<OAuthSourcePropertyMapping> {
|
||||
docLink(): string {
|
||||
return "/docs/sources/property-mappings/expression?utm_source=authentik";
|
||||
return "/docs/sources/property-mappings/expressions?utm_source=authentik";
|
||||
}
|
||||
|
||||
loadInstance(pk: string): Promise<OAuthSourcePropertyMapping> {
|
||||
|
@ -10,7 +10,7 @@ import { PlexSourcePropertyMapping, PropertymappingsApi } from "@goauthentik/api
|
||||
@customElement("ak-property-mapping-source-plex-form")
|
||||
export class PropertyMappingSourcePlexForm extends BasePropertyMappingForm<PlexSourcePropertyMapping> {
|
||||
docLink(): string {
|
||||
return "/docs/sources/property-mappings/expression?utm_source=authentik";
|
||||
return "/docs/sources/property-mappings/expressions?utm_source=authentik";
|
||||
}
|
||||
|
||||
loadInstance(pk: string): Promise<PlexSourcePropertyMapping> {
|
||||
|
@ -10,7 +10,7 @@ import { PropertymappingsApi, SAMLSourcePropertyMapping } from "@goauthentik/api
|
||||
@customElement("ak-property-mapping-source-saml-form")
|
||||
export class PropertyMappingSourceSAMLForm extends BasePropertyMappingForm<SAMLSourcePropertyMapping> {
|
||||
docLink(): string {
|
||||
return "/docs/sources/property-mappings/expression?utm_source=authentik";
|
||||
return "/docs/sources/property-mappings/expressions?utm_source=authentik";
|
||||
}
|
||||
|
||||
loadInstance(pk: string): Promise<SAMLSourcePropertyMapping> {
|
||||
|
@ -10,7 +10,7 @@ import { PropertymappingsApi, SCIMSourcePropertyMapping } from "@goauthentik/api
|
||||
@customElement("ak-property-mapping-source-scim-form")
|
||||
export class PropertyMappingSourceSCIMForm extends BasePropertyMappingForm<SCIMSourcePropertyMapping> {
|
||||
docLink(): string {
|
||||
return "/docs/sources/property-mappings/expression?utm_source=authentik";
|
||||
return "/docs/sources/property-mappings/expressions?utm_source=authentik";
|
||||
}
|
||||
|
||||
loadInstance(pk: string): Promise<SCIMSourcePropertyMapping> {
|
||||
|
@ -61,7 +61,9 @@ export class PolicyTestForm extends Form<PropertyMappingTestRequest> {
|
||||
</ak-codemirror>`
|
||||
: html` <div class="pf-c-form__group-label">
|
||||
<div class="c-form__horizontal-group">
|
||||
<span class="pf-c-form__label-text">${this.result?.result}</span>
|
||||
<span class="pf-c-form__label-text">
|
||||
<pre>${this.result?.result}</pre>
|
||||
</span>
|
||||
</div>
|
||||
</div>`}
|
||||
</ak-form-element-horizontal>`;
|
||||
|
@ -27,7 +27,7 @@ export class GoogleWorkspaceProviderGroupList extends Table<GoogleWorkspaceProvi
|
||||
renderToolbar(): TemplateResult {
|
||||
return html`<ak-forms-modal cancelText=${msg("Close")} ?closeAfterSuccessfulSubmit=${false}>
|
||||
<span slot="submit">${msg("Sync")}</span>
|
||||
<span slot="header">${msg("Sync User")}</span>
|
||||
<span slot="header">${msg("Sync Group")}</span>
|
||||
<ak-sync-object-form
|
||||
.provider=${this.providerId}
|
||||
model=${SyncObjectModelEnum.Group}
|
||||
|
@ -24,7 +24,7 @@ export class MicrosoftEntraProviderGroupList extends Table<MicrosoftEntraProvide
|
||||
renderToolbar(): TemplateResult {
|
||||
return html`<ak-forms-modal cancelText=${msg("Close")} ?closeAfterSuccessfulSubmit=${false}>
|
||||
<span slot="submit">${msg("Sync")}</span>
|
||||
<span slot="header">${msg("Sync User")}</span>
|
||||
<span slot="header">${msg("Sync Group")}</span>
|
||||
<ak-sync-object-form
|
||||
.provider=${this.providerId}
|
||||
model=${SyncObjectModelEnum.Group}
|
||||
|
@ -3,6 +3,12 @@ import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js";
|
||||
|
||||
import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api";
|
||||
|
||||
export const defaultScopes = [
|
||||
"goauthentik.io/providers/oauth2/scope-openid",
|
||||
"goauthentik.io/providers/oauth2/scope-email",
|
||||
"goauthentik.io/providers/oauth2/scope-profile",
|
||||
];
|
||||
|
||||
export async function oauth2PropertyMappingsProvider(page = 1, search = "") {
|
||||
const propertyMappings = await new PropertymappingsApi(
|
||||
DEFAULT_CONFIG,
|
||||
@ -23,6 +29,5 @@ export function makeOAuth2PropertyMappingsSelector(instanceMappings: string[] |
|
||||
return localMappings
|
||||
? ([pk, _]: DualSelectPair) => localMappings.has(pk)
|
||||
: ([_0, _1, _2, scope]: DualSelectPair<ScopeMapping>) =>
|
||||
scope?.managed?.startsWith("goauthentik.io/providers/oauth2/scope-") &&
|
||||
scope?.managed !== "goauthentik.io/providers/oauth2/scope-offline_access";
|
||||
scope?.managed && defaultScopes.includes(scope?.managed);
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ import {
|
||||
makeOAuth2PropertyMappingsSelector,
|
||||
oauth2PropertyMappingsProvider,
|
||||
} from "./OAuth2PropertyMappings.js";
|
||||
import { oauth2SourcesProvider } from "./OAuth2Sources.js";
|
||||
import { makeSourceSelector, oauth2SourcesProvider } from "./OAuth2Sources.js";
|
||||
|
||||
export const clientTypeOptions = [
|
||||
{
|
||||
@ -52,12 +52,6 @@ export const clientTypeOptions = [
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultScopes = [
|
||||
"goauthentik.io/providers/oauth2/scope-openid",
|
||||
"goauthentik.io/providers/oauth2/scope-email",
|
||||
"goauthentik.io/providers/oauth2/scope-profile",
|
||||
];
|
||||
|
||||
export const subjectModeOptions = [
|
||||
{
|
||||
label: msg("Based on the User's hashed ID"),
|
||||
@ -335,12 +329,12 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
|
||||
label=${msg("Trusted OIDC Sources")}
|
||||
name="jwksSources"
|
||||
>
|
||||
<ak-dual-select-provider
|
||||
<ak-dual-select-dynamic-selected
|
||||
.provider=${oauth2SourcesProvider}
|
||||
.selected=${provider?.jwksSources}
|
||||
.selector=${makeSourceSelector(provider?.jwksSources)}
|
||||
available-label=${msg("Available Sources")}
|
||||
selected-label=${msg("Selected Sources")}
|
||||
></ak-dual-select-provider>
|
||||
></ak-dual-select-dynamic-selected>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types";
|
||||
|
||||
import { SourcesApi } from "@goauthentik/api";
|
||||
import { OAuthSource, SourcesApi } from "@goauthentik/api";
|
||||
|
||||
export async function oauth2SourcesProvider(page = 1, search = "") {
|
||||
const oauthSources = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthList({
|
||||
@ -19,3 +20,11 @@ export async function oauth2SourcesProvider(page = 1, search = "") {
|
||||
]),
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSourceSelector(instanceSources: string[] | undefined) {
|
||||
const localSources = instanceSources ? new Set(instanceSources) : undefined;
|
||||
|
||||
return localSources
|
||||
? ([pk, _]: DualSelectPair) => localSources.has(pk)
|
||||
: ([_0, _1, _2, prompt]: DualSelectPair<OAuthSource>) => prompt !== undefined;
|
||||
}
|
||||
|
@ -1,12 +1,14 @@
|
||||
import "@goauthentik/admin/common/ak-crypto-certificate-search";
|
||||
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
|
||||
import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm";
|
||||
import { oauth2SourcesProvider } from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
|
||||
import {
|
||||
makeSourceSelector,
|
||||
oauth2SourcesProvider,
|
||||
} from "@goauthentik/admin/providers/oauth2/OAuth2Sources.js";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { first } from "@goauthentik/common/utils";
|
||||
import "@goauthentik/components/ak-toggle-group";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
|
||||
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js";
|
||||
import "@goauthentik/elements/forms/FormGroup";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import "@goauthentik/elements/forms/SearchSelect";
|
||||
@ -403,12 +405,12 @@ ${this.instance?.skipPathRegex}</textarea
|
||||
label=${msg("Trusted OIDC Sources")}
|
||||
name="jwksSources"
|
||||
>
|
||||
<ak-dual-select-provider
|
||||
<ak-dual-select-dynamic-selected
|
||||
.provider=${oauth2SourcesProvider}
|
||||
.selected=${this.instance?.jwksSources}
|
||||
.selector=${makeSourceSelector(this.instance?.jwksSources)}
|
||||
available-label=${msg("Available Sources")}
|
||||
selected-label=${msg("Selected Sources")}
|
||||
></ak-dual-select-provider>
|
||||
></ak-dual-select-dynamic-selected>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"JWTs signed by certificates configured in the selected sources can be used to authenticate to this provider.",
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user