mirror of
https://github.com/goauthentik/authentik
synced 2026-05-05 22:52:42 +02:00
Compare commits
93 Commits
next
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f83d3a19d0 | ||
|
|
ef59ff1856 | ||
|
|
4966225282 | ||
|
|
2b8765d0aa | ||
|
|
d60d06f958 | ||
|
|
1a3f268476 | ||
|
|
515a855c40 | ||
|
|
16d65b8d12 | ||
|
|
bfe928df18 | ||
|
|
c447bbe6c8 | ||
|
|
1c0a3f95df | ||
|
|
8a6116ab79 | ||
|
|
430010fbea | ||
|
|
079b575a45 | ||
|
|
b2ca887d59 | ||
|
|
d7b30ad0d7 | ||
|
|
b084ace1dd | ||
|
|
b3e45cdf1a | ||
|
|
8132e1f7d9 | ||
|
|
149dccf244 | ||
|
|
b5e4797761 | ||
|
|
be670d6253 | ||
|
|
71060ea4e7 | ||
|
|
f60f38280c | ||
|
|
418deeb332 | ||
|
|
619c77c27e | ||
|
|
ddfddb49da | ||
|
|
dbbb1870b7 | ||
|
|
5b43301206 | ||
|
|
d915d1a94a | ||
|
|
786497790a | ||
|
|
56c899cf21 | ||
|
|
943f22e5a9 | ||
|
|
11b45689f4 | ||
|
|
87f443532f | ||
|
|
0c672a0c37 | ||
|
|
dfd11ceb57 | ||
|
|
d865b7fd87 | ||
|
|
aa8a6b9c43 | ||
|
|
fe5313f42e | ||
|
|
499f739e2b | ||
|
|
4e0e738823 | ||
|
|
24360bf306 | ||
|
|
6fad3c2bbd | ||
|
|
2cf20de7ec | ||
|
|
3d8d3bb8ce | ||
|
|
80bcbe4885 | ||
|
|
32e4782ed8 | ||
|
|
613a51bdbb | ||
|
|
1c6de43701 | ||
|
|
6771530025 | ||
|
|
5876f367bc | ||
|
|
e263af2dd9 | ||
|
|
3a59911a2b | ||
|
|
bbf31e99c3 | ||
|
|
9d5bd42f3e | ||
|
|
e721dae6da | ||
|
|
af3106b144 | ||
|
|
5b55103575 | ||
|
|
ee4ecf929f | ||
|
|
8336556a6f | ||
|
|
709aad1d3b | ||
|
|
fb7ab4937c | ||
|
|
5df1726d80 | ||
|
|
9fdb568843 | ||
|
|
8e76f56f89 | ||
|
|
05d3791577 | ||
|
|
d00dd7eb90 | ||
|
|
8d2e404017 | ||
|
|
95eb2af25e | ||
|
|
cbc00a501b | ||
|
|
480645d897 | ||
|
|
997c767c95 | ||
|
|
5a54e1dc9a | ||
|
|
49b1952566 | ||
|
|
e73edc2fce | ||
|
|
409652e874 | ||
|
|
1d3fb6431f | ||
|
|
76cfada60f | ||
|
|
ac45f80551 | ||
|
|
5ea85f086a | ||
|
|
e3f657746c | ||
|
|
001b56e2cc | ||
|
|
ecbfd2f0de | ||
|
|
45753397e1 | ||
|
|
dc6fe1dafe | ||
|
|
d5e8f2f416 | ||
|
|
d73af5a2b4 | ||
|
|
7042f2bba8 | ||
|
|
efeb260fa8 | ||
|
|
29e90092ea | ||
|
|
0abe865023 | ||
|
|
220c65a41a |
3
.github/actions/setup/action.yml
vendored
3
.github/actions/setup/action.yml
vendored
@@ -12,13 +12,14 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install apt deps
|
||||
- name: Install apt deps & cleanup
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get remove --purge man-db
|
||||
sudo apt-get update
|
||||
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v5
|
||||
|
||||
2
.github/actions/test-results/action.yml
vendored
2
.github/actions/test-results/action.yml
vendored
@@ -20,7 +20,7 @@ runs:
|
||||
- name: PostgreSQL Logs
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ $ACTIONS_RUNNER_DEBUG == 'true' || $ACTIONS_STEP_DEBUG == 'true' ]]; then
|
||||
if [[ $RUNNER_DEBUG == '1' ]]; then
|
||||
docker stop setup-postgresql-1
|
||||
echo "::group::PostgreSQL Logs"
|
||||
docker logs setup-postgresql-1
|
||||
|
||||
4
.github/workflows/release-tag.yml
vendored
4
.github/workflows/release-tag.yml
vendored
@@ -49,8 +49,12 @@ jobs:
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- check-inputs
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
|
||||
@@ -40,7 +40,7 @@ packages/tsconfig @goauthentik/frontend
|
||||
# Web
|
||||
web/ @goauthentik/frontend
|
||||
# Locale
|
||||
locale/ @goauthentik/backend @goauthentik/frontend
|
||||
/locale/ @goauthentik/backend @goauthentik/frontend
|
||||
web/xliff/ @goauthentik/backend @goauthentik/frontend
|
||||
# Docs
|
||||
website/ @goauthentik/docs
|
||||
|
||||
2
Makefile
2
Makefile
@@ -327,6 +327,6 @@ ci-pending-migrations: ci--meta-debug
|
||||
uv run ak makemigrations --check
|
||||
|
||||
ci-test: ci--meta-debug
|
||||
uv run coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
|
||||
uv run coverage run manage.py test --keepdb authentik
|
||||
uv run coverage report
|
||||
uv run coverage xml
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2025.12.0-rc1"
|
||||
VERSION = "2025.12.0-rc3"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class VersionSerializer(PassiveSerializer):
|
||||
|
||||
def get_version_latest(self, _) -> str:
|
||||
"""Get latest version from cache"""
|
||||
if get_current_tenant().schema_name == get_public_schema_name():
|
||||
if get_current_tenant().schema_name != get_public_schema_name():
|
||||
return authentik_version()
|
||||
version_in_cache = cache.get(VERSION_CACHE_KEY)
|
||||
if not version_in_cache: # pragma: no cover
|
||||
|
||||
@@ -240,7 +240,9 @@ class FileUsedByView(APIView):
|
||||
for field in fields:
|
||||
q |= Q(**{field: params.get("name")})
|
||||
|
||||
objs = get_objects_for_user(request.user, f"{app}.view_{model_name}", model)
|
||||
objs = get_objects_for_user(
|
||||
request.user, f"{app}.view_{model_name}", model.objects.all()
|
||||
)
|
||||
objs = objs.filter(q)
|
||||
for obj in objs:
|
||||
serializer = UsedBySerializer(
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class AuthentikFilesConfig(ManagedAppConfig):
|
||||
@@ -11,20 +6,3 @@ class AuthentikFilesConfig(ManagedAppConfig):
|
||||
label = "authentik_admin_files"
|
||||
verbose_name = "authentik Files"
|
||||
default = True
|
||||
|
||||
@ManagedAppConfig.reconcile_global
|
||||
def check_for_media_mount(self):
|
||||
if settings.TEST:
|
||||
return
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
if (
|
||||
CONFIG.get("storage.media.backend", CONFIG.get("storage.backend", "file")) == "file"
|
||||
and Path("/media").exists()
|
||||
):
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="/media has been moved to /data/media. "
|
||||
"Check the release notes for migration steps.",
|
||||
).save()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin
|
||||
from authentik.admin.files.tests.utils import FileTestS3BackendMixin, s3_test_server_available
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
@skipUnless(s3_test_server_available(), "S3 test server not available")
|
||||
class TestS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
"""Test S3 backend functionality"""
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Test file service layer"""
|
||||
|
||||
from unittest import skipUnless
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.admin.files.manager import FileManager
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin, FileTestS3BackendMixin
|
||||
from authentik.admin.files.tests.utils import (
|
||||
FileTestFileBackendMixin,
|
||||
FileTestS3BackendMixin,
|
||||
s3_test_server_available,
|
||||
)
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
@@ -81,6 +87,7 @@ class TestResolveFileUrlFileBackend(FileTestFileBackendMixin, TestCase):
|
||||
self.assertEqual(result, "http://example.com/files/media/public/test.png")
|
||||
|
||||
|
||||
@skipUnless(s3_test_server_available(), "S3 test server not available")
|
||||
class TestResolveFileUrlS3Backend(FileTestS3BackendMixin, TestCase):
|
||||
@CONFIG.patch("storage.media.s3.custom_domain", "s3.test:8080/test")
|
||||
@CONFIG.patch("storage.media.s3.secure_urls", False)
|
||||
|
||||
@@ -62,10 +62,10 @@ class TestSanitizeFilePath(TestCase):
|
||||
"test@file.png", # @
|
||||
"test#file.png", # #
|
||||
"test$file.png", # $
|
||||
"test%file.png", # %
|
||||
"test%file.png", # % (but %(theme)s is allowed)
|
||||
"test&file.png", # &
|
||||
"test*file.png", # *
|
||||
"test(file).png", # parentheses
|
||||
"test(file).png", # parentheses (but %(theme)s is allowed)
|
||||
"test[file].png", # brackets
|
||||
"test{file}.png", # braces
|
||||
]
|
||||
@@ -108,3 +108,30 @@ class TestSanitizeFilePath(TestCase):
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
|
||||
def test_sanitize_theme_variable_valid(self):
|
||||
"""Test sanitizing filename with %(theme)s variable"""
|
||||
# These should all be valid
|
||||
validate_file_name("logo-%(theme)s.png")
|
||||
validate_file_name("brand/logo-%(theme)s.svg")
|
||||
validate_file_name("images/icon-%(theme)s.png")
|
||||
validate_file_name("%(theme)s/logo.png")
|
||||
validate_file_name("brand/%(theme)s/logo.png")
|
||||
|
||||
def test_sanitize_theme_variable_multiple(self):
|
||||
"""Test sanitizing filename with multiple %(theme)s variables"""
|
||||
validate_file_name("%(theme)s/logo-%(theme)s.png")
|
||||
|
||||
def test_sanitize_theme_variable_invalid_format(self):
|
||||
"""Test that partial or malformed theme variables are rejected"""
|
||||
invalid_paths = [
|
||||
"test%(theme.png", # missing )s
|
||||
"test%theme)s.png", # missing (
|
||||
"test%(themes).png", # wrong variable name
|
||||
"test%(THEME)s.png", # wrong case
|
||||
"test%()s.png", # empty variable name
|
||||
]
|
||||
|
||||
for path in invalid_paths:
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import shutil
|
||||
import socket
|
||||
from tempfile import mkdtemp
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from authentik.admin.files.backends.s3 import S3Backend
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.lib.config import CONFIG, UNSET
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
S3_TEST_ENDPOINT = "http://localhost:8020"
|
||||
|
||||
|
||||
def s3_test_server_available() -> bool:
|
||||
"""Check if the S3 test server is reachable."""
|
||||
|
||||
parsed = urlparse(S3_TEST_ENDPOINT)
|
||||
try:
|
||||
with socket.create_connection((parsed.hostname, parsed.port), timeout=2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
class FileTestFileBackendMixin:
|
||||
def setUp(self):
|
||||
@@ -57,7 +72,7 @@ class FileTestS3BackendMixin:
|
||||
for key in s3_config_keys:
|
||||
self.original_media_s3_settings[key] = CONFIG.get(f"storage.media.s3.{key}", UNSET)
|
||||
self.media_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.media.s3.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.media.s3.endpoint", S3_TEST_ENDPOINT)
|
||||
CONFIG.set("storage.media.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.media.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.media.s3.bucket_name", self.media_s3_bucket_name)
|
||||
@@ -70,7 +85,7 @@ class FileTestS3BackendMixin:
|
||||
for key in s3_config_keys:
|
||||
self.original_reports_s3_settings[key] = CONFIG.get(f"storage.reports.s3.{key}", UNSET)
|
||||
self.reports_s3_bucket_name = f"authentik-test-{generate_id(10)}".lower()
|
||||
CONFIG.set("storage.reports.s3.endpoint", "http://localhost:8020")
|
||||
CONFIG.set("storage.reports.s3.endpoint", S3_TEST_ENDPOINT)
|
||||
CONFIG.set("storage.reports.s3.access_key", "accessKey1")
|
||||
CONFIG.set("storage.reports.s3.secret_key", "secretKey1")
|
||||
CONFIG.set("storage.reports.s3.bucket_name", self.reports_s3_bucket_name)
|
||||
|
||||
@@ -12,6 +12,10 @@ from authentik.admin.files.usage import FileUsage
|
||||
MAX_FILE_NAME_LENGTH = 1024
|
||||
MAX_PATH_COMPONENT_LENGTH = 255
|
||||
|
||||
# Theme variable placeholder that can be used in file paths
|
||||
# This allows for theme-specific files like logo-%(theme)s.png
|
||||
THEME_VARIABLE = "%(theme)s"
|
||||
|
||||
|
||||
def validate_file_name(name: str) -> None:
|
||||
if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend(
|
||||
@@ -39,12 +43,17 @@ def validate_upload_file_name(
|
||||
if not name:
|
||||
raise ValidationError(_("File name cannot be empty"))
|
||||
|
||||
# Same regex is used in the frontend as well
|
||||
if not re.match(r"^[a-zA-Z0-9._/-]+$", name):
|
||||
# Allow %(theme)s placeholder for theme-specific files
|
||||
# We temporarily replace it for validation, then check the result
|
||||
name_for_validation = name.replace(THEME_VARIABLE, "theme")
|
||||
|
||||
# Same regex is used in the frontend as well (without %(theme)s handling there)
|
||||
if not re.match(r"^[a-zA-Z0-9._/-]+$", name_for_validation):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
|
||||
"dots (.), hyphens (-), underscores (_), and forward slashes (/)"
|
||||
"dots (.), hyphens (-), underscores (_), forward slashes (/), "
|
||||
"and the special placeholder %(theme)s for theme-specific files"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ class Pagination(pagination.PageNumberPagination):
|
||||
|
||||
def get_page_size(self, request):
|
||||
if self.page_size_query_param in request.query_params:
|
||||
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
|
||||
page_size = super().get_page_size(request)
|
||||
if page_size is not None:
|
||||
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
|
||||
return request.tenant.pagination_default_page_size
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
|
||||
@@ -31,6 +31,7 @@ class Capabilities(models.TextChoices):
|
||||
"""Define capabilities which influence which APIs can/should be used"""
|
||||
|
||||
CAN_SAVE_MEDIA = "can_save_media"
|
||||
CAN_SAVE_REPORTS = "can_save_reports"
|
||||
CAN_GEO_IP = "can_geo_ip"
|
||||
CAN_ASN = "can_asn"
|
||||
CAN_IMPERSONATE = "can_impersonate"
|
||||
@@ -70,6 +71,8 @@ class ConfigView(APIView):
|
||||
caps = []
|
||||
if get_file_manager(FileUsage.MEDIA).manageable:
|
||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||
if get_file_manager(FileUsage.REPORTS).manageable:
|
||||
caps.append(Capabilities.CAN_SAVE_REPORTS)
|
||||
for processor in get_context_processors():
|
||||
if cap := processor.capability():
|
||||
caps.append(cap)
|
||||
|
||||
@@ -8,45 +8,62 @@ metadata:
|
||||
- Application (icon)
|
||||
- Source (icon)
|
||||
- Flow (background)
|
||||
- Endpoint Enrollment token (key)
|
||||
entries:
|
||||
- model: authentik_core.token
|
||||
identifiers:
|
||||
identifier: "%(uid)s-token"
|
||||
attrs:
|
||||
key: "%(uid)s"
|
||||
user: "%(user)s"
|
||||
intent: api
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: "%(uid)s-app"
|
||||
attrs:
|
||||
name: "%(uid)s-app"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
- model: authentik_sources_oauth.oauthsource
|
||||
identifiers:
|
||||
slug: "%(uid)s-source"
|
||||
attrs:
|
||||
name: "%(uid)s-source"
|
||||
provider_type: azuread
|
||||
consumer_key: "%(uid)s"
|
||||
consumer_secret: "%(uid)s"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
- model: authentik_flows.flow
|
||||
identifiers:
|
||||
slug: "%(uid)s-flow"
|
||||
attrs:
|
||||
name: "%(uid)s-flow"
|
||||
title: "%(uid)s-flow"
|
||||
designation: authentication
|
||||
background: https://goauthentik.io/img/icon.png
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
password: "%(uid)s"
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s-no-password"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
token:
|
||||
- model: authentik_core.token
|
||||
identifiers:
|
||||
identifier: "%(uid)s-token"
|
||||
attrs:
|
||||
key: "%(uid)s"
|
||||
user: "%(user)s"
|
||||
intent: api
|
||||
app:
|
||||
- model: authentik_core.application
|
||||
identifiers:
|
||||
slug: "%(uid)s-app"
|
||||
attrs:
|
||||
name: "%(uid)s-app"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
source:
|
||||
- model: authentik_sources_oauth.oauthsource
|
||||
identifiers:
|
||||
slug: "%(uid)s-source"
|
||||
attrs:
|
||||
name: "%(uid)s-source"
|
||||
provider_type: azuread
|
||||
consumer_key: "%(uid)s"
|
||||
consumer_secret: "%(uid)s"
|
||||
icon: https://goauthentik.io/img/icon.png
|
||||
flow:
|
||||
- model: authentik_flows.flow
|
||||
identifiers:
|
||||
slug: "%(uid)s-flow"
|
||||
attrs:
|
||||
name: "%(uid)s-flow"
|
||||
title: "%(uid)s-flow"
|
||||
designation: authentication
|
||||
background: https://goauthentik.io/img/icon.png
|
||||
user:
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
password: "%(uid)s"
|
||||
- model: authentik_core.user
|
||||
identifiers:
|
||||
username: "%(uid)s-no-password"
|
||||
attrs:
|
||||
name: "%(uid)s"
|
||||
endpoint:
|
||||
- model: authentik_endpoints_connectors_agent.agentconnector
|
||||
id: connector
|
||||
identifiers:
|
||||
name: "%(uid)s"
|
||||
- model: authentik_endpoints_connectors_agent.enrollmenttoken
|
||||
identifiers:
|
||||
name: "%(uid)s"
|
||||
attrs:
|
||||
key: "%(uid)s"
|
||||
connector: !KeyOf connector
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.test import TransactionTestCase
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.core.models import Token, User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.endpoints.connectors.agent.models import EnrollmentToken
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
|
||||
@@ -29,12 +30,18 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
|
||||
|
||||
def test_user(self):
|
||||
"""Test user"""
|
||||
user: User = User.objects.filter(username=self.uid).first()
|
||||
user = User.objects.filter(username=self.uid).first()
|
||||
self.assertIsNotNone(user)
|
||||
self.assertTrue(user.check_password(self.uid))
|
||||
|
||||
def test_user_null(self):
|
||||
"""Test user"""
|
||||
user: User = User.objects.filter(username=f"{self.uid}-no-password").first()
|
||||
user = User.objects.filter(username=f"{self.uid}-no-password").first()
|
||||
self.assertIsNotNone(user)
|
||||
self.assertFalse(user.has_usable_password())
|
||||
|
||||
def test_enrollment_token(self):
|
||||
"""Test endpoint enrollment token"""
|
||||
token = EnrollmentToken.objects.filter(name=self.uid).first()
|
||||
self.assertIsNotNone(token)
|
||||
self.assertEqual(token.key, self.uid)
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
|
||||
instance.status,
|
||||
BlueprintInstanceStatus.UNKNOWN,
|
||||
)
|
||||
apply_blueprint(instance.pk)
|
||||
apply_blueprint.send(instance.pk).get_result(block=True)
|
||||
instance.refresh_from_db()
|
||||
self.assertEqual(instance.last_applied_hash, "")
|
||||
self.assertEqual(
|
||||
|
||||
@@ -37,14 +37,21 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
|
||||
return super().validate(attrs)
|
||||
|
||||
def create(self, validated_data: dict) -> MetaResult:
|
||||
from authentik.blueprints.v1.tasks import apply_blueprint
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
|
||||
if not self.blueprint_instance:
|
||||
LOGGER.info("Blueprint does not exist, but not required")
|
||||
return MetaResult()
|
||||
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
|
||||
|
||||
apply_blueprint(self.blueprint_instance.pk)
|
||||
# Apply blueprint directly using Importer to avoid task context requirements
|
||||
# and prevent deadlocks when called from within another blueprint task
|
||||
blueprint_content = self.blueprint_instance.retrieve()
|
||||
importer = Importer.from_string(blueprint_content, self.blueprint_instance.context)
|
||||
valid, logs = importer.validate()
|
||||
[log.log() for log in logs]
|
||||
if valid:
|
||||
importer.apply()
|
||||
return MetaResult()
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_dramatiq_postgres.middleware import CurrentTaskNotFound
|
||||
from dramatiq.actor import actor
|
||||
from dramatiq.middleware import Middleware
|
||||
from structlog.stdlib import get_logger
|
||||
@@ -40,7 +39,6 @@ from authentik.events.utils import sanitize_dict
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.tasks.apps import PRIORITY_HIGH
|
||||
from authentik.tasks.middleware import CurrentTask
|
||||
from authentik.tasks.models import Task
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@@ -191,10 +189,7 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
|
||||
|
||||
@actor(description=_("Apply single blueprint."))
|
||||
def apply_blueprint(instance_pk: UUID):
|
||||
try:
|
||||
self = CurrentTask.get_task()
|
||||
except CurrentTaskNotFound:
|
||||
self = Task()
|
||||
self = CurrentTask.get_task()
|
||||
self.set_uid(str(instance_pk))
|
||||
instance: BlueprintInstance | None = None
|
||||
try:
|
||||
|
||||
@@ -180,10 +180,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
|
||||
def _filter_applications_with_launch_url(
|
||||
self, applications: QuerySet[Application]
|
||||
self, paginated_apps: QuerySet[Application]
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
for app in applications:
|
||||
for app in paginated_apps:
|
||||
if app.get_launch_url():
|
||||
applications.append(app)
|
||||
return applications
|
||||
|
||||
@@ -33,6 +33,16 @@ from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
|
||||
PARTIAL_USER_SERIALIZER_MODEL_FIELDS = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
]
|
||||
|
||||
|
||||
class PartialUserSerializer(ModelSerializer):
|
||||
"""Partial User Serializer, does not include child relations."""
|
||||
@@ -42,16 +52,7 @@ class PartialUserSerializer(ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
"pk",
|
||||
"username",
|
||||
"name",
|
||||
"is_active",
|
||||
"last_login",
|
||||
"email",
|
||||
"attributes",
|
||||
"uid",
|
||||
]
|
||||
fields = PARTIAL_USER_SERIALIZER_MODEL_FIELDS + ["uid"]
|
||||
|
||||
|
||||
class RelatedGroupSerializer(ModelSerializer):
|
||||
@@ -84,6 +85,7 @@ class GroupSerializer(ModelSerializer):
|
||||
source="roles",
|
||||
required=False,
|
||||
)
|
||||
inherited_roles_obj = SerializerMethodField(allow_null=True)
|
||||
num_pk = IntegerField(read_only=True)
|
||||
|
||||
@property
|
||||
@@ -107,6 +109,13 @@ class GroupSerializer(ModelSerializer):
|
||||
return True
|
||||
return str(request.query_params.get("include_parents", "false")).lower() == "true"
|
||||
|
||||
@property
|
||||
def _should_include_inherited_roles(self) -> bool:
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return True
|
||||
return str(request.query_params.get("include_inherited_roles", "false")).lower() == "true"
|
||||
|
||||
@extend_schema_field(PartialUserSerializer(many=True))
|
||||
def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None:
|
||||
if not self._should_include_users:
|
||||
@@ -125,6 +134,15 @@ class GroupSerializer(ModelSerializer):
|
||||
return None
|
||||
return RelatedGroupSerializer(instance.parents, many=True).data
|
||||
|
||||
@extend_schema_field(RoleSerializer(many=True))
|
||||
def get_inherited_roles_obj(self, instance: Group) -> list | None:
|
||||
"""Return only inherited roles from ancestor groups (excludes direct roles)"""
|
||||
if not self._should_include_inherited_roles:
|
||||
return None
|
||||
direct_role_pks = instance.roles.values_list("pk", flat=True)
|
||||
inherited_roles = instance.all_roles().exclude(pk__in=direct_role_pks)
|
||||
return RoleSerializer(inherited_roles, many=True).data
|
||||
|
||||
def validate_is_superuser(self, superuser: bool):
|
||||
"""Ensure that the user creating this group has permissions to set the superuser flag"""
|
||||
request: Request = self.context.get("request", None)
|
||||
@@ -166,6 +184,7 @@ class GroupSerializer(ModelSerializer):
|
||||
"attributes",
|
||||
"roles",
|
||||
"roles_obj",
|
||||
"inherited_roles_obj",
|
||||
"children",
|
||||
"children_obj",
|
||||
]
|
||||
@@ -255,14 +274,21 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
return [
|
||||
StrField(Group, "name"),
|
||||
BoolField(Group, "is_superuser", nullable=True),
|
||||
JSONSearchField(Group, "attributes", suggest_nested=False),
|
||||
JSONSearchField(Group, "attributes"),
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
base_qs = Group.objects.all().prefetch_related("roles")
|
||||
|
||||
if self.serializer_class(context={"request": self.request})._should_include_users:
|
||||
base_qs = base_qs.prefetch_related("users")
|
||||
# Only fetch fields needed by PartialUserSerializer to reduce DB load and instantiation
|
||||
# time
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch(
|
||||
"users",
|
||||
queryset=User.objects.all().only(*PARTIAL_USER_SERIALIZER_MODEL_FIELDS),
|
||||
)
|
||||
)
|
||||
else:
|
||||
base_qs = base_qs.prefetch_related(
|
||||
Prefetch("users", queryset=User.objects.all().only("id"))
|
||||
@@ -281,6 +307,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
OpenApiParameter("include_parents", bool, default=False),
|
||||
OpenApiParameter("include_inherited_roles", bool, default=False),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
@@ -291,6 +318,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
OpenApiParameter("include_parents", bool, default=False),
|
||||
OpenApiParameter("include_inherited_roles", bool, default=False),
|
||||
]
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
|
||||
@@ -518,7 +518,7 @@ class UserViewSet(
|
||||
StrField(User, "path"),
|
||||
BoolField(User, "is_active", nullable=True),
|
||||
ChoiceSearchField(User, "type"),
|
||||
JSONSearchField(User, "attributes", suggest_nested=False),
|
||||
JSONSearchField(User, "attributes"),
|
||||
]
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -18,10 +18,9 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
||||
RoleModelPermission = apps.get_model("guardian", "RoleModelPermission")
|
||||
|
||||
def get_role_for_user_id(user_id: int) -> Role:
|
||||
name = f"ak-managed-role--user-{user_id}"
|
||||
name = f"ak-migrated-role--user-{user_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
name=name,
|
||||
managed=name,
|
||||
)
|
||||
if created:
|
||||
role.users.add(user_id)
|
||||
@@ -32,11 +31,10 @@ def migrate_object_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEdit
|
||||
if not role:
|
||||
# Every django group should already have a role, so this should never happen.
|
||||
# But let's be nice.
|
||||
name = f"ak-managed-role--group-{group_id}"
|
||||
name = f"ak-migrated-role--group-{group_id}"
|
||||
role, created = Role.objects.using(db_alias).get_or_create(
|
||||
group_id=group_id,
|
||||
name=name,
|
||||
managed=name,
|
||||
)
|
||||
if created:
|
||||
role.group_id = group_id
|
||||
|
||||
@@ -66,9 +66,12 @@ class SessionStore(SessionBase):
|
||||
def decode(self, session_data):
|
||||
try:
|
||||
return pickle.loads(session_data) # nosec
|
||||
except pickle.PickleError:
|
||||
# ValueError, unpickling exceptions. If any of these happen, just return an empty
|
||||
# dictionary (an empty session)
|
||||
except (pickle.PickleError, AttributeError, TypeError):
|
||||
# PickleError, ValueError - unpickling exceptions
|
||||
# AttributeError - can happen when Django model fields (e.g., FileField) are unpickled
|
||||
# and their descriptors fail to initialize (e.g., missing storage)
|
||||
# TypeError - can happen with incompatible pickled objects
|
||||
# If any of these happen, just return an empty dictionary (an empty session)
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
@@ -35,8 +35,13 @@ def clean_expired_models():
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
clear_expired_cache()
|
||||
Message.delete_expired()
|
||||
GroupChannel.delete_expired()
|
||||
for cls in [Message, GroupChannel]:
|
||||
objects = cls.objects.all().filter(expires__lt=now())
|
||||
amount = objects.count()
|
||||
for obj in chunked_queryset(objects):
|
||||
obj.delete()
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
|
||||
|
||||
@actor(description=_("Remove temporary users created by SAML Sources."))
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from typing import cast
|
||||
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.fields import ChoiceField
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
@@ -22,6 +24,9 @@ from authentik.endpoints.connectors.agent.api.agent import (
|
||||
from authentik.endpoints.connectors.agent.auth import (
|
||||
AgentAuth,
|
||||
AgentEnrollmentAuth,
|
||||
DeviceAuthFedAuthentication,
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.controller import MDMConfigResponseSerializer
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
@@ -32,7 +37,10 @@ from authentik.endpoints.connectors.agent.models import (
|
||||
)
|
||||
from authentik.endpoints.facts import DeviceFacts, OSFamily
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
|
||||
|
||||
class AgentConnectorSerializer(ConnectorSerializer):
|
||||
@@ -163,3 +171,43 @@ class AgentConnectorViewSet(
|
||||
connection: AgentDeviceConnection = token.device
|
||||
connection.create_snapshot(data.validated_data)
|
||||
return Response(status=204)
|
||||
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
|
||||
responses={
|
||||
200: AgentTokenResponseSerializer(),
|
||||
404: OpenApiResponse(description="Device not found"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
authentication_classes=[DeviceAuthFedAuthentication],
|
||||
)
|
||||
def auth_fed(self, request: Request) -> Response:
|
||||
federated_token, device, connector = request.auth
|
||||
|
||||
policy_result = check_device_policies(device, federated_token.user, request._request)
|
||||
if not policy_result.passing:
|
||||
raise ValidationError(
|
||||
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
|
||||
)
|
||||
|
||||
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
|
||||
rel_exp = int((exp - now()).total_seconds())
|
||||
Event.new(
|
||||
EventAction.LOGIN,
|
||||
**{
|
||||
PLAN_CONTEXT_METHOD: "jwt",
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"jwt": federated_token,
|
||||
"provider": federated_token.provider,
|
||||
},
|
||||
PLAN_CONTEXT_DEVICE: device,
|
||||
},
|
||||
).from_http(request, user=federated_token.user)
|
||||
return Response({"token": token, "expires_in": rel_exp})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.tokens import TokenViewSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
@@ -19,6 +21,11 @@ class EnrollmentTokenSerializer(ModelSerializer):
|
||||
source="device_group", read_only=True, required=False
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
self.fields["key"] = CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = EnrollmentToken
|
||||
fields = [
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
from typing import Any
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from jwt import PyJWTError, decode, encode
|
||||
from rest_framework.authentication import BaseAuthentication, get_authorization_header
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.request import Request
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authentication import IPCUser, validate_auth
|
||||
from authentik.core.middleware import CTX_AUTH_VIA
|
||||
from authentik.core.models import User
|
||||
from authentik.endpoints.connectors.agent.models import DeviceToken, EnrollmentToken
|
||||
from authentik.crypto.apps import MANAGED_KEY
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceToken, EnrollmentToken
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
PLATFORM_ISSUER = "goauthentik.io/platform"
|
||||
|
||||
|
||||
class DeviceUser(IPCUser):
|
||||
@@ -40,3 +55,96 @@ class AgentAuth(BaseAuthentication):
|
||||
raise PermissionDenied()
|
||||
CTX_AUTH_VIA.set("endpoint_token")
|
||||
return (DeviceUser(), device_token)
|
||||
|
||||
|
||||
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
|
||||
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
|
||||
if not kp:
|
||||
return None, None
|
||||
exp = now() + timedelta_from_string(connector.auth_session_duration)
|
||||
token = encode(
|
||||
{
|
||||
"iss": PLATFORM_ISSUER,
|
||||
"aud": str(device.pk),
|
||||
"iat": int(now().timestamp()),
|
||||
"exp": int(exp.timestamp()),
|
||||
"preferred_username": user.username,
|
||||
**kwargs,
|
||||
},
|
||||
kp.private_key,
|
||||
headers={
|
||||
"kid": kp.kid,
|
||||
},
|
||||
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
|
||||
)
|
||||
return token, exp
|
||||
|
||||
|
||||
class DeviceAuthFedAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = validate_auth(get_authorization_header(request))
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing token")
|
||||
return None
|
||||
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
|
||||
if not device:
|
||||
LOGGER.warning("Couldn't find device")
|
||||
return None
|
||||
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
|
||||
connector = connectors_for_device.first()
|
||||
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
|
||||
federated_token = AccessToken.objects.filter(
|
||||
token=raw_token, provider__in=providers
|
||||
).first()
|
||||
if not federated_token:
|
||||
LOGGER.warning("Couldn't lookup provider")
|
||||
return None
|
||||
_key, _alg = federated_token.provider.jwt_key
|
||||
try:
|
||||
decode(
|
||||
raw_token,
|
||||
_key.public_key(),
|
||||
algorithms=[_alg],
|
||||
options={
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
LOGGER.info(
|
||||
"successfully verified JWT with provider", provider=federated_token.provider.name
|
||||
)
|
||||
return (federated_token.user, (federated_token, device, connector))
|
||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
|
||||
return None
|
||||
|
||||
|
||||
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
|
||||
"""Auth schema"""
|
||||
|
||||
target_class = DeviceAuthFedAuthentication
|
||||
name = "device_federation"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
"""Auth schema"""
|
||||
return {"type": "http", "scheme": "bearer"}
|
||||
|
||||
|
||||
def check_device_policies(device: Device, user: User, request: HttpRequest):
|
||||
"""Check policies bound to device group and device"""
|
||||
if device.access_group:
|
||||
result = check_pbm_policies(device.access_group, user, request)
|
||||
if result.passing:
|
||||
return result
|
||||
return check_pbm_policies(device, user, request)
|
||||
|
||||
|
||||
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
|
||||
policy_engine = PolicyEngine(pbm, user, request)
|
||||
policy_engine.use_cache = False
|
||||
policy_engine.empty_result = False
|
||||
policy_engine.mode = pbm.policy_engine_mode
|
||||
policy_engine.build()
|
||||
result = policy_engine.result
|
||||
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
|
||||
return result
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Enterprise API Views"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import timedelta
|
||||
from functools import wraps
|
||||
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -35,6 +37,18 @@ class EnterpriseRequiredMixin:
|
||||
return super().validate(attrs)
|
||||
|
||||
|
||||
def enterprise_action(func: Callable):
|
||||
"""Check permissions for a single custom action"""
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs) -> Response:
|
||||
if not LicenseKey.cached_summary().status.is_valid:
|
||||
raise ValidationError(_("Enterprise is required to use this endpoint."))
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class LicenseSerializer(ModelSerializer):
|
||||
"""License Serializer"""
|
||||
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.endpoints.connectors.agent.api.agent import (
|
||||
AgentAuthenticationResponse,
|
||||
AgentTokenResponseSerializer,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.auth import AgentAuth
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
DeviceAuthenticationToken,
|
||||
DeviceToken,
|
||||
)
|
||||
from authentik.enterprise.endpoints.connectors.agent.auth import (
|
||||
DeviceAuthFedAuthentication,
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
)
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||
from authentik.enterprise.api import enterprise_action
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -37,6 +26,7 @@ class AgentConnectorViewSetMixin:
|
||||
responses=AgentAuthenticationResponse(),
|
||||
)
|
||||
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
|
||||
@enterprise_action
|
||||
def auth_ia(self, request: Request) -> Response:
|
||||
token: DeviceToken = request.auth
|
||||
auth_token = DeviceAuthenticationToken.objects.create(
|
||||
@@ -54,43 +44,3 @@ class AgentConnectorViewSetMixin:
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
|
||||
responses={
|
||||
200: AgentTokenResponseSerializer(),
|
||||
404: OpenApiResponse(description="Device not found"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=False,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
authentication_classes=[DeviceAuthFedAuthentication],
|
||||
)
|
||||
def auth_fed(self, request: Request) -> Response:
|
||||
federated_token, device, connector = request.auth
|
||||
|
||||
policy_result = check_device_policies(device, federated_token.user, request._request)
|
||||
if not policy_result.passing:
|
||||
raise ValidationError(
|
||||
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
|
||||
)
|
||||
|
||||
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
|
||||
rel_exp = int((exp - now()).total_seconds())
|
||||
Event.new(
|
||||
EventAction.LOGIN,
|
||||
**{
|
||||
PLAN_CONTEXT_METHOD: "jwt",
|
||||
PLAN_CONTEXT_METHOD_ARGS: {
|
||||
"jwt": federated_token,
|
||||
"provider": federated_token.provider,
|
||||
},
|
||||
PLAN_CONTEXT_DEVICE: device,
|
||||
},
|
||||
).from_http(request, user=federated_token.user)
|
||||
return Response({"token": token, "expires_in": rel_exp})
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
from django.http import HttpRequest
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from jwt import PyJWTError, decode, encode
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authentication import get_authorization_header, validate_auth
|
||||
from authentik.core.models import User
|
||||
from authentik.crypto.apps import MANAGED_KEY
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
PLATFORM_ISSUER = "goauthentik.io/platform"
|
||||
|
||||
|
||||
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
|
||||
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
|
||||
if not kp:
|
||||
return None, None
|
||||
exp = now() + timedelta_from_string(connector.auth_session_duration)
|
||||
token = encode(
|
||||
{
|
||||
"iss": PLATFORM_ISSUER,
|
||||
"aud": str(device.pk),
|
||||
"iat": int(now().timestamp()),
|
||||
"exp": int(exp.timestamp()),
|
||||
"preferred_username": user.username,
|
||||
**kwargs,
|
||||
},
|
||||
kp.private_key,
|
||||
headers={
|
||||
"kid": kp.kid,
|
||||
},
|
||||
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
|
||||
)
|
||||
return token, exp
|
||||
|
||||
|
||||
class DeviceAuthFedAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate(self, request):
|
||||
raw_token = validate_auth(get_authorization_header(request))
|
||||
if not raw_token:
|
||||
LOGGER.warning("Missing token")
|
||||
return None
|
||||
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
|
||||
if not device:
|
||||
LOGGER.warning("Couldn't find device")
|
||||
return None
|
||||
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
|
||||
connector = connectors_for_device.first()
|
||||
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
|
||||
federated_token = AccessToken.objects.filter(
|
||||
token=raw_token, provider__in=providers
|
||||
).first()
|
||||
if not federated_token:
|
||||
LOGGER.warning("Couldn't lookup provider")
|
||||
return None
|
||||
_key, _alg = federated_token.provider.jwt_key
|
||||
try:
|
||||
decode(
|
||||
raw_token,
|
||||
_key.public_key(),
|
||||
algorithms=[_alg],
|
||||
options={
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
LOGGER.info(
|
||||
"successfully verified JWT with provider", provider=federated_token.provider.name
|
||||
)
|
||||
return (federated_token.user, (federated_token, device, connector))
|
||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
|
||||
return None
|
||||
|
||||
|
||||
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
|
||||
"""Auth schema"""
|
||||
|
||||
target_class = DeviceAuthFedAuthentication
|
||||
name = "device_federation"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
"""Auth schema"""
|
||||
return {"type": "http", "scheme": "bearer"}
|
||||
|
||||
|
||||
def check_device_policies(device: Device, user: User, request: HttpRequest):
|
||||
"""Check policies bound to device group and device"""
|
||||
if device.access_group:
|
||||
result = check_pbm_policies(device.access_group, user, request)
|
||||
if result.passing:
|
||||
return result
|
||||
return check_pbm_policies(device, user, request)
|
||||
|
||||
|
||||
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
|
||||
policy_engine = PolicyEngine(pbm, user, request)
|
||||
policy_engine.use_cache = False
|
||||
policy_engine.empty_result = False
|
||||
policy_engine.mode = pbm.policy_engine_mode
|
||||
policy_engine.build()
|
||||
result = policy_engine.result
|
||||
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
|
||||
return result
|
||||
@@ -63,8 +63,21 @@ class TestConnectorAuthIA(FlowTestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@patch(
|
||||
"authentik.enterprise.license.LicenseKey.validate",
|
||||
MagicMock(
|
||||
return_value=LicenseKey(
|
||||
aud="",
|
||||
exp=expiry_valid,
|
||||
name=generate_id(),
|
||||
internal_users=100,
|
||||
external_users=100,
|
||||
)
|
||||
),
|
||||
)
|
||||
@reconcile_app("authentik_crypto")
|
||||
def test_auth_ia_fulfill(self):
|
||||
License.objects.create(key=generate_id())
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:agentconnector-auth-ia"),
|
||||
|
||||
@@ -3,12 +3,12 @@ from hmac import compare_digest
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, QueryDict
|
||||
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.enterprise.endpoints.connectors.agent.auth import (
|
||||
from authentik.endpoints.connectors.agent.auth import (
|
||||
agent_auth_issue_token,
|
||||
check_device_policies,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken
|
||||
from authentik.endpoints.models import Device
|
||||
from authentik.enterprise.policy import EnterprisePolicyAccessView
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.flows.models import in_memory_stage
|
||||
|
||||
@@ -4,37 +4,35 @@ from django.urls import reverse
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.fields import CharField, SerializerMethodField
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.core.api.groups import PartialUserSerializer
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.reports.models import DataExport
|
||||
from authentik.enterprise.reports.tasks import generate_export
|
||||
from authentik.rbac.permissions import HasPermission
|
||||
|
||||
|
||||
class RequestedBySerializer(ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ("pk", "username")
|
||||
|
||||
|
||||
class ContentTypeSerializer(ModelSerializer):
|
||||
app_label = CharField(read_only=True)
|
||||
model = CharField(read_only=True)
|
||||
verbose_name_plural = SerializerMethodField()
|
||||
|
||||
def get_verbose_name_plural(self, ct: ContentType) -> str:
|
||||
return ct.model_class()._meta.verbose_name_plural
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ("id", "app_label", "model")
|
||||
fields = ("id", "app_label", "model", "verbose_name_plural")
|
||||
|
||||
|
||||
class DataExportSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
requested_by = RequestedBySerializer(read_only=True)
|
||||
requested_by = PartialUserSerializer(read_only=True)
|
||||
content_type = ContentTypeSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.db import connection
|
||||
from django.db.models import Model, Q
|
||||
from djangoql.compat import text_type
|
||||
from djangoql.schema import StrField
|
||||
from djangoql.serializers import DjangoQLSchemaSerializer
|
||||
|
||||
|
||||
class JSONSearchField(StrField):
|
||||
@@ -14,10 +15,18 @@ class JSONSearchField(StrField):
|
||||
|
||||
model: Model
|
||||
|
||||
def __init__(self, model=None, name=None, nullable=None, suggest_nested=True):
|
||||
def __init__(
|
||||
self,
|
||||
model=None,
|
||||
name=None,
|
||||
nullable=None,
|
||||
suggest_nested=False,
|
||||
fixed_structure: OrderedDict | None = None,
|
||||
):
|
||||
# Set this in the constructor to not clobber the type variable
|
||||
self.type = "relation"
|
||||
self.suggest_nested = suggest_nested
|
||||
self.fixed_structure = fixed_structure
|
||||
super().__init__(model, name, nullable)
|
||||
|
||||
def get_lookup(self, path, operator, value):
|
||||
@@ -57,11 +66,23 @@ class JSONSearchField(StrField):
|
||||
)
|
||||
return (x[0] for x in cursor.fetchall())
|
||||
|
||||
def get_nested_options(self) -> OrderedDict:
|
||||
def get_fixed_structure(self, serializer: DjangoQLSchemaSerializer) -> OrderedDict:
|
||||
new_dict = OrderedDict()
|
||||
if not self.fixed_structure:
|
||||
return new_dict
|
||||
new_dict.setdefault(self.relation(), {})
|
||||
for key, value in self.fixed_structure.items():
|
||||
new_dict[self.relation()][key] = serializer.serialize_field(value)
|
||||
if isinstance(value, JSONSearchField):
|
||||
new_dict.update(value.get_nested_options(serializer))
|
||||
return new_dict
|
||||
|
||||
def get_nested_options(self, serializer: DjangoQLSchemaSerializer) -> OrderedDict:
|
||||
"""Get keys of all nested objects to show autocomplete"""
|
||||
if not self.suggest_nested:
|
||||
if self.fixed_structure:
|
||||
return self.get_fixed_structure(serializer)
|
||||
return OrderedDict()
|
||||
base_model_name = f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}"
|
||||
|
||||
def recursive_function(parts: list[str], parent_parts: list[str] | None = None):
|
||||
if not parent_parts:
|
||||
@@ -87,7 +108,7 @@ class JSONSearchField(StrField):
|
||||
relation_structure = defaultdict(dict)
|
||||
|
||||
for relations in self.json_field_keys():
|
||||
result = recursive_function([base_model_name] + relations)
|
||||
result = recursive_function([self.relation()] + relations)
|
||||
for relation_key, value in result.items():
|
||||
for sub_relation_key, sub_value in value.items():
|
||||
if not relation_structure[relation_key].get(sub_relation_key, None):
|
||||
|
||||
@@ -12,7 +12,7 @@ class AKQLSchemaSerializer(DjangoQLSchemaSerializer):
|
||||
for _, field in fields.items():
|
||||
if not isinstance(field, JSONSearchField):
|
||||
continue
|
||||
serialization["models"].update(field.get_nested_options())
|
||||
serialization["models"].update(field.get_nested_options(self))
|
||||
return serialization
|
||||
|
||||
def serialize_field(self, field):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Events API Views"""
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
|
||||
import django_filters
|
||||
@@ -136,7 +137,7 @@ class EventViewSet(
|
||||
filterset_class = EventsFilter
|
||||
|
||||
def get_ql_fields(self):
|
||||
from djangoql.schema import DateTimeField, StrField
|
||||
from djangoql.schema import DateTimeField, IntField, StrField
|
||||
|
||||
from authentik.enterprise.search.fields import ChoiceSearchField, JSONSearchField
|
||||
|
||||
@@ -145,9 +146,42 @@ class EventViewSet(
|
||||
StrField(Event, "event_uuid"),
|
||||
StrField(Event, "app", suggest_options=True),
|
||||
StrField(Event, "client_ip"),
|
||||
JSONSearchField(Event, "user", suggest_nested=False),
|
||||
JSONSearchField(Event, "brand", suggest_nested=False),
|
||||
JSONSearchField(Event, "context", suggest_nested=False),
|
||||
JSONSearchField(
|
||||
Event,
|
||||
"user",
|
||||
fixed_structure=OrderedDict(
|
||||
pk=IntField(),
|
||||
username=StrField(),
|
||||
email=StrField(),
|
||||
),
|
||||
),
|
||||
JSONSearchField(
|
||||
Event,
|
||||
"brand",
|
||||
fixed_structure=OrderedDict(
|
||||
pk=StrField(),
|
||||
app=StrField(),
|
||||
name=StrField(),
|
||||
model_name=StrField(),
|
||||
),
|
||||
),
|
||||
JSONSearchField(
|
||||
Event,
|
||||
"context",
|
||||
fixed_structure=OrderedDict(
|
||||
http_request=JSONSearchField(
|
||||
Event,
|
||||
"context_http_request",
|
||||
fixed_structure=OrderedDict(
|
||||
args=JSONSearchField(Event, "context_http_request_args"),
|
||||
path=StrField(),
|
||||
method=StrField(),
|
||||
request_id=StrField(),
|
||||
user_agent=StrField(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DateTimeField(Event, "created", suggest_options=True),
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from django.utils.timezone import now
|
||||
from rest_framework.fields import CharField, ChoiceField, DateTimeField, DictField
|
||||
from structlog import configure, get_config
|
||||
from structlog.stdlib import NAME_TO_LEVEL, ProcessorFormatter
|
||||
from structlog.stdlib import NAME_TO_LEVEL, ProcessorFormatter, get_logger
|
||||
from structlog.testing import LogCapture
|
||||
from structlog.types import EventDict
|
||||
|
||||
@@ -36,6 +36,9 @@ class LogEvent:
|
||||
event, log_level, item.pop("logger"), timestamp, attributes=sanitize_dict(item)
|
||||
)
|
||||
|
||||
def log(self):
|
||||
get_logger(self.logger).log(NAME_TO_LEVEL[self.log_level], self.event, **self.attributes)
|
||||
|
||||
|
||||
class LogEventSerializer(PassiveSerializer):
|
||||
"""Single log message with all context logged."""
|
||||
|
||||
@@ -8,6 +8,8 @@ from inspect import currentframe
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
@@ -41,6 +43,7 @@ from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
from authentik.root.ws.consumer import build_user_group
|
||||
from authentik.stages.email.models import EmailTemplates
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.tasks.models import TasksModel
|
||||
@@ -361,6 +364,15 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
notification=notification,
|
||||
)
|
||||
notification.save()
|
||||
layer = get_channel_layer()
|
||||
async_to_sync(layer.group_send)(
|
||||
build_user_group(notification.user),
|
||||
{
|
||||
"type": "event.notification",
|
||||
"id": str(notification.pk),
|
||||
"data": notification.serializer(notification).data,
|
||||
},
|
||||
)
|
||||
return []
|
||||
|
||||
def send_webhook(self, notification: "Notification") -> list[str]:
|
||||
|
||||
@@ -84,7 +84,7 @@ class OutgoingSyncProvider(ScheduledModel, Model):
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_dispatch(self) -> None:
|
||||
for schedule in self.schedules:
|
||||
for schedule in self.schedules.all():
|
||||
schedule.send()
|
||||
|
||||
@property
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""authentik database utilities"""
|
||||
|
||||
import gc
|
||||
from collections.abc import Generator
|
||||
|
||||
from django.db import reset_queries
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Model, QuerySet
|
||||
|
||||
|
||||
def chunked_queryset(queryset: QuerySet, chunk_size: int = 1_000):
|
||||
def chunked_queryset[T: Model](queryset: QuerySet[T], chunk_size: int = 1_000) -> Generator[T]:
|
||||
if not queryset.exists():
|
||||
return []
|
||||
|
||||
def get_chunks(qs: QuerySet):
|
||||
def get_chunks(qs: QuerySet) -> Generator[QuerySet[T]]:
|
||||
qs = qs.order_by("pk")
|
||||
pks = qs.values_list("pk", flat=True)
|
||||
start_pk = pks[0]
|
||||
|
||||
@@ -86,7 +86,7 @@ class OutpostConfig:
|
||||
class OutpostModel(Model):
|
||||
"""Base model for providers that need more objects than just themselves"""
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
"""Return a list of all required objects"""
|
||||
return [self]
|
||||
|
||||
@@ -332,41 +332,35 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
|
||||
"""Create per-object and global permissions for outpost service-account"""
|
||||
# To ensure the user only has the correct permissions, we delete all of them and re-add
|
||||
# the ones the user needs
|
||||
with transaction.atomic():
|
||||
user.remove_all_perms_from_managed_role()
|
||||
for model_or_perm in self.get_required_objects():
|
||||
if isinstance(model_or_perm, models.Model):
|
||||
model_or_perm: models.Model
|
||||
code_name = (
|
||||
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
|
||||
)
|
||||
try:
|
||||
user.assign_perms_to_managed_role(code_name, model_or_perm)
|
||||
except (Permission.DoesNotExist, AttributeError) as exc:
|
||||
LOGGER.warning(
|
||||
"permission doesn't exist",
|
||||
code_name=code_name,
|
||||
user=user,
|
||||
model=model_or_perm,
|
||||
try:
|
||||
with transaction.atomic():
|
||||
user.remove_all_perms_from_managed_role()
|
||||
for model_or_perm in self.get_required_objects():
|
||||
if isinstance(model_or_perm, models.Model):
|
||||
code_name = (
|
||||
f"{model_or_perm._meta.app_label}.view_{model_or_perm._meta.model_name}"
|
||||
)
|
||||
Event.new(
|
||||
action=EventAction.SYSTEM_EXCEPTION,
|
||||
message=(
|
||||
"While setting the permissions for the service-account, a "
|
||||
"permission was not found: Check "
|
||||
"https://docs.goauthentik.io/troubleshooting/missing_permission"
|
||||
),
|
||||
).with_exception(exc).set_user(user).save()
|
||||
else:
|
||||
app_label, perm = model_or_perm.split(".")
|
||||
permission = Permission.objects.filter(
|
||||
codename=perm,
|
||||
content_type__app_label=app_label,
|
||||
)
|
||||
if not permission.exists():
|
||||
LOGGER.warning("permission doesn't exist", perm=model_or_perm)
|
||||
continue
|
||||
user.assign_perms_to_managed_role(permission.first())
|
||||
user.assign_perms_to_managed_role(code_name, model_or_perm)
|
||||
elif isinstance(model_or_perm, tuple):
|
||||
perm, obj = model_or_perm
|
||||
user.assign_perms_to_managed_role(perm, obj)
|
||||
else:
|
||||
user.assign_perms_to_managed_role(model_or_perm)
|
||||
except (Permission.DoesNotExist, AttributeError) as exc:
|
||||
LOGGER.warning(
|
||||
"permission doesn't exist",
|
||||
code_name=code_name,
|
||||
user=user,
|
||||
model=model_or_perm,
|
||||
)
|
||||
Event.new(
|
||||
action=EventAction.SYSTEM_EXCEPTION,
|
||||
message=(
|
||||
"While setting the permissions for the service-account, a "
|
||||
"permission was not found: Check "
|
||||
"https://docs.goauthentik.io/troubleshooting/missing_permission"
|
||||
),
|
||||
).with_exception(exc).set_user(user).save()
|
||||
LOGGER.debug(
|
||||
"Updated service account's permissions",
|
||||
obj_perms=user.get_all_obj_perms_on_managed_role(),
|
||||
@@ -431,7 +425,7 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
|
||||
Token.objects.filter(identifier=self.token_identifier).delete()
|
||||
return self.token
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
"""Get an iterator of all objects the user needs read access to"""
|
||||
objects: list[models.Model | str] = [
|
||||
self,
|
||||
@@ -445,7 +439,9 @@ class Outpost(ScheduledModel, SerializerModel, ManagedModel):
|
||||
if self.managed:
|
||||
for brand in Brand.objects.filter(web_certificate__isnull=False):
|
||||
objects.append(brand)
|
||||
objects.append(brand.web_certificate)
|
||||
objects.append(("view_certificatekeypair", brand.web_certificate))
|
||||
objects.append(("view_certificatekeypair_certificate", brand.web_certificate))
|
||||
objects.append(("view_certificatekeypair_key", brand.web_certificate))
|
||||
return objects
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
||||
@@ -51,10 +51,12 @@ class OutpostTests(TestCase):
|
||||
permissions = outpost.user.get_all_obj_perms_on_managed_role().order_by(
|
||||
"content_type__model"
|
||||
)
|
||||
self.assertEqual(len(permissions), 3)
|
||||
self.assertEqual(len(permissions), 5)
|
||||
self.assertEqual(permissions[0].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[1].object_pk, str(outpost.pk))
|
||||
self.assertEqual(permissions[2].object_pk, str(provider.pk))
|
||||
self.assertEqual(permissions[1].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[2].object_pk, str(keypair.pk))
|
||||
self.assertEqual(permissions[3].object_pk, str(outpost.pk))
|
||||
self.assertEqual(permissions[4].object_pk, str(provider.pk))
|
||||
|
||||
# Remove provider from outpost, user should only have access to outpost
|
||||
outpost.providers.remove(provider)
|
||||
|
||||
@@ -93,11 +93,13 @@ class LDAPProvider(OutpostModel, BackchannelProvider):
|
||||
def __str__(self):
|
||||
return f"LDAP Provider {self.name}"
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
required_models = [self, "authentik_core.view_user", "authentik_core.view_group"]
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
required = [self, "authentik_core.view_user", "authentik_core.view_group"]
|
||||
if self.certificate is not None:
|
||||
required_models.append(self.certificate)
|
||||
return required_models
|
||||
required.append(("view_certificatekeypair", self.certificate))
|
||||
required.append(("view_certificatekeypair_certificate", self.certificate))
|
||||
required.append(("view_certificatekeypair_key", self.certificate))
|
||||
return required
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("LDAP Provider")
|
||||
|
||||
@@ -179,11 +179,13 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
def __str__(self):
|
||||
return f"Proxy Provider {self.name}"
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
required_models = [self]
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
required = [self]
|
||||
if self.certificate is not None:
|
||||
required_models.append(self.certificate)
|
||||
return required_models
|
||||
required.append(("view_certificatekeypair", self.certificate))
|
||||
required.append(("view_certificatekeypair_certificate", self.certificate))
|
||||
required.append(("view_certificatekeypair_key", self.certificate))
|
||||
return required
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Proxy Provider")
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""proxy provider tests"""
|
||||
|
||||
from json import loads
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.models import Outpost, OutpostType
|
||||
from authentik.providers.oauth2.models import ClientTypes
|
||||
from authentik.providers.proxy.models import ProxyMode, ProxyProvider
|
||||
|
||||
@@ -127,3 +131,55 @@ class ProxyProviderTests(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
provider: ProxyProvider = ProxyProvider.objects.get(name=name)
|
||||
self.assertEqual(provider.client_type, ClientTypes.CONFIDENTIAL)
|
||||
|
||||
def test_sa_fetch(self):
|
||||
"""Test fetching the outpost config as the service account"""
|
||||
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
|
||||
provider = ProxyProvider.objects.create(name=generate_id())
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
outpost.providers.add(provider)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:proxyprovideroutpost-list"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
)
|
||||
body = loads(res.content)
|
||||
self.assertEqual(body["pagination"]["count"], 1)
|
||||
|
||||
def test_sa_perms_cert(self):
|
||||
"""Test permissions to access a configured certificate"""
|
||||
cert = create_test_cert()
|
||||
outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY)
|
||||
provider = ProxyProvider.objects.create(name=generate_id(), certificate=cert)
|
||||
Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
outpost.providers.add(provider)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:proxyprovideroutpost-list"),
|
||||
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
)
|
||||
body = loads(res.content)
|
||||
self.assertEqual(body["pagination"]["count"], 1)
|
||||
cert_id = body["results"][0]["certificate"]
|
||||
self.assertEqual(cert_id, str(cert.pk))
|
||||
|
||||
res = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-view-certificate",
|
||||
kwargs={
|
||||
"pk": cert_id,
|
||||
},
|
||||
),
|
||||
HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
# res = self.client.get(
|
||||
# reverse(
|
||||
# "authentik_api:certificatekeypair-view-private-key",
|
||||
# kwargs={
|
||||
# "pk": cert_id,
|
||||
# },
|
||||
# ),
|
||||
# HTTP_AUTHORIZATION=f"Bearer {outpost.token.key}",
|
||||
# )
|
||||
# self.assertEqual(res.status_code, 200)
|
||||
|
||||
@@ -64,10 +64,12 @@ class RadiusProvider(OutpostModel, Provider):
|
||||
|
||||
return RadiusProviderSerializer
|
||||
|
||||
def get_required_objects(self) -> Iterable[models.Model | str]:
|
||||
def get_required_objects(self) -> Iterable[models.Model | str | tuple[str, models.Model]]:
|
||||
required = [self, "authentik_stages_mtls.pass_outpost_certificate"]
|
||||
if self.certificate is not None:
|
||||
required.append(self.certificate)
|
||||
required.append(("view_certificatekeypair", self.certificate))
|
||||
required.append(("view_certificatekeypair_certificate", self.certificate))
|
||||
required.append(("view_certificatekeypair_key", self.certificate))
|
||||
return required
|
||||
|
||||
def __str__(self):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.http import Http404
|
||||
from django_filters.filters import AllValuesMultipleFilter, BooleanFilter
|
||||
from django_filters.filters import AllValuesMultipleFilter, BooleanFilter, CharFilter, NumberFilter
|
||||
from django_filters.filterset import FilterSet
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field
|
||||
@@ -22,7 +22,7 @@ from authentik.blueprints.api import ManagedSerializer
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.models import Role, get_permission_choices
|
||||
|
||||
@@ -65,15 +65,63 @@ class RoleSerializer(ManagedSerializer, ModelSerializer):
|
||||
|
||||
|
||||
class RoleFilterSet(FilterSet):
|
||||
"""Filter for PropertyMapping"""
|
||||
"""Filter for Role"""
|
||||
|
||||
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
|
||||
|
||||
managed__isnull = BooleanFilter(field_name="managed", lookup_expr="isnull")
|
||||
|
||||
inherited = BooleanFilter(
|
||||
method="filter_inherited",
|
||||
label="Include inherited roles (requires users or ak_groups filter)",
|
||||
)
|
||||
|
||||
users = extend_schema_field(OpenApiTypes.INT)(
|
||||
NumberFilter(
|
||||
method="filter_users",
|
||||
label="Filter by user (use with inherited=true for all roles)",
|
||||
)
|
||||
)
|
||||
|
||||
ak_groups = extend_schema_field(OpenApiTypes.UUID)(
|
||||
CharFilter(
|
||||
method="filter_ak_groups",
|
||||
label="Filter by group (use with inherited=true for all roles)",
|
||||
)
|
||||
)
|
||||
|
||||
def filter_inherited(self, queryset, name, value):
|
||||
"""This filter is handled by filter_users and filter_ak_groups"""
|
||||
return queryset
|
||||
|
||||
def filter_users(self, queryset, name, value):
|
||||
"""Filter roles by user, optionally including inherited roles"""
|
||||
user = User.objects.filter(pk=value).first()
|
||||
if not user:
|
||||
return queryset.none()
|
||||
|
||||
include_inherited = self.data.get("inherited", "").lower() == "true"
|
||||
if include_inherited:
|
||||
return user.all_roles()
|
||||
return queryset.filter(users=user)
|
||||
|
||||
def filter_ak_groups(self, queryset, name, value):
|
||||
"""Filter roles by group, optionally including inherited roles"""
|
||||
group = Group.objects.filter(pk=value).first()
|
||||
if not group:
|
||||
return queryset.none()
|
||||
|
||||
include_inherited = self.data.get("inherited", "").lower() == "true"
|
||||
if include_inherited:
|
||||
return group.all_roles()
|
||||
return queryset.filter(ak_groups=group)
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ["name", "users", "managed"]
|
||||
fields = [
|
||||
"name",
|
||||
"managed",
|
||||
]
|
||||
|
||||
|
||||
class RoleViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@@ -50,7 +50,7 @@ def get_user(scope):
|
||||
"Cannot find session in scope. You should wrap your consumer in SessionMiddleware."
|
||||
)
|
||||
user = None
|
||||
if (authenticated_session := scope["session"].get("authenticated_session", None)) is not None:
|
||||
if (authenticated_session := scope["session"].get("authenticatedsession", None)) is not None:
|
||||
user = authenticated_session.user
|
||||
return user or AnonymousUser()
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
def add_arguments(cls, parser: ArgumentParser):
|
||||
"""Add more pytest-specific arguments"""
|
||||
DiscoverRunner.add_arguments(parser)
|
||||
default_seed = None
|
||||
if seed := os.getenv("CI_TEST_SEED"):
|
||||
default_seed = int(seed)
|
||||
parser.add_argument(
|
||||
"--randomly-seed",
|
||||
type=int,
|
||||
@@ -103,6 +106,7 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
"to reuse the seed from the previous run."
|
||||
"Default behaviour: use random.Random().getrandbits(32), so the seed is"
|
||||
"different on each run.",
|
||||
default=default_seed,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-capture",
|
||||
|
||||
0
authentik/root/tests/__init__.py
Normal file
0
authentik/root/tests/__init__.py
Normal file
115
authentik/root/tests/test_ws_client.py
Normal file
115
authentik/root/tests/test_ws_client.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from asgiref.sync import sync_to_async
|
||||
from channels.routing import URLRouter
|
||||
from channels.testing import WebsocketCommunicator
|
||||
from django.http import HttpRequest
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
EventAction,
|
||||
Notification,
|
||||
NotificationTransport,
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.root import websocket
|
||||
from authentik.stages.password import BACKEND_INBUILT
|
||||
from authentik.stages.user_login.stage import COOKIE_NAME_KNOWN_DEVICE
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
class TestClientWS(TransactionTestCase):
|
||||
|
||||
def setUp(self):
|
||||
tenant = get_current_tenant()
|
||||
tenant.flags[RefreshOtherFlowsAfterAuthentication().key] = True
|
||||
tenant.save()
|
||||
self.user = create_test_user()
|
||||
|
||||
async def _alogin_cookie(self, user, **kwargs):
|
||||
"""Similar to `client.aforce_login` but allow setting of cookies"""
|
||||
from django.contrib.auth import alogin
|
||||
|
||||
# Create a fake request to store login details.
|
||||
request = HttpRequest()
|
||||
session = await self.client.asession()
|
||||
request.session = session
|
||||
request.COOKIES.update(kwargs)
|
||||
|
||||
await alogin(request, user, BACKEND_INBUILT)
|
||||
# Save the session values.
|
||||
await request.session.asave()
|
||||
self.client._set_login_cookies(request)
|
||||
|
||||
async def test_auth_blank(self):
|
||||
dev_id = generate_id()
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns),
|
||||
"/ws/client/",
|
||||
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
|
||||
|
||||
await communicator.receive_nothing()
|
||||
await communicator.receive_json_from()
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_tab_refresh(self):
|
||||
dev_id = generate_id()
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns),
|
||||
"/ws/client/",
|
||||
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
with patch("authentik.flows.apps.RefreshOtherFlowsAfterAuthentication.get") as flag:
|
||||
flag.return_value = True
|
||||
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
|
||||
|
||||
evt = await communicator.receive_json_from()
|
||||
self.assertEqual(
|
||||
evt, {"message_type": "session.authenticated", "type": "event.session.authenticated"}
|
||||
)
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_notification(self):
|
||||
communicator = WebsocketCommunicator(
|
||||
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
|
||||
)
|
||||
communicator.scope["user"] = self.user
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
transport = await NotificationTransport.objects.acreate(
|
||||
name=generate_id(), mode=TransportMode.LOCAL
|
||||
)
|
||||
event = await sync_to_async(Event.new)(EventAction.LOGIN)
|
||||
event.set_user(self.user)
|
||||
await event.asave()
|
||||
notification = Notification(
|
||||
user=self.user,
|
||||
body="foo",
|
||||
event=event,
|
||||
hyperlink="goauthentik.io",
|
||||
hyperlink_label="a link",
|
||||
)
|
||||
await sync_to_async(transport.send_local)(notification)
|
||||
|
||||
evt = await communicator.receive_json_from(timeout=5)
|
||||
self.assertEqual(evt["message_type"], "notification.new")
|
||||
self.assertEqual(evt["id"], str(notification.pk))
|
||||
self.assertEqual(evt["data"]["pk"], str(notification.pk))
|
||||
self.assertEqual(evt["data"]["body"], "foo")
|
||||
self.assertEqual(evt["data"]["event"]["pk"], str(event.pk))
|
||||
|
||||
await communicator.disconnect()
|
||||
@@ -7,6 +7,7 @@ from channels.generic.websocket import JsonWebsocketConsumer
|
||||
from django.core.cache import cache
|
||||
from django.db import connection
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.root.ws.storage import CACHE_PREFIX
|
||||
|
||||
|
||||
@@ -16,24 +17,34 @@ def build_session_group(session_key: str):
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def build_device_group(session_key: str):
|
||||
def build_device_group(device_id: str):
|
||||
return sha256(
|
||||
f"{connection.schema_name}/group_client_device_{str(session_key)}".encode()
|
||||
f"{connection.schema_name}/group_client_device_{str(device_id)}".encode()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def build_user_group(user: User):
|
||||
return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest()
|
||||
|
||||
|
||||
class MessageConsumer(JsonWebsocketConsumer):
|
||||
"""Consumer which sends django.contrib.messages Messages over WS.
|
||||
channel_name is saved into cache with user_id, and when a add_message is called"""
|
||||
|
||||
session_key: str
|
||||
device_cookie: str | None = None
|
||||
user: User | None = None
|
||||
|
||||
def connect(self):
|
||||
self.accept()
|
||||
self.session_key = self.scope["session"].session_key
|
||||
if self.session_key:
|
||||
cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None)
|
||||
if user := self.scope.get("user"):
|
||||
if user.is_authenticated:
|
||||
async_to_sync(self.channel_layer.group_add)(
|
||||
build_user_group(user), self.channel_name
|
||||
)
|
||||
if device_cookie := self.scope["cookies"].get("authentik_device", None):
|
||||
self.device_cookie = device_cookie
|
||||
async_to_sync(self.channel_layer.group_add)(
|
||||
@@ -47,6 +58,10 @@ class MessageConsumer(JsonWebsocketConsumer):
|
||||
async_to_sync(self.channel_layer.group_discard)(
|
||||
build_device_group(self.device_cookie), self.channel_name
|
||||
)
|
||||
if self.user:
|
||||
async_to_sync(self.channel_layer.group_discard)(
|
||||
build_user_group(self.user), self.channel_name
|
||||
)
|
||||
|
||||
def event_message(self, event: dict):
|
||||
"""Event handler which is called by Messages Storage backend"""
|
||||
@@ -54,4 +69,8 @@ class MessageConsumer(JsonWebsocketConsumer):
|
||||
|
||||
def event_session_authenticated(self, event: dict):
|
||||
"""Event handler post user authentication"""
|
||||
self.send_json({"message_type": "session.authenticated"})
|
||||
self.send_json({"message_type": "session.authenticated", **event})
|
||||
|
||||
def event_notification(self, event: dict):
|
||||
"""Event handler for new notifications"""
|
||||
self.send_json({"message_type": "notification.new", **event})
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.http.request import QueryDict
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField, CharField, IntegerField
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.challenge import (
|
||||
@@ -47,7 +47,7 @@ class AuthenticatorEmailChallengeResponse(ChallengeResponse):
|
||||
|
||||
device: EmailDevice
|
||||
|
||||
code = IntegerField(required=False)
|
||||
code = CharField(required=False)
|
||||
email = CharField(required=False)
|
||||
|
||||
component = CharField(default="ak-stage-authenticator-email")
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import BooleanField, CharField, IntegerField
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
|
||||
from authentik.flows.challenge import (
|
||||
Challenge,
|
||||
@@ -38,7 +38,7 @@ class AuthenticatorSMSChallengeResponse(ChallengeResponse):
|
||||
|
||||
device: SMSDevice
|
||||
|
||||
code = IntegerField(required=False)
|
||||
code = CharField(required=False)
|
||||
phone_number = CharField(required=False)
|
||||
|
||||
component = CharField(default="ak-stage-authenticator-sms")
|
||||
|
||||
@@ -5,7 +5,7 @@ from urllib.parse import quote
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.request import QueryDict
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from authentik.flows.challenge import (
|
||||
@@ -32,10 +32,10 @@ class AuthenticatorTOTPChallengeResponse(ChallengeResponse):
|
||||
|
||||
device: TOTPDevice
|
||||
|
||||
code = IntegerField()
|
||||
code = CharField()
|
||||
component = CharField(default="ak-stage-authenticator-totp")
|
||||
|
||||
def validate_code(self, code: int) -> int:
|
||||
def validate_code(self, code: str) -> str:
|
||||
"""Validate totp code"""
|
||||
if not self.device:
|
||||
raise ValidationError(_("Code does not match"))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""Identification stage logic"""
|
||||
|
||||
from dataclasses import asdict
|
||||
from random import SystemRandom
|
||||
from time import sleep
|
||||
from typing import Any
|
||||
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse
|
||||
@@ -161,8 +160,8 @@ class IdentificationChallengeResponse(ChallengeResponse):
|
||||
op="authentik.stages.identification.validate_invalid_wait",
|
||||
name="Sleep random time on invalid user identifier",
|
||||
):
|
||||
# Sleep a random time (between 90 and 210ms) to "prevent" user enumeration attacks
|
||||
sleep(0.030 * SystemRandom().randint(3, 7))
|
||||
# hash a random password on invalid identifier, same as with a valid identifier
|
||||
make_password(make_password(None))
|
||||
# Log in a similar format to Event.new(), but we don't want to create an event here
|
||||
# as this stage is mostly used by unauthenticated users with very high rate limits
|
||||
self.stage.logger.info(
|
||||
|
||||
@@ -245,7 +245,10 @@ class WorkerStatusMiddleware(Middleware):
|
||||
WorkerStatusMiddleware.keep(status)
|
||||
except DB_ERRORS: # pragma: no cover
|
||||
sleep(10)
|
||||
pass
|
||||
try:
|
||||
connections.close_all()
|
||||
except DB_ERRORS:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def keep(status: WorkerStatus):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2025.12.0-rc1 Blueprint schema",
|
||||
"title": "authentik 2025.12.0-rc3 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@@ -6276,6 +6276,11 @@
|
||||
],
|
||||
"format": "date-time",
|
||||
"title": "Expires"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Key"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -14510,7 +14515,8 @@
|
||||
"description": "Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password."
|
||||
},
|
||||
"webauthn_stage": {
|
||||
"type": "integer",
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Webauthn stage",
|
||||
"description": "When set, and conditional WebAuthn is available, allow the user to use their passkey as a first factor."
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc3}
|
||||
ports:
|
||||
- ${COMPOSE_PORT_HTTP:-9000}:9000
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./media:/data/media
|
||||
- ./data:/data
|
||||
- ./custom-templates:/templates
|
||||
worker:
|
||||
command: worker
|
||||
@@ -52,12 +52,12 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc3}
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./media:/data/media
|
||||
- ./data:/data
|
||||
- ./certs:/certs
|
||||
- ./custom-templates:/templates
|
||||
volumes:
|
||||
|
||||
4
go.mod
4
go.mod
@@ -1,8 +1,6 @@
|
||||
module goauthentik.io
|
||||
|
||||
go 1.24.3
|
||||
|
||||
toolchain go1.24.6
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
beryju.io/ldap v0.1.0
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025.12.0-rc1
|
||||
2025.12.0-rc3
|
||||
@@ -165,7 +165,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
|
||||
for _, u := range g.UsersObj {
|
||||
if flag.UserPk == u.Pk {
|
||||
// TODO: Is there a better way to clone this object?
|
||||
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, []api.RelatedGroup{}, []api.PartialUser{u}, []api.Role{}, []string{}, []api.RelatedGroup{})
|
||||
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, []api.RelatedGroup{}, []api.PartialUser{u}, []api.Role{}, nil, []string{}, []api.RelatedGroup{})
|
||||
fg.SetUsers([]int32{flag.UserPk})
|
||||
fg.SetAttributes(g.Attributes)
|
||||
fg.SetIsSuperuser(*g.IsSuperuser)
|
||||
|
||||
@@ -234,8 +234,8 @@ func NewPostgresStore(log *log.Entry) (*PostgresStore, error) {
|
||||
}
|
||||
|
||||
// Determine connection pool settings
|
||||
maxIdleConns := 10
|
||||
maxOpenConns := 100
|
||||
maxIdleConns := 4
|
||||
maxOpenConns := 4
|
||||
var connMaxLifetime time.Duration
|
||||
if cfg.ConnMaxAge > 0 {
|
||||
connMaxLifetime = time.Duration(cfg.ConnMaxAge) * time.Second
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
package utils
|
||||
|
||||
import "crypto/tls"
|
||||
import (
|
||||
"crypto/tls"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func GetTLSConfig() *tls.Config {
|
||||
// Based on
|
||||
// https://ssl-config.mozilla.org/#server=go&version=1.25&config=intermediate&guideline=5.7
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
MaxVersion: tls.VersionTLS12,
|
||||
CurvePreferences: []tls.CurveID{
|
||||
tls.X25519,
|
||||
tls.CurveP256,
|
||||
tls.CurveP384,
|
||||
},
|
||||
PreferServerCipherSuites: true,
|
||||
CipherSuites: []uint16{},
|
||||
}
|
||||
|
||||
// Insecure SWEET32 attack ciphers, TLS config uses a fallback
|
||||
insecureCiphersIds := []uint16{
|
||||
excludedCiphers := []uint16{
|
||||
// ChaCha20 is not FIPS validated
|
||||
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
|
||||
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
|
||||
// Insecure SWEET32 attack ciphers, TLS config uses a fallback
|
||||
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,
|
||||
tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
|
||||
}
|
||||
|
||||
defaultSecureCiphers := []uint16{}
|
||||
for _, cs := range tls.CipherSuites() {
|
||||
for _, icsId := range insecureCiphersIds {
|
||||
if cs.ID != icsId {
|
||||
defaultSecureCiphers = append(defaultSecureCiphers, cs.ID)
|
||||
}
|
||||
if slices.Contains(excludedCiphers, cs.ID) {
|
||||
continue
|
||||
}
|
||||
defaultSecureCiphers = append(defaultSecureCiphers, cs.ID)
|
||||
}
|
||||
tlsConfig.CipherSuites = defaultSecureCiphers
|
||||
return tlsConfig
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-http-utils/etag"
|
||||
@@ -17,11 +18,44 @@ import (
|
||||
staticWeb "goauthentik.io/web"
|
||||
)
|
||||
|
||||
// Theme variable placeholder that can be used in file paths
|
||||
// This allows for theme-specific files like logo-%(theme)s.png
|
||||
const themeVariable = "%(theme)s"
|
||||
|
||||
// Valid themes that can be substituted for %(theme)s
|
||||
var validThemes = []string{"light", "dark"}
|
||||
|
||||
type StorageClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// pathMatchesWithTheme checks if the requested path matches the JWT path,
|
||||
// accounting for theme variable substitution.
|
||||
// If the JWT path contains %(theme)s, it will match the requested path
|
||||
// if substituting %(theme)s with any valid theme produces the requested path.
|
||||
func pathMatchesWithTheme(jwtPath, requestedPath string) bool {
|
||||
// Direct match (no theme variable)
|
||||
if jwtPath == requestedPath {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if JWT path contains theme variable
|
||||
if !strings.Contains(jwtPath, themeVariable) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try substituting each valid theme and check for a match
|
||||
for _, theme := range validThemes {
|
||||
substituted := strings.ReplaceAll(jwtPath, themeVariable, theme)
|
||||
if substituted == requestedPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func storageTokenIsValid(usage string, r *http.Request) bool {
|
||||
tokenString := r.URL.Query().Get("token")
|
||||
if tokenString == "" {
|
||||
@@ -51,11 +85,8 @@ func storageTokenIsValid(usage string, r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if claims.Path != fmt.Sprintf("%s/%s", usage, r.URL.Path) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
requestedPath := fmt.Sprintf("%s/%s", usage, r.URL.Path)
|
||||
return pathMatchesWithTheme(claims.Path, requestedPath)
|
||||
}
|
||||
|
||||
func (ws *WebServer) configureStatic() {
|
||||
|
||||
95
internal/web/static_test.go
Normal file
95
internal/web/static_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPathMatchesWithTheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jwtPath string
|
||||
requestedPath string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match without theme variable",
|
||||
jwtPath: "media/public/logo.png",
|
||||
requestedPath: "media/public/logo.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match without theme variable",
|
||||
jwtPath: "media/public/logo.png",
|
||||
requestedPath: "media/public/other.png",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "theme variable matches light theme",
|
||||
jwtPath: "media/public/logo-%(theme)s.png",
|
||||
requestedPath: "media/public/logo-light.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "theme variable matches dark theme",
|
||||
jwtPath: "media/public/logo-%(theme)s.png",
|
||||
requestedPath: "media/public/logo-dark.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "theme variable does not match invalid theme",
|
||||
jwtPath: "media/public/logo-%(theme)s.png",
|
||||
requestedPath: "media/public/logo-blue.png",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "theme variable in directory path",
|
||||
jwtPath: "media/%(theme)s/logo.png",
|
||||
requestedPath: "media/light/logo.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "multiple theme variables",
|
||||
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
|
||||
requestedPath: "media/light/logo-light.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "multiple theme variables with dark",
|
||||
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
|
||||
requestedPath: "media/dark/logo-dark.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "multiple theme variables mixed themes should not match",
|
||||
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
|
||||
requestedPath: "media/light/logo-dark.png",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "theme variable with nested path",
|
||||
jwtPath: "media/public/brand/logo-%(theme)s.svg",
|
||||
requestedPath: "media/public/brand/logo-dark.svg",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty paths",
|
||||
jwtPath: "",
|
||||
requestedPath: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "theme variable only",
|
||||
jwtPath: "%(theme)s",
|
||||
requestedPath: "light",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := pathMatchesWithTheme(tt.jwtPath, tt.requestedPath)
|
||||
if got != tt.want {
|
||||
t.Errorf("pathMatchesWithTheme(%q, %q) = %v, want %v",
|
||||
tt.jwtPath, tt.requestedPath, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2025.12.0-rc1
|
||||
Default: 2025.12.0-rc3
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
||||
@@ -30,10 +30,11 @@ class BaseMigration:
|
||||
def __init__(self, cur: Any, con: Any):
|
||||
self.cur = cur
|
||||
self.con = con
|
||||
self.log = get_logger().bind()
|
||||
|
||||
def system_crit(self, command: str):
|
||||
"""Run system command"""
|
||||
LOGGER.debug("Running system_crit command", command=command)
|
||||
self.log.debug("Running system_crit command", command=command)
|
||||
retval = system(command) # nosec
|
||||
if retval != 0:
|
||||
raise CommandError("Migration error")
|
||||
@@ -73,6 +74,7 @@ def release_lock(conn: Connection, cursor: Cursor):
|
||||
|
||||
|
||||
def run_migrations():
|
||||
conn_opts = CONFIG.get_dict_from_b64_json("postgresql.conn_options", default={})
|
||||
conn = connect(
|
||||
dbname=CONFIG.get("postgresql.name"),
|
||||
user=CONFIG.get("postgresql.user"),
|
||||
@@ -83,6 +85,7 @@ def run_migrations():
|
||||
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
|
||||
sslcert=CONFIG.get("postgresql.sslcert"),
|
||||
sslkey=CONFIG.get("postgresql.sslkey"),
|
||||
**conn_opts,
|
||||
)
|
||||
curr = conn.cursor()
|
||||
try:
|
||||
|
||||
41
lifecycle/system_migrations/to_2025_12_group_duplicate.py
Normal file
41
lifecycle/system_migrations/to_2025_12_group_duplicate.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# flake8: noqa
|
||||
from lifecycle.migrate import BaseMigration
|
||||
|
||||
SQL_STATEMENT = """
|
||||
SELECT "authentik_core_group"."name" AS "name",
|
||||
Count("authentik_core_group"."name") AS "name__count"
|
||||
FROM "authentik_core_group" GROUP BY 1
|
||||
HAVING Count("authentik_core_group"."name") > 1
|
||||
ORDER BY 2 DESC,
|
||||
1 ASC
|
||||
"""
|
||||
|
||||
|
||||
class DuplicateNameError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(BaseMigration):
|
||||
def needs_migration(self) -> bool:
|
||||
self.cur.execute(
|
||||
"select 1 from information_schema.tables where table_name = 'django_migrations';"
|
||||
)
|
||||
if not bool(self.cur.rowcount):
|
||||
# No django_migrations table, no data to check
|
||||
return False
|
||||
# migration that introduces the uniqueness
|
||||
self.cur.execute(
|
||||
"select 1 from django_migrations where app = 'authentik_core' and name = '0056_user_roles';"
|
||||
)
|
||||
return not bool(self.cur.rowcount)
|
||||
|
||||
def run(self):
|
||||
rows = self.cur.execute(SQL_STATEMENT).fetchall()
|
||||
if len(rows):
|
||||
for row in rows:
|
||||
self.log.error(
|
||||
"Group with duplicate name detected", group_name=row[0], count=row[1]
|
||||
)
|
||||
raise DuplicateNameError(
|
||||
f"authentik 2025.12 forbids duplicate group names. For a list of duplicate groups, see logging output above. Please rename the offending groups and re-run the migration. For more information, see: https://version-2025-12.goauthentik.io/releases/2025.12/#group-name-uniqueness"
|
||||
)
|
||||
@@ -18,6 +18,7 @@ def check_postgres():
|
||||
if attempt >= CHECK_THRESHOLD:
|
||||
sysexit(1)
|
||||
try:
|
||||
conn_opts = CONFIG.get_dict_from_b64_json("postgresql.conn_options", default={})
|
||||
conn = connect(
|
||||
dbname=CONFIG.refresh("postgresql.name"),
|
||||
user=CONFIG.refresh("postgresql.user"),
|
||||
@@ -28,6 +29,7 @@ def check_postgres():
|
||||
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
|
||||
sslcert=CONFIG.get("postgresql.sslcert"),
|
||||
sslkey=CONFIG.get("postgresql.sslkey"),
|
||||
**conn_opts,
|
||||
)
|
||||
conn.cursor()
|
||||
break
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc3",
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@goauthentik/eslint-config": "./packages/eslint-config",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
"""Convenient shortcuts to manage or check object permissions."""
|
||||
|
||||
from functools import lru_cache, partial
|
||||
from functools import lru_cache
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import connection
|
||||
from django.db.models import (
|
||||
AutoField,
|
||||
BigIntegerField,
|
||||
CharField,
|
||||
Count,
|
||||
ForeignKey,
|
||||
IntegerField,
|
||||
Model,
|
||||
PositiveIntegerField,
|
||||
PositiveSmallIntegerField,
|
||||
QuerySet,
|
||||
SmallIntegerField,
|
||||
UUIDField,
|
||||
)
|
||||
from django.db.models.expressions import Value
|
||||
from django.db.models.functions import Cast, Replace
|
||||
from django.db.models.expressions import RawSQL
|
||||
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.ctypes import get_content_type
|
||||
@@ -295,42 +286,33 @@ def get_objects_for_user( # noqa: PLR0912 PLR0915
|
||||
.filter(object_pk_count__gte=len(codenames))
|
||||
)
|
||||
|
||||
# object_pk is a varchar, while the queryset's pk is probably an integer or a uuid, so we cast
|
||||
handle_pk_field = _handle_pk_field(queryset)
|
||||
if handle_pk_field is not None:
|
||||
perms_queryset = perms_queryset.annotate(obj_pk=handle_pk_field(expression=pk_field))
|
||||
pk_field = "obj_pk"
|
||||
|
||||
return queryset.filter(pk__in=perms_queryset.values_list(pk_field, flat=True))
|
||||
|
||||
|
||||
def _handle_pk_field(queryset):
|
||||
# pk is either UUID or an integer type, while object_pk is a varchar
|
||||
pk = queryset.model._meta.pk
|
||||
|
||||
if isinstance(pk, ForeignKey):
|
||||
return _handle_pk_field(pk.target_field)
|
||||
def _cast_type(pk):
|
||||
if isinstance(pk, ForeignKey):
|
||||
return _cast_type(pk.target_field)
|
||||
if isinstance(pk, UUIDField):
|
||||
return "uuid"
|
||||
return "bigint"
|
||||
|
||||
if isinstance( # noqa: UP038
|
||||
pk,
|
||||
(
|
||||
IntegerField,
|
||||
AutoField,
|
||||
BigIntegerField,
|
||||
PositiveIntegerField,
|
||||
PositiveSmallIntegerField,
|
||||
SmallIntegerField,
|
||||
),
|
||||
):
|
||||
return partial(Cast, output_field=BigIntegerField())
|
||||
cast_type = _cast_type(pk)
|
||||
|
||||
if isinstance(pk, UUIDField):
|
||||
if connection.features.has_native_uuid_field:
|
||||
return partial(Cast, output_field=UUIDField())
|
||||
return partial(
|
||||
Replace,
|
||||
text=Value("-"),
|
||||
replacement=Value(""),
|
||||
output_field=CharField(),
|
||||
)
|
||||
|
||||
return None
|
||||
perms_queryset = perms_queryset.values_list(pk_field, flat=True)
|
||||
# The raw subquery is done to ensure that casting only takes place after the WHERE clause of
|
||||
# `perms_queryset` is ran. Otherwise, the query planner may decide to cast every `object_pk`,
|
||||
# which breaks (for example) if it tries to cast an integer to a UUID. In such a case, the WHERE
|
||||
# of `perms_queryset` will remove any integer.
|
||||
# However, the subquery might get optimized out by the query planner, which would cause the same
|
||||
# cast issue as before. To prevent the subquery from being collapsed in the query below, we add
|
||||
# OFFSET 0.
|
||||
perms_subquery_sql, perms_subquery_params = perms_queryset.query.sql_with_params()
|
||||
subquery = RawSQL(
|
||||
f"""
|
||||
SELECT ("permission_subquery"."{pk_field}")::{cast_type} as "object_pk"
|
||||
FROM ({perms_subquery_sql}) "permission_subquery"
|
||||
OFFSET 0
|
||||
""", # nosec
|
||||
perms_subquery_params,
|
||||
)
|
||||
return queryset.filter(pk__in=subquery)
|
||||
|
||||
@@ -529,3 +529,7 @@ class _PostgresConsumer(Consumer):
|
||||
conn.close()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
try:
|
||||
connections.close_all()
|
||||
except DATABASE_ERRORS:
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "authentik"
|
||||
version = "2025.12.0-rc1"
|
||||
version = "2025.12.0-rc3"
|
||||
description = ""
|
||||
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
||||
requires-python = "==3.13.*"
|
||||
|
||||
67
schema.yml
67
schema.yml
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2025.12.0-rc1
|
||||
version: 2025.12.0-rc3
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@@ -3385,6 +3385,11 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
- in: query
|
||||
name: include_inherited_roles
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
- in: query
|
||||
name: include_parents
|
||||
schema:
|
||||
@@ -3478,6 +3483,11 @@ paths:
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
- in: query
|
||||
name: include_inherited_roles
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
- in: query
|
||||
name: include_parents
|
||||
schema:
|
||||
@@ -20052,6 +20062,16 @@ paths:
|
||||
operationId: rbac_roles_list
|
||||
description: Role viewset
|
||||
parameters:
|
||||
- in: query
|
||||
name: ak_groups
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: inherited
|
||||
schema:
|
||||
type: boolean
|
||||
description: Include inherited roles (requires users or ak_groups filter)
|
||||
- in: query
|
||||
name: managed
|
||||
schema:
|
||||
@@ -20072,11 +20092,7 @@ paths:
|
||||
- in: query
|
||||
name: users
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: integer
|
||||
explode: true
|
||||
style: form
|
||||
type: integer
|
||||
tags:
|
||||
- rbac
|
||||
security:
|
||||
@@ -33586,7 +33602,8 @@ components:
|
||||
minLength: 1
|
||||
default: ak-stage-authenticator-email
|
||||
code:
|
||||
type: integer
|
||||
type: string
|
||||
minLength: 1
|
||||
email:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -33833,7 +33850,8 @@ components:
|
||||
minLength: 1
|
||||
default: ak-stage-authenticator-sms
|
||||
code:
|
||||
type: integer
|
||||
type: string
|
||||
minLength: 1
|
||||
phone_number:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -34107,7 +34125,8 @@ components:
|
||||
minLength: 1
|
||||
default: ak-stage-authenticator-totp
|
||||
code:
|
||||
type: integer
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- code
|
||||
AuthenticatorTOTPStage:
|
||||
@@ -34794,6 +34813,7 @@ components:
|
||||
CapabilitiesEnum:
|
||||
enum:
|
||||
- can_save_media
|
||||
- can_save_reports
|
||||
- can_geo_ip
|
||||
- can_asn
|
||||
- can_impersonate
|
||||
@@ -35388,10 +35408,14 @@ components:
|
||||
model:
|
||||
type: string
|
||||
readOnly: true
|
||||
verbose_name_plural:
|
||||
type: string
|
||||
readOnly: true
|
||||
required:
|
||||
- app_label
|
||||
- id
|
||||
- model
|
||||
- verbose_name_plural
|
||||
ContextualFlowInfo:
|
||||
type: object
|
||||
description: Contextual flow information for a challenge
|
||||
@@ -35739,7 +35763,7 @@ components:
|
||||
readOnly: true
|
||||
requested_by:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/RequestedBy'
|
||||
- $ref: '#/components/schemas/PartialUser'
|
||||
readOnly: true
|
||||
requested_on:
|
||||
type: string
|
||||
@@ -38766,6 +38790,12 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/Role'
|
||||
readOnly: true
|
||||
inherited_roles_obj:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Role'
|
||||
readOnly: true
|
||||
nullable: true
|
||||
children:
|
||||
type: array
|
||||
items:
|
||||
@@ -38781,6 +38811,7 @@ components:
|
||||
required:
|
||||
- children
|
||||
- children_obj
|
||||
- inherited_roles_obj
|
||||
- name
|
||||
- num_pk
|
||||
- parents_obj
|
||||
@@ -51253,22 +51284,6 @@ components:
|
||||
minimum: -2147483648
|
||||
required:
|
||||
- name
|
||||
RequestedBy:
|
||||
type: object
|
||||
properties:
|
||||
pk:
|
||||
type: integer
|
||||
readOnly: true
|
||||
title: ID
|
||||
username:
|
||||
type: string
|
||||
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
|
||||
only.
|
||||
pattern: ^[\w.@+-]+$
|
||||
maxLength: 150
|
||||
required:
|
||||
- pk
|
||||
- username
|
||||
ResidentKeyRequirementEnum:
|
||||
enum:
|
||||
- discouraged
|
||||
|
||||
6
scripts/generate_docker_compose.py
Normal file → Executable file
6
scripts/generate_docker_compose.py
Normal file → Executable file
@@ -1,3 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from yaml import safe_dump
|
||||
|
||||
from authentik import authentik_version
|
||||
@@ -42,7 +44,7 @@ base = {
|
||||
"image": authentik_image,
|
||||
"ports": ["${COMPOSE_PORT_HTTP:-9000}:9000", "${COMPOSE_PORT_HTTPS:-9443}:9443"],
|
||||
"restart": "unless-stopped",
|
||||
"volumes": ["./media:/data/media", "./custom-templates:/templates"],
|
||||
"volumes": ["./data:/data", "./custom-templates:/templates"],
|
||||
},
|
||||
"worker": {
|
||||
"command": "worker",
|
||||
@@ -62,7 +64,7 @@ base = {
|
||||
"user": "root",
|
||||
"volumes": [
|
||||
"/var/run/docker.sock:/var/run/docker.sock",
|
||||
"./media:/data/media",
|
||||
"./data:/data",
|
||||
"./certs:/certs",
|
||||
"./custom-templates:/templates",
|
||||
],
|
||||
|
||||
@@ -89,26 +89,7 @@ class TestSourceOAuth2(SeleniumTestCase):
|
||||
|
||||
interface = self.driver.find_element(By.CSS_SELECTOR, "ak-interface-user").shadow_root
|
||||
|
||||
interface_wait = WebDriverWait(interface, INTERFACE_TIMEOUT)
|
||||
|
||||
try:
|
||||
interface_wait.until(
|
||||
ec.presence_of_element_located((By.CSS_SELECTOR, "ak-interface-user-presentation"))
|
||||
)
|
||||
except TimeoutException:
|
||||
snippet = context.text.strip()[:1000].replace("\n", " ")
|
||||
self.fail(
|
||||
f"Timed out waiting for element text to appear at {self.driver.current_url}. "
|
||||
f"Current content: {snippet or '<empty>'}"
|
||||
)
|
||||
|
||||
interface_presentation = interface.find_element(
|
||||
By.CSS_SELECTOR, "ak-interface-user-presentation"
|
||||
).shadow_root
|
||||
|
||||
user_settings = interface_presentation.find_element(
|
||||
By.CSS_SELECTOR, "ak-user-settings"
|
||||
).shadow_root
|
||||
user_settings = interface.find_element(By.CSS_SELECTOR, "ak-user-settings").shadow_root
|
||||
|
||||
tab_panel = user_settings.find_element(By.CSS_SELECTOR, panel_content_selector).shadow_root
|
||||
|
||||
|
||||
@@ -23,8 +23,10 @@ from docker.models.containers import Container
|
||||
from docker.models.networks import Network
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import (
|
||||
DetachedShadowRootException,
|
||||
NoSuchElementException,
|
||||
NoSuchShadowRootException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
WebDriverException,
|
||||
)
|
||||
@@ -247,36 +249,60 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
Raises a clear test failure if the element isn't found, the text doesn't appear
|
||||
within `timeout` seconds, or the text is not valid JSON.
|
||||
"""
|
||||
use_body = context is None
|
||||
wait_timeout = timeout or self.wait_timeout
|
||||
|
||||
def get_context() -> WebElement:
|
||||
"""Get or refresh the context element."""
|
||||
if use_body:
|
||||
return self.driver.find_element(By.TAG_NAME, "body")
|
||||
return context
|
||||
|
||||
def get_text_safely() -> str:
|
||||
"""Get element text, re-finding element if stale."""
|
||||
for _ in range(5):
|
||||
try:
|
||||
return get_context().text.strip()
|
||||
except StaleElementReferenceException:
|
||||
sleep(0.5)
|
||||
return get_context().text.strip()
|
||||
|
||||
def get_inner_html_safely() -> str:
|
||||
"""Get innerHTML, re-finding element if stale."""
|
||||
for _ in range(5):
|
||||
try:
|
||||
return get_context().get_attribute("innerHTML") or ""
|
||||
except StaleElementReferenceException:
|
||||
sleep(0.5)
|
||||
return get_context().get_attribute("innerHTML") or ""
|
||||
|
||||
try:
|
||||
if context is None:
|
||||
context = self.driver.find_element(By.TAG_NAME, "body")
|
||||
get_context()
|
||||
except NoSuchElementException:
|
||||
self.fail(
|
||||
f"No element found (defaulted to <body>). Current URL: {self.driver.current_url}"
|
||||
)
|
||||
|
||||
wait_timeout = timeout or self.wait_timeout
|
||||
wait = WebDriverWait(context, wait_timeout)
|
||||
wait = WebDriverWait(self.driver, wait_timeout)
|
||||
|
||||
try:
|
||||
wait.until(lambda d: len(d.text.strip()) != 0)
|
||||
wait.until(lambda d: len(get_text_safely()) != 0)
|
||||
except TimeoutException:
|
||||
snippet = context.text.strip()[:500].replace("\n", " ")
|
||||
snippet = get_text_safely()[:500].replace("\n", " ")
|
||||
self.fail(
|
||||
f"Timed out waiting for element text to appear at {self.driver.current_url}. "
|
||||
f"Current content: {snippet or '<empty>'}"
|
||||
)
|
||||
|
||||
body_text = context.text.strip()
|
||||
inner_html = context.get_attribute("innerHTML") or ""
|
||||
body_text = get_text_safely()
|
||||
inner_html = get_inner_html_safely()
|
||||
|
||||
if "redirecting" in inner_html.lower():
|
||||
try:
|
||||
wait.until(lambda d: "redirecting" not in d.get_attribute("innerHTML").lower())
|
||||
wait.until(lambda d: "redirecting" not in get_inner_html_safely().lower())
|
||||
except TimeoutException:
|
||||
snippet = context.text.strip()[:500].replace("\n", " ")
|
||||
inner_html = context.get_attribute("innerHTML") or ""
|
||||
snippet = get_text_safely()[:500].replace("\n", " ")
|
||||
inner_html = get_inner_html_safely()
|
||||
|
||||
self.fail(
|
||||
f"Timed out waiting for redirect to finish at {self.driver.current_url}. "
|
||||
@@ -284,8 +310,8 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
f"{inner_html or '<empty>'}"
|
||||
)
|
||||
|
||||
inner_html = context.get_attribute("innerHTML") or ""
|
||||
body_text = context.text.strip()
|
||||
inner_html = get_inner_html_safely()
|
||||
body_text = get_text_safely()
|
||||
|
||||
snippet = body_text[:500].replace("\n", " ")
|
||||
|
||||
@@ -326,18 +352,23 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
|
||||
|
||||
while attempts < SHADOW_ROOT_RETRIES:
|
||||
try:
|
||||
host = container.find_element(By.CSS_SELECTOR, selector)
|
||||
return host.shadow_root
|
||||
except NoSuchShadowRootException:
|
||||
except (
|
||||
NoSuchElementException,
|
||||
NoSuchShadowRootException,
|
||||
DetachedShadowRootException,
|
||||
StaleElementReferenceException,
|
||||
):
|
||||
attempts += 1
|
||||
sleep(0.2)
|
||||
# re-find host in case it was re-attached
|
||||
try:
|
||||
host = container.find_element(By.CSS_SELECTOR, selector)
|
||||
except NoSuchElementException:
|
||||
# loop and retry finding host
|
||||
pass
|
||||
|
||||
inner_html = host.get_attribute("innerHTML") or "<no host>"
|
||||
inner_html = "<no host>"
|
||||
if host is not None:
|
||||
try:
|
||||
inner_html = host.get_attribute("innerHTML") or "<no host>"
|
||||
except (DetachedShadowRootException, StaleElementReferenceException):
|
||||
inner_html = "<stale host>"
|
||||
|
||||
raise RuntimeError(
|
||||
f"Failed to obtain shadow root for {selector} after {attempts} attempts. "
|
||||
|
||||
2
uv.lock
generated
2
uv.lock
generated
@@ -185,7 +185,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2025.12.0rc1"
|
||||
version = "2025.12.0rc3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "ak-guardian" },
|
||||
|
||||
1
web/.gitignore
vendored
1
web/.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
|
||||
src/locales/*.ts
|
||||
xliff/pseudo[_-]LOCALE.xlf
|
||||
xliff/en[_-]XA.xlf
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"cs-CZ",
|
||||
"de-DE",
|
||||
"en",
|
||||
"en-XA",
|
||||
"es-ES",
|
||||
"fi-FI",
|
||||
"fr-FR",
|
||||
@@ -17,8 +18,7 @@
|
||||
"ru-RU",
|
||||
"tr-TR",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"pseudo-LOCALE"
|
||||
"zh-Hant"
|
||||
],
|
||||
"tsConfig": "./tsconfig.json",
|
||||
"output": {
|
||||
|
||||
139
web/logger/browser.js
Normal file
139
web/logger/browser.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @file Console logger for browser environments.
|
||||
*
|
||||
* @remarks
|
||||
* The repetition of log levels, typedefs, and method signatures is intentional
|
||||
* to give IDEs and type checkers a mapping of log methods to the TypeScript
|
||||
* provided JSDoc comments.
|
||||
*
|
||||
* Additionally, no wrapper functions are used to avoid the browser's console
|
||||
* reported call site being the wrapper instead of the actual caller.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
|
||||
//#region Functions
|
||||
|
||||
/**
|
||||
* @typedef {object} Logger
|
||||
* @property {typeof console.info} info;
|
||||
* @property {typeof console.warn} warn;
|
||||
* @property {typeof console.error} error;
|
||||
* @property {typeof console.debug} debug;
|
||||
* @property {typeof console.trace} trace;
|
||||
*/
|
||||
|
||||
/**
|
||||
* Labels log levels in the browser console.
|
||||
*/
|
||||
const LogLevelLabel = /** @type {const} */ ({
|
||||
info: "[INFO]",
|
||||
warn: "[WARN]",
|
||||
error: "[ERROR]",
|
||||
debug: "[DEBUG]",
|
||||
trace: "[TRACE]",
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {keyof typeof LogLevelLabel} LogLevel
|
||||
*/
|
||||
|
||||
/**
|
||||
* Predefined log levels.
|
||||
*/
|
||||
const LogLevels = /** @type {LogLevel[]} */ (Object.keys(LogLevelLabel));
|
||||
|
||||
/**
|
||||
* Colors for log levels in the browser console.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* The colors are derived from Carbon Design System's palette to ensure
|
||||
* sufficient contrast and accessibility across light and dark themes.
|
||||
*/
|
||||
const LogLevelColors = /** @type {const} */ ({
|
||||
info: `light-dark(#0043CE, #4589FF)`,
|
||||
warn: `light-dark(#F1C21B, #F1C21B)`,
|
||||
error: `light-dark(#DA1E28, #FA4D56)`,
|
||||
debug: `light-dark(#8A3FFC, #A56EFF)`,
|
||||
trace: `light-dark(#8A3FFC, #A56EFF)`,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a logger with the given prefix.
|
||||
*
|
||||
* @param {string} [prefix]
|
||||
* @param {...string} args
|
||||
* @returns {Logger}
|
||||
*
|
||||
*/
|
||||
export function createLogger(prefix, ...args) {
|
||||
const suffix = prefix ? `(${prefix}):` : ":";
|
||||
|
||||
/**
|
||||
* @type {Partial<Logger>}
|
||||
*/
|
||||
const logger = {};
|
||||
|
||||
for (const level of LogLevels) {
|
||||
const label = LogLevelLabel[level];
|
||||
const color = LogLevelColors[level];
|
||||
|
||||
logger[level] = console[level].bind(
|
||||
console,
|
||||
`%c${label}%c ${suffix}%c`,
|
||||
`font-weight: 700; color: ${color};`,
|
||||
`font-weight: 600; color: CanvasText;`,
|
||||
"",
|
||||
...args,
|
||||
);
|
||||
}
|
||||
|
||||
return /** @type {Logger} */ (logger);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Console Logger
|
||||
|
||||
/**
|
||||
* @typedef {Logger & {prefix: (logPrefix: string) => Logger}} IConsoleLogger
|
||||
*/
|
||||
|
||||
/**
|
||||
* A singleton logger instance for the browser.
|
||||
*
|
||||
* ```js
|
||||
* import { ConsoleLogger } from "#logger/browser";
|
||||
*
|
||||
* ConsoleLogger.info("Hello, world!");
|
||||
* ```
|
||||
*
|
||||
* @implements {IConsoleLogger}
|
||||
* @runtime browser
|
||||
*/
|
||||
// @ts-expect-error Logging properties are dynamically assigned.
|
||||
export class ConsoleLogger {
|
||||
/** @type {typeof console.info} */
|
||||
static info;
|
||||
/** @type {typeof console.warn} */
|
||||
static warn;
|
||||
/** @type {typeof console.error} */
|
||||
static error;
|
||||
/** @type {typeof console.debug} */
|
||||
static debug;
|
||||
/** @type {typeof console.trace} */
|
||||
static trace;
|
||||
|
||||
/**
|
||||
* Creates a logger with the given prefix.
|
||||
* @param {string} logPrefix
|
||||
*/
|
||||
static prefix(logPrefix) {
|
||||
return createLogger(logPrefix);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(ConsoleLogger, createLogger());
|
||||
|
||||
//#endregion
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc3",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.12.0-rc1",
|
||||
"version": "2025.12.0-rc3",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -52,7 +52,7 @@ const EmittedLocalesDirectory = resolve(
|
||||
);
|
||||
|
||||
const targetLocales = localizeRules.targetLocales.filter((localeCode) => {
|
||||
return localeCode !== "pseudo-LOCALE";
|
||||
return localeCode !== "en-XA";
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -23,7 +23,7 @@ import { makeFormatter } from "@lit/localize-tools/lib/formatters/index.js";
|
||||
import { sortProgramMessages } from "@lit/localize-tools/lib/messages.js";
|
||||
import { TransformLitLocalizer } from "@lit/localize-tools/lib/modes/transform.js";
|
||||
|
||||
const pseudoLocale = /** @type {Locale} */ ("pseudo-LOCALE");
|
||||
const pseudoLocale = /** @type {Locale} */ ("en-XA");
|
||||
const targetLocales = [pseudoLocale];
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
|
||||
@@ -14,17 +14,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.pf-c-page__main,
|
||||
.pf-c-drawer__content,
|
||||
.pf-c-page__drawer {
|
||||
z-index: auto !important;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.display-none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ak-page-navbar {
|
||||
grid-area: header;
|
||||
}
|
||||
@@ -43,5 +32,5 @@ ak-sidebar-item:active ak-sidebar-item::part(list-item) {
|
||||
}
|
||||
|
||||
.pf-c-drawer__panel {
|
||||
z-index: var(--pf-global--ZIndex--xl);
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import "#admin/AdminInterface/AboutModal";
|
||||
import "#elements/banner/EnterpriseStatusBanner";
|
||||
import "#elements/banner/VersionBanner";
|
||||
import "#elements/messages/MessageContainer";
|
||||
import "#elements/notifications/APIDrawer";
|
||||
import "#elements/notifications/NotificationDrawer";
|
||||
import "#elements/router/RouterOutlet";
|
||||
import "#elements/sidebar/Sidebar";
|
||||
import "#elements/sidebar/SidebarItem";
|
||||
@@ -15,15 +13,22 @@ import {
|
||||
} from "./AdminSidebar.js";
|
||||
|
||||
import { isAPIResultReady } from "#common/api/responses";
|
||||
import { EVENT_API_DRAWER_TOGGLE, EVENT_NOTIFICATION_DRAWER_TOGGLE } from "#common/constants";
|
||||
import { configureSentry } from "#common/sentry/index";
|
||||
import { isGuest } from "#common/users";
|
||||
import { WebsocketClient } from "#common/ws";
|
||||
import { WebsocketClient } from "#common/ws/WebSocketClient";
|
||||
|
||||
import { AuthenticatedInterface } from "#elements/AuthenticatedInterface";
|
||||
import { listen } from "#elements/decorators/listen";
|
||||
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
|
||||
import { WithNotifications } from "#elements/mixins/notifications";
|
||||
import { canAccessAdmin, WithSession } from "#elements/mixins/session";
|
||||
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
|
||||
import { AKDrawerChangeEvent } from "#elements/notifications/events";
|
||||
import {
|
||||
DrawerState,
|
||||
persistDrawerParams,
|
||||
readDrawerParams,
|
||||
renderNotificationDrawerPanel,
|
||||
} from "#elements/notifications/utils";
|
||||
|
||||
import { PageNavMenuToggle } from "#components/ak-page-navbar";
|
||||
|
||||
@@ -34,29 +39,37 @@ import { ROUTES } from "#admin/Routes";
|
||||
import { CapabilitiesEnum } from "@goauthentik/api";
|
||||
|
||||
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators.js";
|
||||
import { customElement, property, query, state } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
|
||||
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
await import("@goauthentik/esbuild-plugin-live-reload/client");
|
||||
}
|
||||
|
||||
@customElement("ak-interface-admin")
|
||||
export class AdminInterface extends WithCapabilitiesConfig(WithSession(AuthenticatedInterface)) {
|
||||
export class AdminInterface extends WithCapabilitiesConfig(
|
||||
WithNotifications(WithSession(AuthenticatedInterface)),
|
||||
) {
|
||||
//#region Styles
|
||||
|
||||
public static readonly styles: CSSResult[] = [
|
||||
// ---
|
||||
PFPage,
|
||||
PFButton,
|
||||
PFDrawer,
|
||||
PFNav,
|
||||
Styles,
|
||||
];
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Properties
|
||||
|
||||
@property({ type: Boolean })
|
||||
public notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||
|
||||
@property({ type: Boolean })
|
||||
public apiDrawerOpen = getURLParam("apiDrawerOpen", false);
|
||||
|
||||
@query("ak-about-modal")
|
||||
public aboutModal?: AboutModal;
|
||||
|
||||
@@ -74,19 +87,14 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Styles
|
||||
@state()
|
||||
protected drawer: DrawerState = readDrawerParams();
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
// ---
|
||||
PFBase,
|
||||
PFPage,
|
||||
PFButton,
|
||||
PFDrawer,
|
||||
PFNav,
|
||||
Styles,
|
||||
];
|
||||
|
||||
//#endregion
|
||||
@listen(AKDrawerChangeEvent)
|
||||
protected drawerListener = (event: AKDrawerChangeEvent) => {
|
||||
this.drawer = event.drawer;
|
||||
persistDrawerParams(event.drawer);
|
||||
};
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
@@ -99,6 +107,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
|
||||
|
||||
this.#sidebarMatcher = window.matchMedia("(min-width: 1200px)");
|
||||
this.sidebarOpen = this.#sidebarMatcher.matches;
|
||||
|
||||
this.addEventListener(PageNavMenuToggle.eventName, this.#onPageNavMenuEvent, {
|
||||
passive: true,
|
||||
});
|
||||
@@ -107,20 +116,6 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, () => {
|
||||
this.notificationDrawerOpen = !this.notificationDrawerOpen;
|
||||
updateURLParams({
|
||||
notificationDrawerOpen: this.notificationDrawerOpen,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener(EVENT_API_DRAWER_TOGGLE, () => {
|
||||
this.apiDrawerOpen = !this.apiDrawerOpen;
|
||||
updateURLParams({
|
||||
apiDrawerOpen: this.apiDrawerOpen,
|
||||
});
|
||||
});
|
||||
|
||||
this.#sidebarMatcher.addEventListener("change", this.#sidebarMediaQueryListener, {
|
||||
passive: true,
|
||||
});
|
||||
@@ -128,6 +123,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener);
|
||||
|
||||
WebsocketClient.close();
|
||||
@@ -154,11 +150,10 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
|
||||
"pf-m-collapsed": !this.sidebarOpen,
|
||||
};
|
||||
|
||||
const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen;
|
||||
|
||||
const openDrawerCount = (this.drawer.notifications ? 1 : 0) + (this.drawer.api ? 1 : 0);
|
||||
const drawerClasses = {
|
||||
"pf-m-expanded": drawerOpen,
|
||||
"pf-m-collapsed": !drawerOpen,
|
||||
"pf-m-expanded": openDrawerCount !== 0,
|
||||
"pf-m-collapsed": openDrawerCount === 0,
|
||||
};
|
||||
|
||||
return html`<div class="pf-c-page">
|
||||
@@ -190,18 +185,7 @@ export class AdminInterface extends WithCapabilitiesConfig(WithSession(Authentic
|
||||
</ak-router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
<ak-notification-drawer
|
||||
class="pf-c-drawer__panel pf-m-width-33 ${this.notificationDrawerOpen
|
||||
? ""
|
||||
: "display-none"}"
|
||||
?hidden=${!this.notificationDrawerOpen}
|
||||
></ak-notification-drawer>
|
||||
<ak-api-drawer
|
||||
class="pf-c-drawer__panel pf-m-width-33 ${this.apiDrawerOpen
|
||||
? ""
|
||||
: "display-none"}"
|
||||
?hidden=${!this.apiDrawerOpen}
|
||||
></ak-api-drawer>
|
||||
${renderNotificationDrawerPanel(this.drawer)}
|
||||
<ak-about-modal></ak-about-modal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,11 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
||||
</ak-aggregate-card>
|
||||
</div>
|
||||
<div class="pf-l-grid__item pf-m-12-col pf-m-12-col-on-xl pf-m-4-col-on-2xl">
|
||||
<ak-aggregate-card icon="fa fa-sync-alt" label=${msg("Sync status")}>
|
||||
<ak-aggregate-card
|
||||
icon="fa fa-sync-alt"
|
||||
label=${msg("Sync status")}
|
||||
tooltip=${msg("Integrations synced in the last 12 hours.")}
|
||||
>
|
||||
<ak-admin-status-chart-sync></ak-admin-status-chart-sync>
|
||||
</ak-aggregate-card>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@ export class SyncStatusChart extends AKChart<SummarizedSyncStatus[]> {
|
||||
const status = await fetchSyncStatus(element);
|
||||
|
||||
const now = new Date().getTime();
|
||||
const maxDelta = 3600000; // 1 hour
|
||||
const maxDelta = 12 * 60 * 60 * 1000; // 12 hours
|
||||
|
||||
if (
|
||||
status.lastSyncStatus === TaskAggregatedStatusEnum.Error ||
|
||||
|
||||
@@ -99,6 +99,9 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
|
||||
const alertMsg = msg(
|
||||
"Using this form will only create an Application. In order to authenticate with the application, you will have to manually pair it with a Provider.",
|
||||
);
|
||||
const providerFromInstance = this.instance?.provider;
|
||||
const providerValue = providerFromInstance ?? this.provider;
|
||||
const providerPrefilled = !this.instance && this.provider !== undefined;
|
||||
|
||||
return html`
|
||||
${this.instance ? nothing : html`<ak-alert level="pf-m-info">${alertMsg}</ak-alert>`}
|
||||
@@ -134,9 +137,10 @@ export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Applicatio
|
||||
<ak-provider-search-input
|
||||
name="provider"
|
||||
label=${msg("Provider")}
|
||||
value=${ifPresent(this.instance?.provider)}
|
||||
.value=${providerValue}
|
||||
.readOnly=${providerPrefilled}
|
||||
?blankable=${!providerPrefilled}
|
||||
help=${msg("Select a provider that this application should use.")}
|
||||
blankable
|
||||
></ak-provider-search-input>
|
||||
<ak-backchannel-providers-input
|
||||
name="backchannelProviders"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Provider, ProvidersAllListRequest, ProvidersApi } from "@goauthentik/ap
|
||||
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
const renderElement = (item: Provider) => item.name;
|
||||
const renderValue = (item: Provider | undefined) => item?.pk;
|
||||
@@ -53,6 +54,9 @@ export class AkProviderInput extends AKElement {
|
||||
@property({ type: Number })
|
||||
value?: number;
|
||||
|
||||
@property({ type: Boolean, attribute: "readonly" })
|
||||
readOnly = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
required = false;
|
||||
|
||||
@@ -76,6 +80,8 @@ export class AkProviderInput extends AKElement {
|
||||
};
|
||||
|
||||
render() {
|
||||
const readOnlyValue = this.readOnly && typeof this.value === "number";
|
||||
|
||||
return html` <ak-form-element-horizontal name=${this.name}>
|
||||
${AKLabel(
|
||||
{
|
||||
@@ -86,7 +92,9 @@ export class AkProviderInput extends AKElement {
|
||||
},
|
||||
this.label,
|
||||
)}
|
||||
|
||||
${readOnlyValue
|
||||
? html`<input type="hidden" name=${this.name} value=${this.value ?? ""} />`
|
||||
: nothing}
|
||||
<ak-search-select
|
||||
.fieldID=${this.fieldID}
|
||||
.selected=${this.#selected}
|
||||
@@ -94,7 +102,9 @@ export class AkProviderInput extends AKElement {
|
||||
.renderElement=${renderElement}
|
||||
.value=${renderValue}
|
||||
.groupBy=${doGroupBy}
|
||||
?blankable=${!!this.blankable}
|
||||
?blankable=${readOnlyValue ? false : !!this.blankable}
|
||||
?readonly=${this.readOnly}
|
||||
name=${ifDefined(readOnlyValue ? undefined : this.name)}
|
||||
>
|
||||
</ak-search-select>
|
||||
${this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing}
|
||||
|
||||
@@ -2,6 +2,7 @@ import "#elements/forms/DeleteBulkForm";
|
||||
import "#elements/forms/ModalForm";
|
||||
import "#admin/endpoints/DeviceAccessGroupForm";
|
||||
import "#admin/policies/BoundPoliciesList";
|
||||
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
@@ -39,17 +40,19 @@ export class DeviceAccessGroupsListPage extends TablePage<DeviceAccessGroup> {
|
||||
row(item: DeviceAccessGroup): SlottedTemplateResult[] {
|
||||
return [
|
||||
html`${item.name}`,
|
||||
html`<ak-forms-modal>
|
||||
<span slot="submit">${msg("Update")}</span>
|
||||
<span slot="header">${msg("Update Group")}</span>
|
||||
<ak-endpoints-device-access-groups-form slot="form" pk=${item.pbmUuid}>
|
||||
</ak-endpoints-device-access-groups-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-plain">
|
||||
<pf-tooltip position="top" content=${msg("Edit")}>
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</pf-tooltip>
|
||||
</button>
|
||||
</ak-forms-modal>`,
|
||||
html`<div>
|
||||
<ak-forms-modal>
|
||||
<span slot="submit">${msg("Update")}</span>
|
||||
<span slot="header">${msg("Update Group")}</span>
|
||||
<ak-endpoints-device-access-groups-form slot="form" .instancePk=${item.pbmUuid}>
|
||||
</ak-endpoints-device-access-groups-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-plain">
|
||||
<pf-tooltip position="top" content=${msg("Edit")}>
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</pf-tooltip>
|
||||
</button>
|
||||
</ak-forms-modal>
|
||||
</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -44,23 +44,25 @@ export class ConnectorsListPage extends TablePage<Connector> {
|
||||
return [
|
||||
html`<a href="#/endpoints/connectors/${item.connectorUuid}">${item.name}</a>`,
|
||||
html`${item.verboseName}`,
|
||||
html`<ak-forms-modal>
|
||||
<span slot="submit">${msg("Update")}</span>
|
||||
<span slot="header">${msg("Update Connector")}</span>
|
||||
<ak-proxy-form
|
||||
slot="form"
|
||||
.args=${{
|
||||
instancePk: item.connectorUuid,
|
||||
}}
|
||||
type=${ifDefined(item.component)}
|
||||
>
|
||||
</ak-proxy-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-plain">
|
||||
<pf-tooltip position="top" content=${msg("Edit")}>
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</pf-tooltip>
|
||||
</button>
|
||||
</ak-forms-modal>`,
|
||||
html`<div>
|
||||
<ak-forms-modal>
|
||||
<span slot="submit">${msg("Update")}</span>
|
||||
<span slot="header">${msg("Update Connector")}</span>
|
||||
<ak-proxy-form
|
||||
slot="form"
|
||||
.args=${{
|
||||
instancePk: item.connectorUuid,
|
||||
}}
|
||||
type=${ifDefined(item.component)}
|
||||
>
|
||||
</ak-proxy-form>
|
||||
<button slot="trigger" class="pf-c-button pf-m-plain">
|
||||
<pf-tooltip position="top" content=${msg("Edit")}>
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
</pf-tooltip>
|
||||
</button>
|
||||
</ak-forms-modal>
|
||||
</div>`,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { ModelForm } from "#elements/forms/ModelForm";
|
||||
import { WithBrandConfig } from "#elements/mixins/branding";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
|
||||
import { gidStartNumberHelp, uidStartNumberHelp } from "#admin/providers/ldap/LDAPOptionsAndHelp";
|
||||
import {
|
||||
oauth2ProvidersProvider,
|
||||
oauth2ProvidersSelector,
|
||||
@@ -62,9 +61,11 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm<AgentConnector
|
||||
renderForm() {
|
||||
return html`<ak-text-input
|
||||
name="name"
|
||||
placeholder=${msg("Connector name...")}
|
||||
placeholder=${msg("Type a connector name...")}
|
||||
label=${msg("Connector name")}
|
||||
value=${ifDefined(this.instance?.name)}
|
||||
input-hint="code"
|
||||
autofocus
|
||||
required
|
||||
></ak-text-input>
|
||||
<ak-text-input
|
||||
@@ -183,15 +184,19 @@ export class AgentConnectorForm extends WithBrandConfig(ModelForm<AgentConnector
|
||||
label=${msg("NSS User ID offset")}
|
||||
required
|
||||
name="nssUidOffset"
|
||||
value="${this.instance?.nssUidOffset ?? 1000}"
|
||||
help=${uidStartNumberHelp}
|
||||
value="${this.instance?.nssUidOffset ?? 2000}"
|
||||
help=${msg(
|
||||
"The start for user ID numbers, this number is added to the user ID to make sure that the numbers aren't too low for POSIX users. Default is 2000 to prevent collisions with local users.",
|
||||
)}
|
||||
></ak-number-input>
|
||||
<ak-number-input
|
||||
label=${msg("NSS Group ID offset")}
|
||||
required
|
||||
name="nssGidOffset"
|
||||
value="${this.instance?.nssGidOffset ?? 1000}"
|
||||
help=${gidStartNumberHelp}
|
||||
value="${this.instance?.nssGidOffset ?? 4000}"
|
||||
help=${msg(
|
||||
"The start for group ID numbers, this number is added to a number generated from the groups' ID to make sure that the numbers aren't too low for POSIX groups. Default is 4000 to prevent collisions with local groups.",
|
||||
)}
|
||||
></ak-number-input>
|
||||
</div>
|
||||
</ak-form-group>`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user