Compare commits
18 Commits
version/20
...
version-20
| Author | SHA1 | Date | |
|---|---|---|---|
| a36f788e60 | |||
| 50ad69bdad | |||
| 0edd7531a1 | |||
| 5a2c914d19 | |||
| f21062581a | |||
| 676e7885e8 | |||
| 80441d2277 | |||
| e760f73518 | |||
| 948f80d7ae | |||
| 0e4b153e7f | |||
| efac5ce7bd | |||
| d9fbe1d467 | |||
| 527e584699 | |||
| 80dfe371e6 | |||
| a3d1491aee | |||
| 1b98792637 | |||
| 111e120220 | |||
| 20642d49c3 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.10.3
|
||||
current_version = 2024.10.5
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
||||
2
.github/actions/setup/action.yml
vendored
2
.github/actions/setup/action.yml
vendored
@ -35,7 +35,7 @@ runs:
|
||||
run: |
|
||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
||||
poetry install
|
||||
poetry install --sync
|
||||
cd web && npm ci
|
||||
- name: Generate config
|
||||
shell: poetry run python {0}
|
||||
|
||||
2
.github/workflows/release-tag.yml
vendored
2
.github/workflows/release-tag.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
|
||||
docker buildx install
|
||||
mkdir -p ./gen-ts-api
|
||||
docker build -t testing:latest .
|
||||
docker build --no-cache -t testing:latest .
|
||||
echo "AUTHENTIK_IMAGE=testing" >> .env
|
||||
echo "AUTHENTIK_TAG=latest" >> .env
|
||||
docker compose up --no-start
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2024.10.3"
|
||||
__version__ = "2024.10.5"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.urls import resolve
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.api.users import UserViewSet
|
||||
from authentik.enterprise.api import LicenseViewSet
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import LicenseUsageStatus
|
||||
@ -59,6 +60,9 @@ class EnterpriseMiddleware:
|
||||
# Flow executor is mounted as an API path but explicitly allowed
|
||||
if request.resolver_match._func_path == class_to_path(FlowExecutorView):
|
||||
return True
|
||||
# Always allow making changes to users, even in case the license has ben exceeded
|
||||
if request.resolver_match._func_path == class_to_path(UserViewSet):
|
||||
return True
|
||||
# Only apply these restrictions to the API
|
||||
if "authentik_api" not in request.resolver_match.app_names:
|
||||
return True
|
||||
|
||||
@ -4,7 +4,9 @@ from typing import Any
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
|
||||
@ -26,6 +28,7 @@ HEADER_ACCESS_CHALLENGE_RESPONSE = "X-Verified-Access-Challenge-Response"
|
||||
DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess"
|
||||
|
||||
|
||||
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
||||
class GoogleChromeDeviceTrustConnector(View):
|
||||
"""Google Chrome Device-trust connector based endpoint authenticator"""
|
||||
|
||||
|
||||
@ -215,3 +215,49 @@ class TestReadOnly(FlowTestCase):
|
||||
{"detail": "Request denied due to expired/invalid license.", "code": "denied_license"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=expiry_valid,
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.get_internal_user_count",
|
||||
MagicMock(return_value=1000),
|
||||
)
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.get_external_user_count",
|
||||
MagicMock(return_value=1000),
|
||||
)
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.record_usage",
|
||||
MagicMock(),
|
||||
)
|
||||
def test_manage_users(self):
|
||||
"""Test that managing users is still possible"""
|
||||
License.objects.create(key=generate_id())
|
||||
usage = LicenseUsage.objects.create(
|
||||
internal_user_count=100,
|
||||
external_user_count=100,
|
||||
status=LicenseUsageStatus.VALID,
|
||||
)
|
||||
usage.record_date = now() - timedelta(weeks=THRESHOLD_READ_ONLY_WEEKS + 1)
|
||||
usage.save(update_fields=["record_date"])
|
||||
|
||||
admin = create_test_admin_user()
|
||||
self.client.force_login(admin)
|
||||
|
||||
# Reading is always allowed
|
||||
response = self.client.get(reverse("authentik_api:user-list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Writing should also be allowed
|
||||
response = self.client.patch(reverse("authentik_api:user-detail", kwargs={"pk": admin.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.http import HttpRequest
|
||||
from django.http.request import QueryDict
|
||||
@ -224,6 +225,14 @@ class ChallengeStageView(StageView):
|
||||
full_errors[field].append(field_error)
|
||||
challenge_response.initial_data["response_errors"] = full_errors
|
||||
if not challenge_response.is_valid():
|
||||
if settings.TEST:
|
||||
raise StageInvalidException(
|
||||
(
|
||||
f"Invalid challenge response: \n\t{challenge_response.errors}"
|
||||
f"\n\nValidated data:\n\t {challenge_response.data}"
|
||||
f"\n\nInitial data:\n\t {challenge_response.initial_data}"
|
||||
),
|
||||
)
|
||||
self.logger.error(
|
||||
"f(ch): invalid challenge response",
|
||||
errors=challenge_response.errors,
|
||||
|
||||
@ -5,6 +5,7 @@ import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from glob import glob
|
||||
@ -336,6 +337,58 @@ def redis_url(db: int) -> str:
|
||||
return _redis_url
|
||||
|
||||
|
||||
def django_db_config(config: ConfigLoader | None = None) -> dict:
|
||||
if not config:
|
||||
config = CONFIG
|
||||
db = {
|
||||
"default": {
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": config.get("postgresql.host"),
|
||||
"NAME": config.get("postgresql.name"),
|
||||
"USER": config.get("postgresql.user"),
|
||||
"PASSWORD": config.get("postgresql.password"),
|
||||
"PORT": config.get("postgresql.port"),
|
||||
"OPTIONS": {
|
||||
"sslmode": config.get("postgresql.sslmode"),
|
||||
"sslrootcert": config.get("postgresql.sslrootcert"),
|
||||
"sslcert": config.get("postgresql.sslcert"),
|
||||
"sslkey": config.get("postgresql.sslkey"),
|
||||
},
|
||||
"TEST": {
|
||||
"NAME": config.get("postgresql.test.name"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if config.get_bool("postgresql.use_pgpool", False):
|
||||
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
|
||||
|
||||
if config.get_bool("postgresql.use_pgbouncer", False):
|
||||
# https://docs.djangoproject.com/en/4.0/ref/databases/#transaction-pooling-server-side-cursors
|
||||
db["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
|
||||
# https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections
|
||||
db["default"]["CONN_MAX_AGE"] = None # persistent
|
||||
|
||||
for replica in config.get_keys("postgresql.read_replicas"):
|
||||
_database = deepcopy(db["default"])
|
||||
for setting, current_value in db["default"].items():
|
||||
if isinstance(current_value, dict):
|
||||
continue
|
||||
override = config.get(
|
||||
f"postgresql.read_replicas.{replica}.{setting.lower()}", default=UNSET
|
||||
)
|
||||
if override is not UNSET:
|
||||
_database[setting] = override
|
||||
for setting in db["default"]["OPTIONS"].keys():
|
||||
override = config.get(
|
||||
f"postgresql.read_replicas.{replica}.{setting.lower()}", default=UNSET
|
||||
)
|
||||
if override is not UNSET:
|
||||
_database["OPTIONS"][setting] = override
|
||||
db[f"replica_{replica}"] = _database
|
||||
return db
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(argv) < 2: # noqa: PLR2004
|
||||
print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder))
|
||||
|
||||
@ -9,7 +9,14 @@ from unittest import mock
|
||||
from django.conf import ImproperlyConfigured
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.lib.config import ENV_PREFIX, UNSET, Attr, AttrEncoder, ConfigLoader
|
||||
from authentik.lib.config import (
|
||||
ENV_PREFIX,
|
||||
UNSET,
|
||||
Attr,
|
||||
AttrEncoder,
|
||||
ConfigLoader,
|
||||
django_db_config,
|
||||
)
|
||||
|
||||
|
||||
class TestConfig(TestCase):
|
||||
@ -175,3 +182,201 @@ class TestConfig(TestCase):
|
||||
config = ConfigLoader()
|
||||
config.set("foo.bar", "baz")
|
||||
self.assertEqual(list(config.get_keys("foo")), ["bar"])
|
||||
|
||||
def test_db_default(self):
|
||||
"""Test default DB Config"""
|
||||
config = ConfigLoader()
|
||||
config.set("postgresql.host", "foo")
|
||||
config.set("postgresql.name", "foo")
|
||||
config.set("postgresql.user", "foo")
|
||||
config.set("postgresql.password", "foo")
|
||||
config.set("postgresql.port", "foo")
|
||||
config.set("postgresql.sslmode", "foo")
|
||||
config.set("postgresql.sslrootcert", "foo")
|
||||
config.set("postgresql.sslcert", "foo")
|
||||
config.set("postgresql.sslkey", "foo")
|
||||
config.set("postgresql.test.name", "foo")
|
||||
conf = django_db_config(config)
|
||||
self.assertEqual(
|
||||
conf,
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": "foo",
|
||||
"NAME": "foo",
|
||||
"OPTIONS": {
|
||||
"sslcert": "foo",
|
||||
"sslkey": "foo",
|
||||
"sslmode": "foo",
|
||||
"sslrootcert": "foo",
|
||||
},
|
||||
"PASSWORD": "foo",
|
||||
"PORT": "foo",
|
||||
"TEST": {"NAME": "foo"},
|
||||
"USER": "foo",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_db_read_replicas(self):
|
||||
"""Test read replicas"""
|
||||
config = ConfigLoader()
|
||||
config.set("postgresql.host", "foo")
|
||||
config.set("postgresql.name", "foo")
|
||||
config.set("postgresql.user", "foo")
|
||||
config.set("postgresql.password", "foo")
|
||||
config.set("postgresql.port", "foo")
|
||||
config.set("postgresql.sslmode", "foo")
|
||||
config.set("postgresql.sslrootcert", "foo")
|
||||
config.set("postgresql.sslcert", "foo")
|
||||
config.set("postgresql.sslkey", "foo")
|
||||
config.set("postgresql.test.name", "foo")
|
||||
# Read replica
|
||||
config.set("postgresql.read_replicas.0.host", "bar")
|
||||
conf = django_db_config(config)
|
||||
self.assertEqual(
|
||||
conf,
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": "foo",
|
||||
"NAME": "foo",
|
||||
"OPTIONS": {
|
||||
"sslcert": "foo",
|
||||
"sslkey": "foo",
|
||||
"sslmode": "foo",
|
||||
"sslrootcert": "foo",
|
||||
},
|
||||
"PASSWORD": "foo",
|
||||
"PORT": "foo",
|
||||
"TEST": {"NAME": "foo"},
|
||||
"USER": "foo",
|
||||
},
|
||||
"replica_0": {
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": "bar",
|
||||
"NAME": "foo",
|
||||
"OPTIONS": {
|
||||
"sslcert": "foo",
|
||||
"sslkey": "foo",
|
||||
"sslmode": "foo",
|
||||
"sslrootcert": "foo",
|
||||
},
|
||||
"PASSWORD": "foo",
|
||||
"PORT": "foo",
|
||||
"TEST": {"NAME": "foo"},
|
||||
"USER": "foo",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_db_read_replicas_pgpool(self):
|
||||
"""Test read replicas"""
|
||||
config = ConfigLoader()
|
||||
config.set("postgresql.host", "foo")
|
||||
config.set("postgresql.name", "foo")
|
||||
config.set("postgresql.user", "foo")
|
||||
config.set("postgresql.password", "foo")
|
||||
config.set("postgresql.port", "foo")
|
||||
config.set("postgresql.sslmode", "foo")
|
||||
config.set("postgresql.sslrootcert", "foo")
|
||||
config.set("postgresql.sslcert", "foo")
|
||||
config.set("postgresql.sslkey", "foo")
|
||||
config.set("postgresql.test.name", "foo")
|
||||
config.set("postgresql.use_pgpool", True)
|
||||
# Read replica
|
||||
config.set("postgresql.read_replicas.0.host", "bar")
|
||||
# This isn't supported
|
||||
config.set("postgresql.read_replicas.0.use_pgpool", False)
|
||||
conf = django_db_config(config)
|
||||
self.assertEqual(
|
||||
conf,
|
||||
{
|
||||
"default": {
|
||||
"DISABLE_SERVER_SIDE_CURSORS": True,
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": "foo",
|
||||
"NAME": "foo",
|
||||
"OPTIONS": {
|
||||
"sslcert": "foo",
|
||||
"sslkey": "foo",
|
||||
"sslmode": "foo",
|
||||
"sslrootcert": "foo",
|
||||
},
|
||||
"PASSWORD": "foo",
|
||||
"PORT": "foo",
|
||||
"TEST": {"NAME": "foo"},
|
||||
"USER": "foo",
|
||||
},
|
||||
"replica_0": {
|
||||
"DISABLE_SERVER_SIDE_CURSORS": True,
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": "bar",
|
||||
"NAME": "foo",
|
||||
"OPTIONS": {
|
||||
"sslcert": "foo",
|
||||
"sslkey": "foo",
|
||||
"sslmode": "foo",
|
||||
"sslrootcert": "foo",
|
||||
},
|
||||
"PASSWORD": "foo",
|
||||
"PORT": "foo",
|
||||
"TEST": {"NAME": "foo"},
|
||||
"USER": "foo",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_db_read_replicas_diff_ssl(self):
|
||||
"""Test read replicas (with different SSL Settings)"""
|
||||
"""Test read replicas"""
|
||||
config = ConfigLoader()
|
||||
config.set("postgresql.host", "foo")
|
||||
config.set("postgresql.name", "foo")
|
||||
config.set("postgresql.user", "foo")
|
||||
config.set("postgresql.password", "foo")
|
||||
config.set("postgresql.port", "foo")
|
||||
config.set("postgresql.sslmode", "foo")
|
||||
config.set("postgresql.sslrootcert", "foo")
|
||||
config.set("postgresql.sslcert", "foo")
|
||||
config.set("postgresql.sslkey", "foo")
|
||||
config.set("postgresql.test.name", "foo")
|
||||
# Read replica
|
||||
config.set("postgresql.read_replicas.0.host", "bar")
|
||||
config.set("postgresql.read_replicas.0.sslcert", "bar")
|
||||
conf = django_db_config(config)
|
||||
self.assertEqual(
|
||||
conf,
|
||||
{
|
||||
"default": {
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": "foo",
|
||||
"NAME": "foo",
|
||||
"OPTIONS": {
|
||||
"sslcert": "foo",
|
||||
"sslkey": "foo",
|
||||
"sslmode": "foo",
|
||||
"sslrootcert": "foo",
|
||||
},
|
||||
"PASSWORD": "foo",
|
||||
"PORT": "foo",
|
||||
"TEST": {"NAME": "foo"},
|
||||
"USER": "foo",
|
||||
},
|
||||
"replica_0": {
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": "bar",
|
||||
"NAME": "foo",
|
||||
"OPTIONS": {
|
||||
"sslcert": "bar",
|
||||
"sslkey": "foo",
|
||||
"sslmode": "foo",
|
||||
"sslrootcert": "foo",
|
||||
},
|
||||
"PASSWORD": "foo",
|
||||
"PORT": "foo",
|
||||
"TEST": {"NAME": "foo"},
|
||||
"USER": "foo",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@ -37,7 +37,7 @@ def migrate_session(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0040_provider_invalidation_flow"),
|
||||
("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
|
||||
("authentik_providers_oauth2", "0021_oauth2provider_encryption_key_and_more"),
|
||||
]
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ from django.db import migrations
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0040_provider_invalidation_flow"),
|
||||
("authentik_core", "0039_source_group_matching_mode_alter_group_name_and_more"),
|
||||
("authentik_providers_oauth2", "0022_remove_accesstoken_session_id_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
# Generated by Django 5.0.9 on 2024-11-04 12:56
|
||||
from dataclasses import asdict
|
||||
from django.apps.registry import Apps
|
||||
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
@ -18,8 +19,8 @@ def migrate_redirect_uris(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
mode = RedirectURIMatchingMode.STRICT
|
||||
if old == "*" or old == ".*":
|
||||
mode = RedirectURIMatchingMode.REGEX
|
||||
uris.append(RedirectURI(mode, url=old))
|
||||
provider.redirect_uris = uris
|
||||
uris.append(asdict(RedirectURI(mode, url=old)))
|
||||
provider._redirect_uris = uris
|
||||
provider.save()
|
||||
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.api.providers import RedirectURISerializer
|
||||
from authentik.providers.oauth2.models import ScopeMapping
|
||||
from authentik.providers.oauth2.views.provider import ProviderInfoView
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
@ -39,7 +40,7 @@ class ProxyProviderSerializer(ProviderSerializer):
|
||||
"""ProxyProvider Serializer"""
|
||||
|
||||
client_id = CharField(read_only=True)
|
||||
redirect_uris = CharField(read_only=True)
|
||||
redirect_uris = RedirectURISerializer(many=True, read_only=True, source="_redirect_uris")
|
||||
outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
|
||||
|
||||
def validate_basic_auth_enabled(self, value: bool) -> bool:
|
||||
|
||||
@ -34,10 +34,9 @@ def _get_callback_url(uri: str) -> list[RedirectURI]:
|
||||
return [
|
||||
RedirectURI(
|
||||
RedirectURIMatchingMode.STRICT,
|
||||
urljoin(uri, "outpost.goauthentik.io/callback")
|
||||
+ f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true",
|
||||
urljoin(uri, "outpost.goauthentik.io/callback") + f"?{OUTPOST_CALLBACK_SIGNATURE}=true",
|
||||
),
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, uri + f"\\?{OUTPOST_CALLBACK_SIGNATURE}=true"),
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, uri + f"?{OUTPOST_CALLBACK_SIGNATURE}=true"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -12,7 +12,7 @@ from sentry_sdk import set_tag
|
||||
from xmlsec import enable_debug_trace
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.lib.config import CONFIG, redis_url
|
||||
from authentik.lib.config import CONFIG, django_db_config, redis_url
|
||||
from authentik.lib.logging import get_logger_config, structlog_configure
|
||||
from authentik.lib.sentry import sentry_init
|
||||
from authentik.lib.utils.reflection import get_env
|
||||
@ -38,7 +38,6 @@ LANGUAGE_COOKIE_NAME = "authentik_language"
|
||||
SESSION_COOKIE_NAME = "authentik_session"
|
||||
SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None)
|
||||
APPEND_SLASH = False
|
||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
@ -296,45 +295,7 @@ CHANNEL_LAYERS = {
|
||||
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
|
||||
|
||||
ORIGINAL_BACKEND = "django_prometheus.db.backends.postgresql"
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "authentik.root.db",
|
||||
"HOST": CONFIG.get("postgresql.host"),
|
||||
"NAME": CONFIG.get("postgresql.name"),
|
||||
"USER": CONFIG.get("postgresql.user"),
|
||||
"PASSWORD": CONFIG.get("postgresql.password"),
|
||||
"PORT": CONFIG.get("postgresql.port"),
|
||||
"SSLMODE": CONFIG.get("postgresql.sslmode"),
|
||||
"SSLROOTCERT": CONFIG.get("postgresql.sslrootcert"),
|
||||
"SSLCERT": CONFIG.get("postgresql.sslcert"),
|
||||
"SSLKEY": CONFIG.get("postgresql.sslkey"),
|
||||
"TEST": {
|
||||
"NAME": CONFIG.get("postgresql.test.name"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if CONFIG.get_bool("postgresql.use_pgpool", False):
|
||||
DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
|
||||
|
||||
if CONFIG.get_bool("postgresql.use_pgbouncer", False):
|
||||
# https://docs.djangoproject.com/en/4.0/ref/databases/#transaction-pooling-server-side-cursors
|
||||
DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = True
|
||||
# https://docs.djangoproject.com/en/4.0/ref/databases/#persistent-connections
|
||||
DATABASES["default"]["CONN_MAX_AGE"] = None # persistent
|
||||
|
||||
for replica in CONFIG.get_keys("postgresql.read_replicas"):
|
||||
_database = DATABASES["default"].copy()
|
||||
for setting in DATABASES["default"].keys():
|
||||
default = object()
|
||||
if setting in ("TEST",):
|
||||
continue
|
||||
override = CONFIG.get(
|
||||
f"postgresql.read_replicas.{replica}.{setting.lower()}", default=default
|
||||
)
|
||||
if override is not default:
|
||||
_database[setting] = override
|
||||
DATABASES[f"replica_{replica}"] = _database
|
||||
DATABASES = django_db_config()
|
||||
|
||||
DATABASE_ROUTERS = (
|
||||
"authentik.tenants.db.FailoverRouter",
|
||||
|
||||
@ -332,7 +332,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
||||
serializer = SelectableStageSerializer(
|
||||
data={
|
||||
"pk": stage.pk,
|
||||
"name": getattr(stage, "friendly_name", stage.name),
|
||||
"name": getattr(stage, "friendly_name", stage.name) or stage.name,
|
||||
"verbose_name": str(stage._meta.verbose_name)
|
||||
.replace("Setup Stage", "")
|
||||
.strip(),
|
||||
|
||||
@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls.base import reverse
|
||||
from django.utils.timezone import now
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction
|
||||
@ -13,6 +14,7 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
|
||||
from authentik.stages.authenticator_totp.models import AuthenticatorTOTPStage, TOTPDigits
|
||||
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageSerializer
|
||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||
from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES
|
||||
@ -76,8 +78,8 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
||||
conf_stage = AuthenticatorStaticStage.objects.create(
|
||||
name=generate_id(),
|
||||
)
|
||||
conf_stage2 = AuthenticatorStaticStage.objects.create(
|
||||
name=generate_id(),
|
||||
conf_stage2 = AuthenticatorTOTPStage.objects.create(
|
||||
name=generate_id(), digits=TOTPDigits.SIX
|
||||
)
|
||||
stage = AuthenticatorValidateStage.objects.create(
|
||||
name=generate_id(),
|
||||
@ -153,10 +155,14 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
||||
{
|
||||
"device_class": "static",
|
||||
"device_uid": "1",
|
||||
"challenge": {},
|
||||
"last_used": now(),
|
||||
},
|
||||
{
|
||||
"device_class": "totp",
|
||||
"device_uid": "2",
|
||||
"challenge": {},
|
||||
"last_used": now(),
|
||||
},
|
||||
]
|
||||
session[SESSION_KEY_PLAN] = plan
|
||||
|
||||
@ -26,6 +26,7 @@ from authentik.flows.models import FlowDesignation
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView
|
||||
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_GET
|
||||
from authentik.lib.avatars import DEFAULT_AVATAR
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
from authentik.lib.utils.urls import reverse_with_qs
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
@ -76,7 +77,7 @@ class IdentificationChallenge(Challenge):
|
||||
allow_show_password = BooleanField(default=False)
|
||||
application_pre = CharField(required=False)
|
||||
flow_designation = ChoiceField(FlowDesignation.choices)
|
||||
captcha_stage = CaptchaChallenge(required=False)
|
||||
captcha_stage = CaptchaChallenge(required=False, allow_null=True)
|
||||
|
||||
enroll_url = CharField(required=False)
|
||||
recovery_url = CharField(required=False)
|
||||
@ -224,6 +225,8 @@ class IdentificationStageView(ChallengeStageView):
|
||||
"js_url": current_stage.captcha_stage.js_url,
|
||||
"site_key": current_stage.captcha_stage.public_key,
|
||||
"interactive": current_stage.captcha_stage.interactive,
|
||||
"pending_user": "",
|
||||
"pending_user_avatar": DEFAULT_AVATAR,
|
||||
}
|
||||
if current_stage.captcha_stage
|
||||
else None
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2024.10.3 Blueprint schema",
|
||||
"title": "authentik 2024.10.5 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
|
||||
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.5}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -52,7 +52,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.3}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.10.5}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
||||
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.10.3"
|
||||
const VERSION = "2024.10.5"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2024.10.3",
|
||||
"version": "2024.10.5",
|
||||
"private": true
|
||||
}
|
||||
|
||||
16
poetry.lock
generated
16
poetry.lock
generated
@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
@ -4549,19 +4549,19 @@ test = ["pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "72.1.0"
|
||||
version = "69.1.1"
|
||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"},
|
||||
{file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"},
|
||||
{file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"},
|
||||
{file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.text (>=3.7)", "more-itertools (>=8.8)", "ordered-set (>=3.1.1)", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
|
||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
|
||||
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.11.*)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (<0.4)", "pytest-ruff (>=0.2.1)", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
|
||||
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
|
||||
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
|
||||
testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
@ -5565,4 +5565,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "~3.12"
|
||||
content-hash = "10aa88f2f0e56cddd91adba8c39c52de92763429fb615a27c3dc218952cff808"
|
||||
content-hash = "32f3901cb944de57ed5cb11dde3a2010de845b04adf557b7e3a701581e260613"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "authentik"
|
||||
version = "2024.10.3"
|
||||
version = "2024.10.5"
|
||||
description = ""
|
||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||
|
||||
@ -139,6 +139,7 @@ scim2-filter-parser = "*"
|
||||
sentry-sdk = "*"
|
||||
service_identity = "*"
|
||||
setproctitle = "*"
|
||||
setuptools = "~69.1"
|
||||
structlog = "*"
|
||||
swagger-spec-validator = "*"
|
||||
tenant-schemas-celery = "*"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2024.10.3
|
||||
version: 2024.10.5
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@ -51480,7 +51480,9 @@ components:
|
||||
description: When enabled, this provider will intercept the authorization
|
||||
header and authenticate requests based on its value.
|
||||
redirect_uris:
|
||||
type: string
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RedirectURI'
|
||||
readOnly: true
|
||||
cookie_domain:
|
||||
type: string
|
||||
|
||||
8
web/package-lock.json
generated
8
web/package-lock.json
generated
@ -23,7 +23,7 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@goauthentik/api": "^2024.10.2-1732196831",
|
||||
"@goauthentik/api": "^2024.10.2-1732206118",
|
||||
"@lit-labs/ssr": "^3.2.2",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
@ -1775,9 +1775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@goauthentik/api": {
|
||||
"version": "2024.10.2-1732196831",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.10.2-1732196831.tgz",
|
||||
"integrity": "sha512-+JU9XrgCjAp3wPC9elENVGLaUm0V2jPGV1ewhCYQnkKRIFQmJY9t/SIvbM7nPCCXJwgmpPTMaLT5McrE8ybWOg=="
|
||||
"version": "2024.10.2-1732206118",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.10.2-1732206118.tgz",
|
||||
"integrity": "sha512-Zg90AJvGDquD3u73yIBKXFBDxsCljPxVqylylS6hgPzkLSogKVVkjhmKteWFXDrVxxsxo5XIa4FuTe3wAERyzw=="
|
||||
},
|
||||
"node_modules/@goauthentik/web": {
|
||||
"resolved": "",
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
"@floating-ui/dom": "^1.6.11",
|
||||
"@formatjs/intl-listformat": "^7.5.7",
|
||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
||||
"@goauthentik/api": "^2024.10.2-1732196831",
|
||||
"@goauthentik/api": "^2024.10.2-1732206118",
|
||||
"@lit-labs/ssr": "^3.2.2",
|
||||
"@lit/context": "^1.1.2",
|
||||
"@lit/localize": "^0.12.2",
|
||||
|
||||
@ -11,6 +11,10 @@ import {
|
||||
redirectUriHelp,
|
||||
subjectModeOptions,
|
||||
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
|
||||
import {
|
||||
IRedirectURIInput,
|
||||
akOAuthRedirectURIInput,
|
||||
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderRedirectURI";
|
||||
import {
|
||||
makeSourceSelector,
|
||||
oauth2SourcesProvider,
|
||||
@ -31,7 +35,13 @@ import { customElement, state } from "@lit/reactive-element/decorators.js";
|
||||
import { html, nothing } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import { ClientTypeEnum, FlowsInstancesListDesignationEnum, SourcesApi } from "@goauthentik/api";
|
||||
import {
|
||||
ClientTypeEnum,
|
||||
FlowsInstancesListDesignationEnum,
|
||||
MatchingModeEnum,
|
||||
RedirectURI,
|
||||
SourcesApi,
|
||||
} from "@goauthentik/api";
|
||||
import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api";
|
||||
|
||||
import BaseProviderPanel from "../BaseProviderPanel";
|
||||
@ -120,14 +130,27 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel {
|
||||
>
|
||||
</ak-text-input>
|
||||
|
||||
<ak-textarea-input
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Redirect URIs/Origins")}
|
||||
required
|
||||
name="redirectUris"
|
||||
label=${msg("Redirect URIs/Origins (RegEx)")}
|
||||
.value=${provider?.redirectUris}
|
||||
.errorMessages=${errors?.redirectUriHelp ?? []}
|
||||
.bighelp=${redirectUriHelp}
|
||||
>
|
||||
</ak-textarea-input>
|
||||
<ak-array-input
|
||||
.items=${[]}
|
||||
.newItem=${() => ({
|
||||
matchingMode: MatchingModeEnum.Strict,
|
||||
url: "",
|
||||
})}
|
||||
.row=${(f?: RedirectURI) =>
|
||||
akOAuthRedirectURIInput({
|
||||
".redirectURI": f,
|
||||
"style": "width: 100%",
|
||||
"name": "oauth2-redirect-uri",
|
||||
} as unknown as IRedirectURIInput)}
|
||||
>
|
||||
</ak-array-input>
|
||||
${redirectUriHelp}
|
||||
</ak-form-element-horizontal>
|
||||
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Signing Key")}
|
||||
|
||||
@ -219,6 +219,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: item.pk,
|
||||
impersonationRequest: { reason: "" },
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
|
||||
@ -234,6 +234,7 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
|
||||
akOAuthRedirectURIInput({
|
||||
".redirectURI": f,
|
||||
"style": "width: 100%",
|
||||
"name": "oauth2-redirect-uri",
|
||||
} as unknown as IRedirectURIInput)}
|
||||
>
|
||||
</ak-array-input>
|
||||
|
||||
@ -84,7 +84,7 @@ export class OAuth2ProviderRedirectURI extends AkControlElement<RedirectURI> {
|
||||
required
|
||||
id="url"
|
||||
placeholder=${msg("URL")}
|
||||
name="href"
|
||||
name="url"
|
||||
tabindex="1"
|
||||
/>
|
||||
</div>`;
|
||||
|
||||
@ -234,7 +234,11 @@ export class OAuth2ProviderViewPage extends AKElement {
|
||||
</dt>
|
||||
<dd class="pf-c-description-list__description">
|
||||
<div class="pf-c-description-list__text">
|
||||
${this.provider.redirectUris}
|
||||
<ul>
|
||||
${this.provider.redirectUris.map((ru) => {
|
||||
return html`<li>${ru.matchingMode}: ${ru.url}</li>`;
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@ -392,10 +392,14 @@ export class ProxyProviderViewPage extends AKElement {
|
||||
<dd class="pf-c-description-list__description">
|
||||
<div class="pf-c-description-list__text">
|
||||
<ul class="pf-c-list">
|
||||
${this.provider.redirectUris.split("\n").map((url) => {
|
||||
return html`<li><pre>${url}</pre></li>`;
|
||||
<ul>
|
||||
${this.provider.redirectUris.map((ru) => {
|
||||
return html`<li>
|
||||
${ru.matchingMode}: ${ru.url}
|
||||
</li>`;
|
||||
})}
|
||||
</ul>
|
||||
</ul>
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@ -272,6 +272,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: item.pk,
|
||||
impersonationRequest: { reason: "" },
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
|
||||
@ -215,6 +215,7 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: user.pk,
|
||||
impersonationRequest: { reason: "" },
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
|
||||
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
||||
export const ERROR_CLASS = "pf-m-danger";
|
||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||
export const CURRENT_CLASS = "pf-m-current";
|
||||
export const VERSION = "2024.10.3";
|
||||
export const VERSION = "2024.10.5";
|
||||
export const TITLE_DEFAULT = "authentik";
|
||||
export const ROUTE_SEPARATOR = ";";
|
||||
|
||||
|
||||
41
web/src/elements/utils/listenerController.ts
Normal file
41
web/src/elements/utils/listenerController.ts
Normal file
@ -0,0 +1,41 @@
|
||||
// This is a more modern way to handle disconnecting listeners on demand.
|
||||
|
||||
// example usage:
|
||||
|
||||
/*
|
||||
export class MyElement extends LitElement {
|
||||
|
||||
this.listenerController = new ListenerController();
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("event-1", handler1, { signal: this.listenerController.signal });
|
||||
window.addEventListener("event-2", handler2, { signal: this.listenerController.signal });
|
||||
window.addEventListener("event-3", handler3, { signal: this.listenerController.signal });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
// This will disconnect *all* the event listeners at once, and resets the listenerController,
|
||||
// releasing the memory used for the signal as well. No more trying to map all the
|
||||
// `addEventListener` to `removeEventListener` tediousness!
|
||||
this.listenerController.abort();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
export class ListenerController {
|
||||
listenerController?: AbortController;
|
||||
|
||||
get signal() {
|
||||
if (!this.listenerController) {
|
||||
this.listenerController = new AbortController();
|
||||
}
|
||||
return this.listenerController.signal;
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.listenerController?.abort();
|
||||
this.listenerController = undefined;
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,18 @@
|
||||
///<reference types="@hcaptcha/types"/>
|
||||
import { renderStatic } from "@goauthentik/common/purify";
|
||||
import "@goauthentik/elements/EmptyState";
|
||||
import { akEmptyState } from "@goauthentik/elements/EmptyState";
|
||||
import { bound } from "@goauthentik/elements/decorators/bound";
|
||||
import "@goauthentik/elements/forms/FormElement";
|
||||
import { ListenerController } from "@goauthentik/elements/utils/listenerController.js";
|
||||
import { randomId } from "@goauthentik/elements/utils/randomId";
|
||||
import "@goauthentik/flow/FormStatic";
|
||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||
import { P, match } from "ts-pattern";
|
||||
import type { TurnstileObject } from "turnstile-types";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, PropertyValues, TemplateResult, css, html } from "lit";
|
||||
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@ -23,8 +27,72 @@ import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/
|
||||
interface TurnstileWindow extends Window {
|
||||
turnstile: TurnstileObject;
|
||||
}
|
||||
|
||||
type TokenHandler = (token: string) => void;
|
||||
|
||||
type Dims = { height: number };
|
||||
|
||||
type IframeCaptchaMessage = {
|
||||
source?: string;
|
||||
context?: string;
|
||||
message: "captcha";
|
||||
token: string;
|
||||
};
|
||||
|
||||
type IframeResizeMessage = {
|
||||
source?: string;
|
||||
context?: string;
|
||||
message: "resize";
|
||||
size: Dims;
|
||||
};
|
||||
|
||||
type IframeMessageEvent = MessageEvent<IframeCaptchaMessage | IframeResizeMessage>;
|
||||
|
||||
type CaptchaHandler = {
|
||||
name: string;
|
||||
interactive: () => Promise<unknown>;
|
||||
execute: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
// A container iframe for a hosted Captcha, with an event emitter to monitor when the Captcha forces
|
||||
// a resize. Because the Captcha is itself in an iframe, the reported height is often off by some
|
||||
// margin, so adding 2rem of height to our container adds padding and prevents scroll bars or hidden
|
||||
// rendering.
|
||||
|
||||
const iframeTemplate = (captchaElement: TemplateResult, challengeUrl: string) =>
|
||||
html`<!doctype html>
|
||||
<head>
|
||||
<html>
|
||||
<body style="display:flex;flex-direction:row;justify-content:center;">
|
||||
${captchaElement}
|
||||
<script>
|
||||
new ResizeObserver((entries) => {
|
||||
const height =
|
||||
document.body.offsetHeight +
|
||||
parseFloat(getComputedStyle(document.body).fontSize) * 2;
|
||||
window.parent.postMessage({
|
||||
message: "resize",
|
||||
source: "goauthentik.io",
|
||||
context: "flow-executor",
|
||||
size: { height },
|
||||
});
|
||||
}).observe(document.querySelector(".ak-captcha-container"));
|
||||
</script>
|
||||
<script src=${challengeUrl}></script>
|
||||
<script>
|
||||
function callback(token) {
|
||||
window.parent.postMessage({
|
||||
message: "captcha",
|
||||
source: "goauthentik.io",
|
||||
context: "flow-executor",
|
||||
token: token,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</head>`;
|
||||
|
||||
@customElement("ak-stage-captcha")
|
||||
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
|
||||
static get styles(): CSSResult[] {
|
||||
@ -37,26 +105,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
css`
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 73px; /* tmp */
|
||||
height: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
handlers = [this.handleGReCaptcha, this.handleHCaptcha, this.handleTurnstile];
|
||||
|
||||
@state()
|
||||
error?: string;
|
||||
|
||||
@state()
|
||||
captchaFrame: HTMLIFrameElement;
|
||||
|
||||
@state()
|
||||
captchaDocumentContainer: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
scriptElement?: HTMLScriptElement;
|
||||
|
||||
@property({ type: Boolean })
|
||||
embedded = false;
|
||||
|
||||
@ -65,209 +119,177 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
this.host.submit({ component: "ak-stage-captcha", token });
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.captchaFrame = document.createElement("iframe");
|
||||
this.captchaFrame.src = "about:blank";
|
||||
this.captchaFrame.id = `ak-captcha-${randomId()}`;
|
||||
@state()
|
||||
error?: string;
|
||||
|
||||
this.captchaDocumentContainer = document.createElement("div");
|
||||
this.captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
|
||||
this.messageCallback = this.messageCallback.bind(this);
|
||||
}
|
||||
handlers: CaptchaHandler[] = [
|
||||
{
|
||||
name: "grecaptcha",
|
||||
interactive: this.renderGReCaptchaFrame,
|
||||
execute: this.executeGReCaptcha,
|
||||
},
|
||||
{
|
||||
name: "hcaptcha",
|
||||
interactive: this.renderHCaptchaFrame,
|
||||
execute: this.executeHCaptcha,
|
||||
},
|
||||
{
|
||||
name: "turnstile",
|
||||
interactive: this.renderTurnstileFrame,
|
||||
execute: this.executeTurnstile,
|
||||
},
|
||||
];
|
||||
|
||||
_captchaFrame?: HTMLIFrameElement;
|
||||
_captchaDocumentContainer?: HTMLDivElement;
|
||||
_listenController = new ListenerController();
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this.messageCallback);
|
||||
window.addEventListener("message", this.onIframeMessage, {
|
||||
signal: this._listenController.signal,
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this.messageCallback);
|
||||
if (!this.challenge.interactive) {
|
||||
this._listenController.abort();
|
||||
if (!this.challenge?.interactive) {
|
||||
if (document.body.contains(this.captchaDocumentContainer)) {
|
||||
document.body.removeChild(this.captchaDocumentContainer);
|
||||
}
|
||||
}
|
||||
|
||||
messageCallback(
|
||||
ev: MessageEvent<{
|
||||
source?: string;
|
||||
context?: string;
|
||||
message: string;
|
||||
token: string;
|
||||
}>,
|
||||
) {
|
||||
const msg = ev.data;
|
||||
if (msg.source !== "goauthentik.io" || msg.context !== "flow-executor") {
|
||||
return;
|
||||
}
|
||||
if (msg.message !== "captcha") {
|
||||
return;
|
||||
}
|
||||
this.onTokenChange(msg.token);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
async renderFrame(captchaElement: TemplateResult) {
|
||||
this.captchaFrame.contentWindow?.document.open();
|
||||
this.captchaFrame.contentWindow?.document.write(
|
||||
await renderStatic(
|
||||
html`<!doctype html>
|
||||
<html>
|
||||
<body style="display:flex;flex-direction:row;justify-content:center;">
|
||||
${captchaElement}
|
||||
<script src=${this.challenge.jsUrl}></script>
|
||||
<script>
|
||||
function callback(token) {
|
||||
window.parent.postMessage({
|
||||
message: "captcha",
|
||||
source: "goauthentik.io",
|
||||
context: "flow-executor",
|
||||
token: token,
|
||||
});
|
||||
get captchaDocumentContainer() {
|
||||
if (this._captchaDocumentContainer) {
|
||||
return this._captchaDocumentContainer;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`,
|
||||
),
|
||||
);
|
||||
this.captchaFrame.contentWindow?.document.close();
|
||||
this._captchaDocumentContainer = document.createElement("div");
|
||||
this._captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
|
||||
return this._captchaDocumentContainer;
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("challenge") && this.challenge !== undefined) {
|
||||
this.scriptElement = document.createElement("script");
|
||||
this.scriptElement.src = this.challenge.jsUrl;
|
||||
this.scriptElement.async = true;
|
||||
this.scriptElement.defer = true;
|
||||
this.scriptElement.dataset.akCaptchaScript = "true";
|
||||
this.scriptElement.onload = async () => {
|
||||
console.debug("authentik/stages/captcha: script loaded");
|
||||
let found = false;
|
||||
let lastError = undefined;
|
||||
this.handlers.forEach(async (handler) => {
|
||||
let handlerFound = false;
|
||||
try {
|
||||
console.debug(`authentik/stages/captcha[${handler.name}]: trying handler`);
|
||||
handlerFound = await handler.apply(this);
|
||||
if (handlerFound) {
|
||||
console.debug(
|
||||
`authentik/stages/captcha[${handler.name}]: handler succeeded`,
|
||||
);
|
||||
found = true;
|
||||
}
|
||||
} catch (exc) {
|
||||
console.debug(
|
||||
`authentik/stages/captcha[${handler.name}]: handler failed: ${exc}`,
|
||||
);
|
||||
if (handlerFound) {
|
||||
lastError = exc;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!found && lastError) {
|
||||
this.error = (lastError as Error).toString();
|
||||
}
|
||||
};
|
||||
document.head
|
||||
.querySelectorAll("[data-ak-captcha-script=true]")
|
||||
.forEach((el) => el.remove());
|
||||
document.head.appendChild(this.scriptElement);
|
||||
if (!this.challenge.interactive) {
|
||||
document.body.appendChild(this.captchaDocumentContainer);
|
||||
}
|
||||
get captchaFrame() {
|
||||
if (this._captchaFrame) {
|
||||
return this._captchaFrame;
|
||||
}
|
||||
this._captchaFrame = document.createElement("iframe");
|
||||
this._captchaFrame.src = "about:blank";
|
||||
this._captchaFrame.id = `ak-captcha-${randomId()}`;
|
||||
return this._captchaFrame;
|
||||
}
|
||||
|
||||
async handleGReCaptcha(): Promise<boolean> {
|
||||
if (!Object.hasOwn(window, "grecaptcha")) {
|
||||
return false;
|
||||
onFrameResize({ height }: Dims) {
|
||||
this.captchaFrame.style.height = `${height}px`;
|
||||
}
|
||||
if (this.challenge.interactive) {
|
||||
|
||||
// ADR: Did not to put anything into `otherwise` or `exhaustive` here because iframe messages
|
||||
// that were not of interest to us also weren't necessarily corrupt or suspicious. For example,
|
||||
// during testing Storybook throws a lot of cross-iframe messages that we don't care about.
|
||||
|
||||
@bound
|
||||
onIframeMessage({ data }: IframeMessageEvent) {
|
||||
match(data)
|
||||
.with(
|
||||
{ source: "goauthentik.io", context: "flow-executor", message: "captcha" },
|
||||
({ token }) => this.onTokenChange(token),
|
||||
)
|
||||
.with(
|
||||
{ source: "goauthentik.io", context: "flow-executor", message: "resize" },
|
||||
({ size }) => this.onFrameResize(size),
|
||||
)
|
||||
.with(
|
||||
{ source: "goauthentik.io", context: "flow-executor", message: P.any },
|
||||
({ message }) => {
|
||||
console.debug(`authentik/stages/captcha: Unknown message: ${message}`);
|
||||
},
|
||||
)
|
||||
.otherwise(() => {});
|
||||
}
|
||||
|
||||
async renderGReCaptchaFrame() {
|
||||
this.renderFrame(
|
||||
html`<div
|
||||
class="g-recaptcha"
|
||||
class="g-recaptcha ak-captcha-container"
|
||||
data-sitekey="${this.challenge.siteKey}"
|
||||
data-callback="callback"
|
||||
></div>`,
|
||||
);
|
||||
} else {
|
||||
grecaptcha.ready(() => {
|
||||
const captchaId = grecaptcha.render(this.captchaDocumentContainer, {
|
||||
}
|
||||
|
||||
async executeGReCaptcha() {
|
||||
return grecaptcha.ready(() => {
|
||||
grecaptcha.execute(
|
||||
grecaptcha.render(this.captchaDocumentContainer, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
size: "invisible",
|
||||
}),
|
||||
);
|
||||
});
|
||||
grecaptcha.execute(captchaId);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleHCaptcha(): Promise<boolean> {
|
||||
if (!Object.hasOwn(window, "hcaptcha")) {
|
||||
return false;
|
||||
}
|
||||
if (this.challenge.interactive) {
|
||||
async renderHCaptchaFrame() {
|
||||
this.renderFrame(
|
||||
html`<div
|
||||
class="h-captcha"
|
||||
class="h-captcha ak-captcha-container"
|
||||
data-sitekey="${this.challenge.siteKey}"
|
||||
data-theme="${this.activeTheme ? this.activeTheme : "light"}"
|
||||
data-callback="callback"
|
||||
></div> `,
|
||||
);
|
||||
} else {
|
||||
const captchaId = hcaptcha.render(this.captchaDocumentContainer, {
|
||||
}
|
||||
|
||||
async executeHCaptcha() {
|
||||
return hcaptcha.execute(
|
||||
hcaptcha.render(this.captchaDocumentContainer, {
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
size: "invisible",
|
||||
});
|
||||
hcaptcha.execute(captchaId);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async handleTurnstile(): Promise<boolean> {
|
||||
if (!Object.hasOwn(window, "turnstile")) {
|
||||
return false;
|
||||
}
|
||||
if (this.challenge.interactive) {
|
||||
async renderTurnstileFrame() {
|
||||
this.renderFrame(
|
||||
html`<div
|
||||
class="cf-turnstile"
|
||||
class="cf-turnstile ak-captcha-container"
|
||||
data-sitekey="${this.challenge.siteKey}"
|
||||
data-callback="callback"
|
||||
></div>`,
|
||||
);
|
||||
} else {
|
||||
(window as unknown as TurnstileWindow).turnstile.render(this.captchaDocumentContainer, {
|
||||
}
|
||||
|
||||
async executeTurnstile() {
|
||||
return (window as unknown as TurnstileWindow).turnstile.render(
|
||||
this.captchaDocumentContainer,
|
||||
{
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
return true;
|
||||
|
||||
async renderFrame(captchaElement: TemplateResult) {
|
||||
this.captchaFrame.contentWindow?.document.open();
|
||||
this.captchaFrame.contentWindow?.document.write(
|
||||
await renderStatic(iframeTemplate(captchaElement, this.challenge.jsUrl)),
|
||||
);
|
||||
this.captchaFrame.contentWindow?.document.close();
|
||||
}
|
||||
|
||||
renderBody() {
|
||||
if (this.error) {
|
||||
return html`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`;
|
||||
}
|
||||
if (this.challenge.interactive) {
|
||||
return html`${this.captchaFrame}`;
|
||||
}
|
||||
return html`<ak-empty-state loading header=${msg("Verifying...")}></ak-empty-state>`;
|
||||
// [hasError, isInteractive]
|
||||
// prettier-ignore
|
||||
return match([Boolean(this.error), Boolean(this.challenge?.interactive)])
|
||||
.with([true, P.any], () => akEmptyState({ icon: "fa-times", header: this.error }))
|
||||
.with([false, true], () => html`${this.captchaFrame}`)
|
||||
.with([false, false], () => akEmptyState({ loading: true, header: msg("Verifying...") }))
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.embedded) {
|
||||
if (!this.challenge.interactive) {
|
||||
return html``;
|
||||
}
|
||||
return this.renderBody();
|
||||
}
|
||||
if (!this.challenge) {
|
||||
return html`<ak-empty-state loading> </ak-empty-state>`;
|
||||
}
|
||||
renderMain() {
|
||||
return html`<header class="pf-c-login__main-header">
|
||||
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
||||
</header>
|
||||
@ -291,6 +313,63 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
<ul class="pf-c-login__main-footer-links"></ul>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
// [isEmbedded, hasChallenge, isInteractive]
|
||||
// prettier-ignore
|
||||
return match([this.embedded, Boolean(this.challenge), Boolean(this.challenge?.interactive)])
|
||||
.with([true, false, P.any], () => nothing)
|
||||
.with([true, true, false], () => nothing)
|
||||
.with([true, true, true], () => this.renderBody())
|
||||
.with([false, false, P.any], () => akEmptyState({ loading: true }))
|
||||
.with([false, true, P.any], () => this.renderMain())
|
||||
.exhaustive();
|
||||
}
|
||||
|
||||
updated(changedProperties: PropertyValues<this>) {
|
||||
if (!(changedProperties.has("challenge") && this.challenge !== undefined)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachCaptcha = async () => {
|
||||
console.debug("authentik/stages/captcha: script loaded");
|
||||
const handlers = this.handlers.filter(({ name }) => Object.hasOwn(window, name));
|
||||
let lastError = undefined;
|
||||
let found = false;
|
||||
for (const { name, interactive, execute } of handlers) {
|
||||
console.debug(`authentik/stages/captcha: trying handler ${name}`);
|
||||
try {
|
||||
const runner = this.challenge.interactive ? interactive : execute;
|
||||
await runner.apply(this);
|
||||
console.debug(`authentik/stages/captcha[${name}]: handler succeeded`);
|
||||
found = true;
|
||||
break;
|
||||
} catch (exc) {
|
||||
console.debug(`authentik/stages/captcha[${name}]: handler failed`);
|
||||
console.debug(exc);
|
||||
lastError = exc;
|
||||
}
|
||||
}
|
||||
this.error = found ? undefined : (lastError ?? "Unspecified error").toString();
|
||||
};
|
||||
|
||||
const scriptElement = document.createElement("script");
|
||||
scriptElement.src = this.challenge.jsUrl;
|
||||
scriptElement.async = true;
|
||||
scriptElement.defer = true;
|
||||
scriptElement.dataset.akCaptchaScript = "true";
|
||||
scriptElement.onload = attachCaptcha;
|
||||
|
||||
document.head
|
||||
.querySelectorAll("[data-ak-captcha-script=true]")
|
||||
.forEach((el) => el.remove());
|
||||
|
||||
document.head.appendChild(scriptElement);
|
||||
|
||||
if (!this.challenge.interactive) {
|
||||
document.body.appendChild(this.captchaDocumentContainer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@ -101,7 +101,15 @@ The following events occur when a license expires or the internal/external user
|
||||
|
||||
- After another 2 weeks, users get a warning banner
|
||||
|
||||
- After another 2 weeks, the authentik Enterprise instance becomes “read-only”
|
||||
- After another 2 weeks, the authentik Enterprise instance becomes "read-only"
|
||||
|
||||
When an authentik instance is in read-only mode, the following actions are still possible:
|
||||
|
||||
- Users can authenticate and authorize applications
|
||||
- Licenses can be modified
|
||||
- Users can be modified/deleted <span class="badge badge--version">authentik 2024.10.5+</span>
|
||||
|
||||
After the violation is corrected (either the user count returns to be within the limits of the license or the license is renewed), authentik will return to the standard read-write mode and the notification will disappear.
|
||||
|
||||
### About users and licenses
|
||||
|
||||
|
||||
Reference in New Issue
Block a user