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]
|
[bumpversion]
|
||||||
current_version = 2024.10.3
|
current_version = 2024.10.5
|
||||||
tag = True
|
tag = True
|
||||||
commit = 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*))?
|
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: |
|
run: |
|
||||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||||
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
||||||
poetry install
|
poetry install --sync
|
||||||
cd web && npm ci
|
cd web && npm ci
|
||||||
- name: Generate config
|
- name: Generate config
|
||||||
shell: poetry run python {0}
|
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
|
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
|
||||||
docker buildx install
|
docker buildx install
|
||||||
mkdir -p ./gen-ts-api
|
mkdir -p ./gen-ts-api
|
||||||
docker build -t testing:latest .
|
docker build --no-cache -t testing:latest .
|
||||||
echo "AUTHENTIK_IMAGE=testing" >> .env
|
echo "AUTHENTIK_IMAGE=testing" >> .env
|
||||||
echo "AUTHENTIK_TAG=latest" >> .env
|
echo "AUTHENTIK_TAG=latest" >> .env
|
||||||
docker compose up --no-start
|
docker compose up --no-start
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from os import environ
|
from os import environ
|
||||||
|
|
||||||
__version__ = "2024.10.3"
|
__version__ = "2024.10.5"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from django.http import HttpRequest, HttpResponse, JsonResponse
|
|||||||
from django.urls import resolve
|
from django.urls import resolve
|
||||||
from structlog.stdlib import BoundLogger, get_logger
|
from structlog.stdlib import BoundLogger, get_logger
|
||||||
|
|
||||||
|
from authentik.core.api.users import UserViewSet
|
||||||
from authentik.enterprise.api import LicenseViewSet
|
from authentik.enterprise.api import LicenseViewSet
|
||||||
from authentik.enterprise.license import LicenseKey
|
from authentik.enterprise.license import LicenseKey
|
||||||
from authentik.enterprise.models import LicenseUsageStatus
|
from authentik.enterprise.models import LicenseUsageStatus
|
||||||
@ -59,6 +60,9 @@ class EnterpriseMiddleware:
|
|||||||
# Flow executor is mounted as an API path but explicitly allowed
|
# Flow executor is mounted as an API path but explicitly allowed
|
||||||
if request.resolver_match._func_path == class_to_path(FlowExecutorView):
|
if request.resolver_match._func_path == class_to_path(FlowExecutorView):
|
||||||
return True
|
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
|
# Only apply these restrictions to the API
|
||||||
if "authentik_api" not in request.resolver_match.app_names:
|
if "authentik_api" not in request.resolver_match.app_names:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@ -4,7 +4,9 @@ from typing import Any
|
|||||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.decorators import method_decorator
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||||
from googleapiclient.discovery import build
|
from googleapiclient.discovery import build
|
||||||
|
|
||||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
|
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"
|
DEVICE_TRUST_VERIFIED_ACCESS = "VerifiedAccess"
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
||||||
class GoogleChromeDeviceTrustConnector(View):
|
class GoogleChromeDeviceTrustConnector(View):
|
||||||
"""Google Chrome Device-trust connector based endpoint authenticator"""
|
"""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"},
|
{"detail": "Request denied due to expired/invalid license.", "code": "denied_license"},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 400)
|
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 typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
from django.http.request import QueryDict
|
from django.http.request import QueryDict
|
||||||
@ -224,6 +225,14 @@ class ChallengeStageView(StageView):
|
|||||||
full_errors[field].append(field_error)
|
full_errors[field].append(field_error)
|
||||||
challenge_response.initial_data["response_errors"] = full_errors
|
challenge_response.initial_data["response_errors"] = full_errors
|
||||||
if not challenge_response.is_valid():
|
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(
|
self.logger.error(
|
||||||
"f(ch): invalid challenge response",
|
"f(ch): invalid challenge response",
|
||||||
errors=challenge_response.errors,
|
errors=challenge_response.errors,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from copy import deepcopy
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from glob import glob
|
from glob import glob
|
||||||
@ -336,6 +337,58 @@ def redis_url(db: int) -> str:
|
|||||||
return _redis_url
|
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 __name__ == "__main__":
|
||||||
if len(argv) < 2: # noqa: PLR2004
|
if len(argv) < 2: # noqa: PLR2004
|
||||||
print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder))
|
print(dumps(CONFIG.raw, indent=4, cls=AttrEncoder))
|
||||||
|
|||||||
@ -9,7 +9,14 @@ from unittest import mock
|
|||||||
from django.conf import ImproperlyConfigured
|
from django.conf import ImproperlyConfigured
|
||||||
from django.test import TestCase
|
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):
|
class TestConfig(TestCase):
|
||||||
@ -175,3 +182,201 @@ class TestConfig(TestCase):
|
|||||||
config = ConfigLoader()
|
config = ConfigLoader()
|
||||||
config.set("foo.bar", "baz")
|
config.set("foo.bar", "baz")
|
||||||
self.assertEqual(list(config.get_keys("foo")), ["bar"])
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
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"),
|
("authentik_providers_oauth2", "0021_oauth2provider_encryption_key_and_more"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from django.db import migrations
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
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"),
|
("authentik_providers_oauth2", "0022_remove_accesstoken_session_id_and_more"),
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
# Generated by Django 5.0.9 on 2024-11-04 12:56
|
# Generated by Django 5.0.9 on 2024-11-04 12:56
|
||||||
|
from dataclasses import asdict
|
||||||
from django.apps.registry import Apps
|
from django.apps.registry import Apps
|
||||||
|
|
||||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
@ -18,8 +19,8 @@ def migrate_redirect_uris(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|||||||
mode = RedirectURIMatchingMode.STRICT
|
mode = RedirectURIMatchingMode.STRICT
|
||||||
if old == "*" or old == ".*":
|
if old == "*" or old == ".*":
|
||||||
mode = RedirectURIMatchingMode.REGEX
|
mode = RedirectURIMatchingMode.REGEX
|
||||||
uris.append(RedirectURI(mode, url=old))
|
uris.append(asdict(RedirectURI(mode, url=old)))
|
||||||
provider.redirect_uris = uris
|
provider._redirect_uris = uris
|
||||||
provider.save()
|
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.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
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.models import ScopeMapping
|
||||||
from authentik.providers.oauth2.views.provider import ProviderInfoView
|
from authentik.providers.oauth2.views.provider import ProviderInfoView
|
||||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||||
@ -39,7 +40,7 @@ class ProxyProviderSerializer(ProviderSerializer):
|
|||||||
"""ProxyProvider Serializer"""
|
"""ProxyProvider Serializer"""
|
||||||
|
|
||||||
client_id = CharField(read_only=True)
|
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")
|
outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
|
||||||
|
|
||||||
def validate_basic_auth_enabled(self, value: bool) -> bool:
|
def validate_basic_auth_enabled(self, value: bool) -> bool:
|
||||||
|
|||||||
@ -34,10 +34,9 @@ def _get_callback_url(uri: str) -> list[RedirectURI]:
|
|||||||
return [
|
return [
|
||||||
RedirectURI(
|
RedirectURI(
|
||||||
RedirectURIMatchingMode.STRICT,
|
RedirectURIMatchingMode.STRICT,
|
||||||
urljoin(uri, "outpost.goauthentik.io/callback")
|
urljoin(uri, "outpost.goauthentik.io/callback") + f"?{OUTPOST_CALLBACK_SIGNATURE}=true",
|
||||||
+ 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 xmlsec import enable_debug_trace
|
||||||
|
|
||||||
from authentik import __version__
|
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.logging import get_logger_config, structlog_configure
|
||||||
from authentik.lib.sentry import sentry_init
|
from authentik.lib.sentry import sentry_init
|
||||||
from authentik.lib.utils.reflection import get_env
|
from authentik.lib.utils.reflection import get_env
|
||||||
@ -38,7 +38,6 @@ LANGUAGE_COOKIE_NAME = "authentik_language"
|
|||||||
SESSION_COOKIE_NAME = "authentik_session"
|
SESSION_COOKIE_NAME = "authentik_session"
|
||||||
SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None)
|
SESSION_COOKIE_DOMAIN = CONFIG.get("cookie_domain", None)
|
||||||
APPEND_SLASH = False
|
APPEND_SLASH = False
|
||||||
X_FRAME_OPTIONS = "SAMEORIGIN"
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
AUTHENTICATION_BACKENDS = [
|
||||||
"django.contrib.auth.backends.ModelBackend",
|
"django.contrib.auth.backends.ModelBackend",
|
||||||
@ -296,45 +295,7 @@ CHANNEL_LAYERS = {
|
|||||||
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
|
# https://docs.djangoproject.com/en/2.1/ref/settings/#databases
|
||||||
|
|
||||||
ORIGINAL_BACKEND = "django_prometheus.db.backends.postgresql"
|
ORIGINAL_BACKEND = "django_prometheus.db.backends.postgresql"
|
||||||
DATABASES = {
|
DATABASES = django_db_config()
|
||||||
"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
|
|
||||||
|
|
||||||
DATABASE_ROUTERS = (
|
DATABASE_ROUTERS = (
|
||||||
"authentik.tenants.db.FailoverRouter",
|
"authentik.tenants.db.FailoverRouter",
|
||||||
|
|||||||
@ -332,7 +332,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
|
|||||||
serializer = SelectableStageSerializer(
|
serializer = SelectableStageSerializer(
|
||||||
data={
|
data={
|
||||||
"pk": stage.pk,
|
"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)
|
"verbose_name": str(stage._meta.verbose_name)
|
||||||
.replace("Setup Stage", "")
|
.replace("Setup Stage", "")
|
||||||
.strip(),
|
.strip(),
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
from django.test.client import RequestFactory
|
from django.test.client import RequestFactory
|
||||||
from django.urls.base import reverse
|
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.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
from authentik.flows.models import FlowDesignation, FlowStageBinding, NotConfiguredAction
|
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.lib.generators import generate_id, generate_key
|
||||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||||
from authentik.stages.authenticator_static.models import AuthenticatorStaticStage
|
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.api import AuthenticatorValidateStageSerializer
|
||||||
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
|
||||||
from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES
|
from authentik.stages.authenticator_validate.stage import PLAN_CONTEXT_DEVICE_CHALLENGES
|
||||||
@ -76,8 +78,8 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
|||||||
conf_stage = AuthenticatorStaticStage.objects.create(
|
conf_stage = AuthenticatorStaticStage.objects.create(
|
||||||
name=generate_id(),
|
name=generate_id(),
|
||||||
)
|
)
|
||||||
conf_stage2 = AuthenticatorStaticStage.objects.create(
|
conf_stage2 = AuthenticatorTOTPStage.objects.create(
|
||||||
name=generate_id(),
|
name=generate_id(), digits=TOTPDigits.SIX
|
||||||
)
|
)
|
||||||
stage = AuthenticatorValidateStage.objects.create(
|
stage = AuthenticatorValidateStage.objects.create(
|
||||||
name=generate_id(),
|
name=generate_id(),
|
||||||
@ -153,10 +155,14 @@ class AuthenticatorValidateStageTests(FlowTestCase):
|
|||||||
{
|
{
|
||||||
"device_class": "static",
|
"device_class": "static",
|
||||||
"device_uid": "1",
|
"device_uid": "1",
|
||||||
|
"challenge": {},
|
||||||
|
"last_used": now(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"device_class": "totp",
|
"device_class": "totp",
|
||||||
"device_uid": "2",
|
"device_uid": "2",
|
||||||
|
"challenge": {},
|
||||||
|
"last_used": now(),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
session[SESSION_KEY_PLAN] = plan
|
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.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, ChallengeStageView
|
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.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.reflection import all_subclasses
|
||||||
from authentik.lib.utils.urls import reverse_with_qs
|
from authentik.lib.utils.urls import reverse_with_qs
|
||||||
from authentik.root.middleware import ClientIPMiddleware
|
from authentik.root.middleware import ClientIPMiddleware
|
||||||
@ -76,7 +77,7 @@ class IdentificationChallenge(Challenge):
|
|||||||
allow_show_password = BooleanField(default=False)
|
allow_show_password = BooleanField(default=False)
|
||||||
application_pre = CharField(required=False)
|
application_pre = CharField(required=False)
|
||||||
flow_designation = ChoiceField(FlowDesignation.choices)
|
flow_designation = ChoiceField(FlowDesignation.choices)
|
||||||
captcha_stage = CaptchaChallenge(required=False)
|
captcha_stage = CaptchaChallenge(required=False, allow_null=True)
|
||||||
|
|
||||||
enroll_url = CharField(required=False)
|
enroll_url = CharField(required=False)
|
||||||
recovery_url = CharField(required=False)
|
recovery_url = CharField(required=False)
|
||||||
@ -224,6 +225,8 @@ class IdentificationStageView(ChallengeStageView):
|
|||||||
"js_url": current_stage.captcha_stage.js_url,
|
"js_url": current_stage.captcha_stage.js_url,
|
||||||
"site_key": current_stage.captcha_stage.public_key,
|
"site_key": current_stage.captcha_stage.public_key,
|
||||||
"interactive": current_stage.captcha_stage.interactive,
|
"interactive": current_stage.captcha_stage.interactive,
|
||||||
|
"pending_user": "",
|
||||||
|
"pending_user_avatar": DEFAULT_AVATAR,
|
||||||
}
|
}
|
||||||
if current_stage.captcha_stage
|
if current_stage.captcha_stage
|
||||||
else None
|
else None
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
"$schema": "http://json-schema.org/draft-07/schema",
|
"$schema": "http://json-schema.org/draft-07/schema",
|
||||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"title": "authentik 2024.10.3 Blueprint schema",
|
"title": "authentik 2024.10.5 Blueprint schema",
|
||||||
"required": [
|
"required": [
|
||||||
"version",
|
"version",
|
||||||
"entries"
|
"entries"
|
||||||
|
|||||||
@ -31,7 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis:/data
|
- redis:/data
|
||||||
server:
|
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
|
restart: unless-stopped
|
||||||
command: server
|
command: server
|
||||||
environment:
|
environment:
|
||||||
@ -52,7 +52,7 @@ services:
|
|||||||
- postgresql
|
- postgresql
|
||||||
- redis
|
- redis
|
||||||
worker:
|
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
|
restart: unless-stopped
|
||||||
command: worker
|
command: worker
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@ -29,4 +29,4 @@ func UserAgent() string {
|
|||||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERSION = "2024.10.3"
|
const VERSION = "2024.10.5"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@goauthentik/authentik",
|
"name": "@goauthentik/authentik",
|
||||||
"version": "2024.10.3",
|
"version": "2024.10.5",
|
||||||
"private": true
|
"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]]
|
[[package]]
|
||||||
name = "aiohappyeyeballs"
|
name = "aiohappyeyeballs"
|
||||||
@ -4549,19 +4549,19 @@ test = ["pytest"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "setuptools"
|
name = "setuptools"
|
||||||
version = "72.1.0"
|
version = "69.1.1"
|
||||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"},
|
{file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"},
|
||||||
{file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"},
|
{file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[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)"]
|
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"]
|
||||||
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"]
|
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"]
|
||||||
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"]
|
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]]
|
[[package]]
|
||||||
name = "six"
|
name = "six"
|
||||||
@ -5565,4 +5565,4 @@ files = [
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "~3.12"
|
python-versions = "~3.12"
|
||||||
content-hash = "10aa88f2f0e56cddd91adba8c39c52de92763429fb615a27c3dc218952cff808"
|
content-hash = "32f3901cb944de57ed5cb11dde3a2010de845b04adf557b7e3a701581e260613"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "authentik"
|
name = "authentik"
|
||||||
version = "2024.10.3"
|
version = "2024.10.5"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["authentik Team <hello@goauthentik.io>"]
|
authors = ["authentik Team <hello@goauthentik.io>"]
|
||||||
|
|
||||||
@ -139,6 +139,7 @@ scim2-filter-parser = "*"
|
|||||||
sentry-sdk = "*"
|
sentry-sdk = "*"
|
||||||
service_identity = "*"
|
service_identity = "*"
|
||||||
setproctitle = "*"
|
setproctitle = "*"
|
||||||
|
setuptools = "~69.1"
|
||||||
structlog = "*"
|
structlog = "*"
|
||||||
swagger-spec-validator = "*"
|
swagger-spec-validator = "*"
|
||||||
tenant-schemas-celery = "*"
|
tenant-schemas-celery = "*"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: authentik
|
title: authentik
|
||||||
version: 2024.10.3
|
version: 2024.10.5
|
||||||
description: Making authentication simple.
|
description: Making authentication simple.
|
||||||
contact:
|
contact:
|
||||||
email: hello@goauthentik.io
|
email: hello@goauthentik.io
|
||||||
@ -51480,7 +51480,9 @@ components:
|
|||||||
description: When enabled, this provider will intercept the authorization
|
description: When enabled, this provider will intercept the authorization
|
||||||
header and authenticate requests based on its value.
|
header and authenticate requests based on its value.
|
||||||
redirect_uris:
|
redirect_uris:
|
||||||
type: string
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/RedirectURI'
|
||||||
readOnly: true
|
readOnly: true
|
||||||
cookie_domain:
|
cookie_domain:
|
||||||
type: string
|
type: string
|
||||||
|
|||||||
8
web/package-lock.json
generated
8
web/package-lock.json
generated
@ -23,7 +23,7 @@
|
|||||||
"@floating-ui/dom": "^1.6.11",
|
"@floating-ui/dom": "^1.6.11",
|
||||||
"@formatjs/intl-listformat": "^7.5.7",
|
"@formatjs/intl-listformat": "^7.5.7",
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@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-labs/ssr": "^3.2.2",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
"@lit/localize": "^0.12.2",
|
"@lit/localize": "^0.12.2",
|
||||||
@ -1775,9 +1775,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@goauthentik/api": {
|
"node_modules/@goauthentik/api": {
|
||||||
"version": "2024.10.2-1732196831",
|
"version": "2024.10.2-1732206118",
|
||||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.10.2-1732196831.tgz",
|
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2024.10.2-1732206118.tgz",
|
||||||
"integrity": "sha512-+JU9XrgCjAp3wPC9elENVGLaUm0V2jPGV1ewhCYQnkKRIFQmJY9t/SIvbM7nPCCXJwgmpPTMaLT5McrE8ybWOg=="
|
"integrity": "sha512-Zg90AJvGDquD3u73yIBKXFBDxsCljPxVqylylS6hgPzkLSogKVVkjhmKteWFXDrVxxsxo5XIa4FuTe3wAERyzw=="
|
||||||
},
|
},
|
||||||
"node_modules/@goauthentik/web": {
|
"node_modules/@goauthentik/web": {
|
||||||
"resolved": "",
|
"resolved": "",
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
"@floating-ui/dom": "^1.6.11",
|
"@floating-ui/dom": "^1.6.11",
|
||||||
"@formatjs/intl-listformat": "^7.5.7",
|
"@formatjs/intl-listformat": "^7.5.7",
|
||||||
"@fortawesome/fontawesome-free": "^6.6.0",
|
"@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-labs/ssr": "^3.2.2",
|
||||||
"@lit/context": "^1.1.2",
|
"@lit/context": "^1.1.2",
|
||||||
"@lit/localize": "^0.12.2",
|
"@lit/localize": "^0.12.2",
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import {
|
|||||||
redirectUriHelp,
|
redirectUriHelp,
|
||||||
subjectModeOptions,
|
subjectModeOptions,
|
||||||
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
|
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
|
||||||
|
import {
|
||||||
|
IRedirectURIInput,
|
||||||
|
akOAuthRedirectURIInput,
|
||||||
|
} from "@goauthentik/admin/providers/oauth2/OAuth2ProviderRedirectURI";
|
||||||
import {
|
import {
|
||||||
makeSourceSelector,
|
makeSourceSelector,
|
||||||
oauth2SourcesProvider,
|
oauth2SourcesProvider,
|
||||||
@ -31,7 +35,13 @@ import { customElement, state } from "@lit/reactive-element/decorators.js";
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import { ifDefined } from "lit/directives/if-defined.js";
|
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 { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api";
|
||||||
|
|
||||||
import BaseProviderPanel from "../BaseProviderPanel";
|
import BaseProviderPanel from "../BaseProviderPanel";
|
||||||
@ -120,14 +130,27 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel {
|
|||||||
>
|
>
|
||||||
</ak-text-input>
|
</ak-text-input>
|
||||||
|
|
||||||
<ak-textarea-input
|
<ak-form-element-horizontal
|
||||||
|
label=${msg("Redirect URIs/Origins")}
|
||||||
|
required
|
||||||
name="redirectUris"
|
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
|
<ak-form-element-horizontal
|
||||||
label=${msg("Signing Key")}
|
label=${msg("Signing Key")}
|
||||||
|
|||||||
@ -219,6 +219,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
|||||||
return new CoreApi(DEFAULT_CONFIG)
|
return new CoreApi(DEFAULT_CONFIG)
|
||||||
.coreUsersImpersonateCreate({
|
.coreUsersImpersonateCreate({
|
||||||
id: item.pk,
|
id: item.pk,
|
||||||
|
impersonationRequest: { reason: "" },
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
|
|||||||
@ -234,6 +234,7 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
|
|||||||
akOAuthRedirectURIInput({
|
akOAuthRedirectURIInput({
|
||||||
".redirectURI": f,
|
".redirectURI": f,
|
||||||
"style": "width: 100%",
|
"style": "width: 100%",
|
||||||
|
"name": "oauth2-redirect-uri",
|
||||||
} as unknown as IRedirectURIInput)}
|
} as unknown as IRedirectURIInput)}
|
||||||
>
|
>
|
||||||
</ak-array-input>
|
</ak-array-input>
|
||||||
|
|||||||
@ -84,7 +84,7 @@ export class OAuth2ProviderRedirectURI extends AkControlElement<RedirectURI> {
|
|||||||
required
|
required
|
||||||
id="url"
|
id="url"
|
||||||
placeholder=${msg("URL")}
|
placeholder=${msg("URL")}
|
||||||
name="href"
|
name="url"
|
||||||
tabindex="1"
|
tabindex="1"
|
||||||
/>
|
/>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|||||||
@ -234,7 +234,11 @@ export class OAuth2ProviderViewPage extends AKElement {
|
|||||||
</dt>
|
</dt>
|
||||||
<dd class="pf-c-description-list__description">
|
<dd class="pf-c-description-list__description">
|
||||||
<div class="pf-c-description-list__text">
|
<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>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -392,10 +392,14 @@ export class ProxyProviderViewPage extends AKElement {
|
|||||||
<dd class="pf-c-description-list__description">
|
<dd class="pf-c-description-list__description">
|
||||||
<div class="pf-c-description-list__text">
|
<div class="pf-c-description-list__text">
|
||||||
<ul class="pf-c-list">
|
<ul class="pf-c-list">
|
||||||
${this.provider.redirectUris.split("\n").map((url) => {
|
<ul>
|
||||||
return html`<li><pre>${url}</pre></li>`;
|
${this.provider.redirectUris.map((ru) => {
|
||||||
|
return html`<li>
|
||||||
|
${ru.matchingMode}: ${ru.url}
|
||||||
|
</li>`;
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -272,6 +272,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
|||||||
return new CoreApi(DEFAULT_CONFIG)
|
return new CoreApi(DEFAULT_CONFIG)
|
||||||
.coreUsersImpersonateCreate({
|
.coreUsersImpersonateCreate({
|
||||||
id: item.pk,
|
id: item.pk,
|
||||||
|
impersonationRequest: { reason: "" },
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
|
|||||||
@ -215,6 +215,7 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
|
|||||||
return new CoreApi(DEFAULT_CONFIG)
|
return new CoreApi(DEFAULT_CONFIG)
|
||||||
.coreUsersImpersonateCreate({
|
.coreUsersImpersonateCreate({
|
||||||
id: user.pk,
|
id: user.pk,
|
||||||
|
impersonationRequest: { reason: "" },
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
|
|||||||
@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
|
|||||||
export const ERROR_CLASS = "pf-m-danger";
|
export const ERROR_CLASS = "pf-m-danger";
|
||||||
export const PROGRESS_CLASS = "pf-m-in-progress";
|
export const PROGRESS_CLASS = "pf-m-in-progress";
|
||||||
export const CURRENT_CLASS = "pf-m-current";
|
export const CURRENT_CLASS = "pf-m-current";
|
||||||
export const VERSION = "2024.10.3";
|
export const VERSION = "2024.10.5";
|
||||||
export const TITLE_DEFAULT = "authentik";
|
export const TITLE_DEFAULT = "authentik";
|
||||||
export const ROUTE_SEPARATOR = ";";
|
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"/>
|
///<reference types="@hcaptcha/types"/>
|
||||||
import { renderStatic } from "@goauthentik/common/purify";
|
import { renderStatic } from "@goauthentik/common/purify";
|
||||||
import "@goauthentik/elements/EmptyState";
|
import "@goauthentik/elements/EmptyState";
|
||||||
|
import { akEmptyState } from "@goauthentik/elements/EmptyState";
|
||||||
|
import { bound } from "@goauthentik/elements/decorators/bound";
|
||||||
import "@goauthentik/elements/forms/FormElement";
|
import "@goauthentik/elements/forms/FormElement";
|
||||||
|
import { ListenerController } from "@goauthentik/elements/utils/listenerController.js";
|
||||||
import { randomId } from "@goauthentik/elements/utils/randomId";
|
import { randomId } from "@goauthentik/elements/utils/randomId";
|
||||||
import "@goauthentik/flow/FormStatic";
|
import "@goauthentik/flow/FormStatic";
|
||||||
import { BaseStage } from "@goauthentik/flow/stages/base";
|
import { BaseStage } from "@goauthentik/flow/stages/base";
|
||||||
|
import { P, match } from "ts-pattern";
|
||||||
import type { TurnstileObject } from "turnstile-types";
|
import type { TurnstileObject } from "turnstile-types";
|
||||||
|
|
||||||
import { msg } from "@lit/localize";
|
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 { customElement, property, state } from "lit/decorators.js";
|
||||||
import { ifDefined } from "lit/directives/if-defined.js";
|
import { ifDefined } from "lit/directives/if-defined.js";
|
||||||
|
|
||||||
@ -23,8 +27,72 @@ import { CaptchaChallenge, CaptchaChallengeResponseRequest } from "@goauthentik/
|
|||||||
interface TurnstileWindow extends Window {
|
interface TurnstileWindow extends Window {
|
||||||
turnstile: TurnstileObject;
|
turnstile: TurnstileObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenHandler = (token: string) => void;
|
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")
|
@customElement("ak-stage-captcha")
|
||||||
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
|
export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeResponseRequest> {
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
@ -37,26 +105,12 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
css`
|
css`
|
||||||
iframe {
|
iframe {
|
||||||
width: 100%;
|
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 })
|
@property({ type: Boolean })
|
||||||
embedded = false;
|
embedded = false;
|
||||||
|
|
||||||
@ -65,209 +119,177 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
this.host.submit({ component: "ak-stage-captcha", token });
|
this.host.submit({ component: "ak-stage-captcha", token });
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
@state()
|
||||||
super();
|
error?: string;
|
||||||
this.captchaFrame = document.createElement("iframe");
|
|
||||||
this.captchaFrame.src = "about:blank";
|
|
||||||
this.captchaFrame.id = `ak-captcha-${randomId()}`;
|
|
||||||
|
|
||||||
this.captchaDocumentContainer = document.createElement("div");
|
handlers: CaptchaHandler[] = [
|
||||||
this.captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
|
{
|
||||||
this.messageCallback = this.messageCallback.bind(this);
|
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 {
|
connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
window.addEventListener("message", this.messageCallback);
|
window.addEventListener("message", this.onIframeMessage, {
|
||||||
|
signal: this._listenController.signal,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
super.disconnectedCallback();
|
this._listenController.abort();
|
||||||
window.removeEventListener("message", this.messageCallback);
|
if (!this.challenge?.interactive) {
|
||||||
if (!this.challenge.interactive) {
|
if (document.body.contains(this.captchaDocumentContainer)) {
|
||||||
document.body.removeChild(this.captchaDocumentContainer);
|
document.body.removeChild(this.captchaDocumentContainer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
super.disconnectedCallback();
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async renderFrame(captchaElement: TemplateResult) {
|
get captchaDocumentContainer() {
|
||||||
this.captchaFrame.contentWindow?.document.open();
|
if (this._captchaDocumentContainer) {
|
||||||
this.captchaFrame.contentWindow?.document.write(
|
return this._captchaDocumentContainer;
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
this._captchaDocumentContainer = document.createElement("div");
|
||||||
</body>
|
this._captchaDocumentContainer.id = `ak-captcha-${randomId()}`;
|
||||||
</html>`,
|
return this._captchaDocumentContainer;
|
||||||
),
|
|
||||||
);
|
|
||||||
this.captchaFrame.contentWindow?.document.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changedProperties: PropertyValues<this>) {
|
get captchaFrame() {
|
||||||
if (changedProperties.has("challenge") && this.challenge !== undefined) {
|
if (this._captchaFrame) {
|
||||||
this.scriptElement = document.createElement("script");
|
return this._captchaFrame;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
this._captchaFrame = document.createElement("iframe");
|
||||||
|
this._captchaFrame.src = "about:blank";
|
||||||
|
this._captchaFrame.id = `ak-captcha-${randomId()}`;
|
||||||
|
return this._captchaFrame;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleGReCaptcha(): Promise<boolean> {
|
onFrameResize({ height }: Dims) {
|
||||||
if (!Object.hasOwn(window, "grecaptcha")) {
|
this.captchaFrame.style.height = `${height}px`;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
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(
|
this.renderFrame(
|
||||||
html`<div
|
html`<div
|
||||||
class="g-recaptcha"
|
class="g-recaptcha ak-captcha-container"
|
||||||
data-sitekey="${this.challenge.siteKey}"
|
data-sitekey="${this.challenge.siteKey}"
|
||||||
data-callback="callback"
|
data-callback="callback"
|
||||||
></div>`,
|
></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,
|
sitekey: this.challenge.siteKey,
|
||||||
callback: this.onTokenChange,
|
callback: this.onTokenChange,
|
||||||
size: "invisible",
|
size: "invisible",
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
grecaptcha.execute(captchaId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleHCaptcha(): Promise<boolean> {
|
async renderHCaptchaFrame() {
|
||||||
if (!Object.hasOwn(window, "hcaptcha")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.challenge.interactive) {
|
|
||||||
this.renderFrame(
|
this.renderFrame(
|
||||||
html`<div
|
html`<div
|
||||||
class="h-captcha"
|
class="h-captcha ak-captcha-container"
|
||||||
data-sitekey="${this.challenge.siteKey}"
|
data-sitekey="${this.challenge.siteKey}"
|
||||||
data-theme="${this.activeTheme ? this.activeTheme : "light"}"
|
data-theme="${this.activeTheme ? this.activeTheme : "light"}"
|
||||||
data-callback="callback"
|
data-callback="callback"
|
||||||
></div> `,
|
></div> `,
|
||||||
);
|
);
|
||||||
} else {
|
}
|
||||||
const captchaId = hcaptcha.render(this.captchaDocumentContainer, {
|
|
||||||
|
async executeHCaptcha() {
|
||||||
|
return hcaptcha.execute(
|
||||||
|
hcaptcha.render(this.captchaDocumentContainer, {
|
||||||
sitekey: this.challenge.siteKey,
|
sitekey: this.challenge.siteKey,
|
||||||
callback: this.onTokenChange,
|
callback: this.onTokenChange,
|
||||||
size: "invisible",
|
size: "invisible",
|
||||||
});
|
}),
|
||||||
hcaptcha.execute(captchaId);
|
);
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleTurnstile(): Promise<boolean> {
|
async renderTurnstileFrame() {
|
||||||
if (!Object.hasOwn(window, "turnstile")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.challenge.interactive) {
|
|
||||||
this.renderFrame(
|
this.renderFrame(
|
||||||
html`<div
|
html`<div
|
||||||
class="cf-turnstile"
|
class="cf-turnstile ak-captcha-container"
|
||||||
data-sitekey="${this.challenge.siteKey}"
|
data-sitekey="${this.challenge.siteKey}"
|
||||||
data-callback="callback"
|
data-callback="callback"
|
||||||
></div>`,
|
></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,
|
sitekey: this.challenge.siteKey,
|
||||||
callback: this.onTokenChange,
|
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() {
|
renderBody() {
|
||||||
if (this.error) {
|
// [hasError, isInteractive]
|
||||||
return html`<ak-empty-state icon="fa-times" header=${this.error}> </ak-empty-state>`;
|
// prettier-ignore
|
||||||
}
|
return match([Boolean(this.error), Boolean(this.challenge?.interactive)])
|
||||||
if (this.challenge.interactive) {
|
.with([true, P.any], () => akEmptyState({ icon: "fa-times", header: this.error }))
|
||||||
return html`${this.captchaFrame}`;
|
.with([false, true], () => html`${this.captchaFrame}`)
|
||||||
}
|
.with([false, false], () => akEmptyState({ loading: true, header: msg("Verifying...") }))
|
||||||
return html`<ak-empty-state loading header=${msg("Verifying...")}></ak-empty-state>`;
|
.exhaustive();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
renderMain() {
|
||||||
if (this.embedded) {
|
|
||||||
if (!this.challenge.interactive) {
|
|
||||||
return html``;
|
|
||||||
}
|
|
||||||
return this.renderBody();
|
|
||||||
}
|
|
||||||
if (!this.challenge) {
|
|
||||||
return html`<ak-empty-state loading> </ak-empty-state>`;
|
|
||||||
}
|
|
||||||
return html`<header class="pf-c-login__main-header">
|
return html`<header class="pf-c-login__main-header">
|
||||||
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
|
||||||
</header>
|
</header>
|
||||||
@ -291,6 +313,63 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
|||||||
<ul class="pf-c-login__main-footer-links"></ul>
|
<ul class="pf-c-login__main-footer-links"></ul>
|
||||||
</footer>`;
|
</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 {
|
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, 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
|
### About users and licenses
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user