Compare commits
140 Commits
reduce-mem
...
enterprise
Author | SHA1 | Date | |
---|---|---|---|
60000812fd | |||
929e70669a | |||
36114284bf | |||
a65ea0de94 | |||
2056b0cbee | |||
af85ddf60b | |||
e8e42261e3 | |||
9fda4e91ad | |||
a11f1258e1 | |||
a97578ac62 | |||
41aa36d06f | |||
62fc4c56e4 | |||
4514412010 | |||
463efac469 | |||
f4508659cf | |||
336f6f0dc2 | |||
c19a887356 | |||
09931bcbc2 | |||
7a4293bf17 | |||
6e569acd84 | |||
02c69d767f | |||
1863a9a12b | |||
b981bc5ba1 | |||
5da02971eb | |||
1f49ee77df | |||
baf8f18d54 | |||
5445b1235a | |||
2893a54ffb | |||
94eff50306 | |||
0befc26507 | |||
629d5df763 | |||
3098313981 | |||
c0a370bb2b | |||
a19d915d2b | |||
9a0dc50174 | |||
ac0a708f92 | |||
0ffaf0393e | |||
9bb3aa0374 | |||
f6a32dc6e5 | |||
af83fc7245 | |||
84de15568a | |||
29f8a82b49 | |||
cd05c0ec19 | |||
c19a1b373a | |||
31b9cbfb85 | |||
c0fe0dab61 | |||
1bd42345b9 | |||
90e7545d57 | |||
78d42c391d | |||
2ad831adb0 | |||
5eaa94917b | |||
6c0d462410 | |||
9dc2c26ba9 | |||
774a84f9e6 | |||
56015d883b | |||
9d15fa4a57 | |||
bb7338f5c1 | |||
f949141d03 | |||
646d133c30 | |||
3ee3adc509 | |||
1b4fee2bac | |||
10c358401d | |||
9dddbd2f0c | |||
078d643c20 | |||
733b7cf139 | |||
f83fab214b | |||
9ce460a0ac | |||
e69a380a39 | |||
2d89f42c68 | |||
3d4d167542 | |||
ee8d3c5146 | |||
0406b0d95a | |||
44d49bb14c | |||
afb1686be7 | |||
6b1802697d | |||
943fd6b78b | |||
ed33d314cd | |||
d343ccc539 | |||
31e8fb7c8c | |||
23faa0b839 | |||
3cbfd836ac | |||
10ab6e4327 | |||
561d2220bc | |||
e6c47db9f8 | |||
5f5171c472 | |||
bdf4236973 | |||
a61a41d7d0 | |||
c7532d35f2 | |||
27baedfea4 | |||
e3011eab9a | |||
9635dd98f3 | |||
bd0d7edbc4 | |||
9b05418306 | |||
d4e15f0f39 | |||
ec9c2266eb | |||
5ebd280087 | |||
1cc8d80600 | |||
3b70cd735e | |||
42766e13da | |||
8938fa5a7e | |||
4c8f610cdb | |||
8690200cd8 | |||
91145b7929 | |||
d255e53756 | |||
d51e6a5551 | |||
5433839ea0 | |||
863a7e6095 | |||
50db80428c | |||
ffd5234396 | |||
95890638a5 | |||
f7d2a68b1d | |||
83ecb64f33 | |||
40b0f7df8d | |||
ee6fcdfbd8 | |||
94623615a6 | |||
aa4f817856 | |||
c3aefd55a2 | |||
1298cdc338 | |||
3eaaa35a4c | |||
d17f781d11 | |||
c82b79f10f | |||
0aa7be6e2c | |||
9811ec57df | |||
393e5f236c | |||
59ae9c6148 | |||
fd8e20bdeb | |||
737aced000 | |||
dc3559c7e9 | |||
02bd699917 | |||
5fccbd7c04 | |||
6fc92bd50c | |||
687f6d683a | |||
4a8329649c | |||
0c296efede | |||
112520fd88 | |||
ee648269f7 | |||
15be3f2461 | |||
ef9557c578 | |||
48700c0e9c | |||
18a48030a8 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2024.12.0
|
||||
current_version = 2024.12.2
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
|
||||
|
6
.github/workflows/ci-main.yml
vendored
6
.github/workflows/ci-main.yml
vendored
@ -134,7 +134,7 @@ jobs:
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@v1.11.0
|
||||
uses: helm/kind-action@v1.12.0
|
||||
- name: run integration
|
||||
run: |
|
||||
poetry run coverage run manage.py test tests/integration
|
||||
@ -168,6 +168,8 @@ jobs:
|
||||
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
|
||||
- name: ldap
|
||||
glob: tests/e2e/test_provider_ldap* tests/e2e/test_source_ldap*
|
||||
- name: rac
|
||||
glob: tests/e2e/test_provider_rac*
|
||||
- name: radius
|
||||
glob: tests/e2e/test_provider_radius*
|
||||
- name: scim
|
||||
@ -243,7 +245,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.2.0
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: prepare variables
|
||||
|
2
.github/workflows/ci-outpost.yml
vendored
2
.github/workflows/ci-outpost.yml
vendored
@ -82,7 +82,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.2.0
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: prepare variables
|
||||
|
8
.github/workflows/release-publish.yml
vendored
8
.github/workflows/release-publish.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.2.0
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: prepare variables
|
||||
@ -83,7 +83,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.2.0
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: prepare variables
|
||||
@ -188,8 +188,8 @@ jobs:
|
||||
aws-region: ${{ env.AWS_REGION }}
|
||||
- name: Upload template
|
||||
run: |
|
||||
aws s3 cp website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml
|
||||
aws s3 cp website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
|
||||
aws s3 cp --acl=public-read website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml
|
||||
aws s3 cp --acl=public-read website/docs/install-config/install/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
|
||||
test-release:
|
||||
needs:
|
||||
- build-server
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
from os import environ
|
||||
|
||||
__version__ = "2024.12.0"
|
||||
__version__ = "2024.12.2"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
@ -16,5 +16,5 @@ def get_full_version() -> str:
|
||||
"""Get full version, with build hash appended"""
|
||||
version = __version__
|
||||
if (build_hash := get_build_hash()) != "":
|
||||
version += "." + build_hash
|
||||
return f"{version}+{build_hash}"
|
||||
return version
|
||||
|
@ -7,7 +7,9 @@ from sys import version as python_version
|
||||
from typing import TypedDict
|
||||
|
||||
from cryptography.hazmat.backends.openssl.backend import backend
|
||||
from django.conf import settings
|
||||
from django.utils.timezone import now
|
||||
from django.views.debug import SafeExceptionReporterFilter
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
from rest_framework.request import Request
|
||||
@ -52,10 +54,16 @@ class SystemInfoSerializer(PassiveSerializer):
|
||||
def get_http_headers(self, request: Request) -> dict[str, str]:
|
||||
"""Get HTTP Request headers"""
|
||||
headers = {}
|
||||
raw_session = request._request.COOKIES.get(settings.SESSION_COOKIE_NAME)
|
||||
for key, value in request.META.items():
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
headers[key] = value
|
||||
actual_value = value
|
||||
if raw_session in actual_value:
|
||||
actual_value = actual_value.replace(
|
||||
raw_session, SafeExceptionReporterFilter.cleansed_substitute
|
||||
)
|
||||
headers[key] = actual_value
|
||||
return headers
|
||||
|
||||
def get_http_host(self, request: Request) -> str:
|
||||
|
@ -1,12 +1,16 @@
|
||||
"""authentik administration overview"""
|
||||
|
||||
from socket import gethostname
|
||||
|
||||
from django.conf import settings
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from rest_framework.fields import IntegerField
|
||||
from packaging.version import parse
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from authentik import get_full_version
|
||||
from authentik.rbac.permissions import HasPermission
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
@ -16,11 +20,38 @@ class WorkerView(APIView):
|
||||
|
||||
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
|
||||
|
||||
@extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()}))
|
||||
@extend_schema(
|
||||
responses=inline_serializer(
|
||||
"Worker",
|
||||
fields={
|
||||
"worker_id": CharField(),
|
||||
"version": CharField(),
|
||||
"version_matching": BooleanField(),
|
||||
},
|
||||
many=True,
|
||||
)
|
||||
)
|
||||
def get(self, request: Request) -> Response:
|
||||
"""Get currently connected worker count."""
|
||||
count = len(CELERY_APP.control.ping(timeout=0.5))
|
||||
raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5)
|
||||
our_version = parse(get_full_version())
|
||||
response = []
|
||||
for worker in raw:
|
||||
key = list(worker.keys())[0]
|
||||
version = worker[key].get("version")
|
||||
version_matching = False
|
||||
if version:
|
||||
version_matching = parse(version) == our_version
|
||||
response.append(
|
||||
{"worker_id": key, "version": version, "version_matching": version_matching}
|
||||
)
|
||||
# In debug we run with `task_always_eager`, so tasks are ran on the main process
|
||||
if settings.DEBUG: # pragma: no cover
|
||||
count += 1
|
||||
return Response({"count": count})
|
||||
response.append(
|
||||
{
|
||||
"worker_id": f"authentik-debug@{gethostname()}",
|
||||
"version": get_full_version(),
|
||||
"version_matching": True,
|
||||
}
|
||||
)
|
||||
return Response(response)
|
||||
|
@ -1,11 +1,10 @@
|
||||
"""authentik admin app config"""
|
||||
|
||||
from prometheus_client import Gauge, Info
|
||||
from prometheus_client import Info
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
|
||||
PROM_INFO = Info("authentik_version", "Currently running authentik version")
|
||||
GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
|
||||
|
||||
|
||||
class AuthentikAdminConfig(ManagedAppConfig):
|
||||
|
@ -1,14 +1,35 @@
|
||||
"""admin signals"""
|
||||
|
||||
from django.dispatch import receiver
|
||||
from packaging.version import parse
|
||||
from prometheus_client import Gauge
|
||||
|
||||
from authentik.admin.apps import GAUGE_WORKERS
|
||||
from authentik import get_full_version
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
|
||||
GAUGE_WORKERS = Gauge(
|
||||
"authentik_admin_workers",
|
||||
"Currently connected workers, their versions and if they are the same version as authentik",
|
||||
["version", "version_matched"],
|
||||
)
|
||||
|
||||
|
||||
_version = parse(get_full_version())
|
||||
|
||||
|
||||
@receiver(monitoring_set)
|
||||
def monitoring_set_workers(sender, **kwargs):
|
||||
"""Set worker gauge"""
|
||||
count = len(CELERY_APP.control.ping(timeout=0.5))
|
||||
GAUGE_WORKERS.set(count)
|
||||
raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5)
|
||||
worker_version_count = {}
|
||||
for worker in raw:
|
||||
key = list(worker.keys())[0]
|
||||
version = worker[key].get("version")
|
||||
version_matching = False
|
||||
if version:
|
||||
version_matching = parse(version) == _version
|
||||
worker_version_count.setdefault(version, {"count": 0, "matching": version_matching})
|
||||
worker_version_count[version]["count"] += 1
|
||||
for version, stats in worker_version_count.items():
|
||||
GAUGE_WORKERS.labels(version, stats["matching"]).set(stats["count"])
|
||||
|
@ -34,7 +34,7 @@ class TestAdminAPI(TestCase):
|
||||
response = self.client.get(reverse("authentik_api:admin_workers"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content)
|
||||
self.assertEqual(body["count"], 0)
|
||||
self.assertEqual(len(body), 0)
|
||||
|
||||
def test_metrics(self):
|
||||
"""Test metrics API"""
|
||||
|
@ -1,67 +0,0 @@
|
||||
"""API Authorization"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Model
|
||||
from django.db.models.query import QuerySet
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.authentication import get_authorization_header
|
||||
from rest_framework.filters import BaseFilterBackend
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentik.api.authentication import validate_auth
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
|
||||
|
||||
class OwnerFilter(BaseFilterBackend):
|
||||
"""Filter objects by their owner"""
|
||||
|
||||
owner_key = "user"
|
||||
|
||||
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
|
||||
if request.user.is_superuser:
|
||||
return queryset
|
||||
return queryset.filter(**{self.owner_key: request.user})
|
||||
|
||||
|
||||
class SecretKeyFilter(DjangoFilterBackend):
|
||||
"""Allow access to all objects when authenticated with secret key as token.
|
||||
|
||||
Replaces both DjangoFilterBackend and ObjectFilter"""
|
||||
|
||||
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
|
||||
auth_header = get_authorization_header(request)
|
||||
token = validate_auth(auth_header)
|
||||
if token and token == settings.SECRET_KEY:
|
||||
return queryset
|
||||
queryset = ObjectFilter().filter_queryset(request, queryset, view)
|
||||
return super().filter_queryset(request, queryset, view)
|
||||
|
||||
|
||||
class OwnerPermissions(BasePermission):
|
||||
"""Authorize requests by an object's owner matching the requesting user"""
|
||||
|
||||
owner_key = "user"
|
||||
|
||||
def has_permission(self, request: Request, view) -> bool:
|
||||
"""If the user is authenticated, we allow all requests here. For listing, the
|
||||
object-level permissions are done by the filter backend"""
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
|
||||
"""Check if the object's owner matches the currently logged in user"""
|
||||
if not hasattr(obj, self.owner_key):
|
||||
return False
|
||||
owner = getattr(obj, self.owner_key)
|
||||
if owner != request.user:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class OwnerSuperuserPermissions(OwnerPermissions):
|
||||
"""Similar to OwnerPermissions, except always allow access for superusers"""
|
||||
|
||||
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
|
||||
if request.user.is_superuser:
|
||||
return True
|
||||
return super().has_object_permission(request, view, obj)
|
68
authentik/blueprints/management/commands/blueprint_shell.py
Normal file
68
authentik/blueprints/management/commands/blueprint_shell.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Test and debug Blueprints"""
|
||||
|
||||
import atexit
|
||||
import readline
|
||||
from pathlib import Path
|
||||
from pprint import pformat
|
||||
from sys import exit as sysexit
|
||||
from textwrap import indent
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from structlog.stdlib import get_logger
|
||||
from yaml import load
|
||||
|
||||
from authentik.blueprints.v1.common import BlueprintLoader, EntryInvalidError
|
||||
from authentik.core.management.commands.shell import get_banner_text
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Test and debug Blueprints"""
|
||||
|
||||
lines = []
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
histfolder = Path("~").expanduser() / Path(".local/share/authentik")
|
||||
histfolder.mkdir(parents=True, exist_ok=True)
|
||||
histfile = histfolder / Path("blueprint_shell_history")
|
||||
readline.parse_and_bind("tab: complete")
|
||||
readline.parse_and_bind("set editing-mode vi")
|
||||
|
||||
try:
|
||||
readline.read_history_file(str(histfile))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
atexit.register(readline.write_history_file, str(histfile))
|
||||
|
||||
@no_translations
|
||||
def handle(self, *args, **options):
|
||||
"""Interactively debug blueprint files"""
|
||||
self.stdout.write(get_banner_text("Blueprint shell"))
|
||||
self.stdout.write("Type '.eval' to evaluate previously entered statement(s).")
|
||||
|
||||
def do_eval():
|
||||
yaml_input = "\n".join([line for line in self.lines if line])
|
||||
data = load(yaml_input, BlueprintLoader)
|
||||
self.stdout.write(pformat(data))
|
||||
self.lines = []
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = input("> ")
|
||||
if line == ".eval":
|
||||
do_eval()
|
||||
else:
|
||||
self.lines.append(line)
|
||||
except EntryInvalidError as exc:
|
||||
self.stdout.write("Failed to evaluate expression:")
|
||||
self.stdout.write(indent(exception_to_string(exc), prefix=" "))
|
||||
except EOFError:
|
||||
break
|
||||
except KeyboardInterrupt:
|
||||
self.stdout.write()
|
||||
sysexit(0)
|
||||
self.stdout.write()
|
@ -202,6 +202,9 @@ class Blueprint:
|
||||
class YAMLTag:
|
||||
"""Base class for all YAML Tags"""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self.resolve(BlueprintEntry(""), Blueprint()))
|
||||
|
||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||
"""Implement yaml tag logic"""
|
||||
raise NotImplementedError
|
||||
|
@ -14,10 +14,10 @@ from rest_framework.response import Response
|
||||
from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.authorization import SecretKeyFilter
|
||||
from authentik.brands.models import Brand
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.rbac.filters import SecretKeyFilter
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
|
@ -1,15 +1,16 @@
|
||||
"""Application Roles API Viewset"""
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import (
|
||||
Application,
|
||||
ApplicationEntitlement,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
@ -18,7 +19,10 @@ class ApplicationEntitlementSerializer(ModelSerializer):
|
||||
|
||||
def validate_app(self, app: Application) -> Application:
|
||||
"""Ensure user has permission to view"""
|
||||
user: User = self._context["request"].user
|
||||
request: HttpRequest = self.context.get("request")
|
||||
if not request and SERIALIZER_CONTEXT_BLUEPRINT in self.context:
|
||||
return app
|
||||
user = request.user
|
||||
if user.has_perm("view_application", app) or user.has_perm(
|
||||
"authentik_core.view_application"
|
||||
):
|
||||
|
@ -2,16 +2,12 @@
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from guardian.utils import get_anonymous_user
|
||||
from rest_framework import mixins
|
||||
from rest_framework.fields import SerializerMethodField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from ua_parser import user_agent_parser
|
||||
|
||||
from authentik.api.authorization import OwnerSuperuserPermissions
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import AuthenticatedSession
|
||||
@ -110,11 +106,4 @@ class AuthenticatedSessionViewSet(
|
||||
search_fields = ["user__username", "last_ip", "last_user_agent"]
|
||||
filterset_fields = ["user__username", "last_ip", "last_user_agent"]
|
||||
ordering = ["user__username"]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user if self.request else get_anonymous_user()
|
||||
if user.is_superuser:
|
||||
return super().get_queryset()
|
||||
return super().get_queryset().filter(user=user.pk)
|
||||
owner_field = "user"
|
||||
|
@ -2,19 +2,16 @@
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.object_types import TypesMixin
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@ -189,11 +186,10 @@ class UserSourceConnectionViewSet(
|
||||
|
||||
queryset = UserSourceConnection.objects.all()
|
||||
serializer_class = UserSourceConnectionSerializer
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filterset_fields = ["user", "source__slug"]
|
||||
search_fields = ["source__slug"]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
ordering = ["source__slug", "pk"]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class GroupSourceConnectionSerializer(SourceSerializer):
|
||||
@ -228,8 +224,7 @@ class GroupSourceConnectionViewSet(
|
||||
|
||||
queryset = GroupSourceConnection.objects.all()
|
||||
serializer_class = GroupSourceConnectionSerializer
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filterset_fields = ["group", "source__slug"]
|
||||
search_fields = ["source__slug"]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
ordering = ["source__slug", "pk"]
|
||||
owner_field = "user"
|
||||
|
@ -3,18 +3,15 @@
|
||||
from typing import Any
|
||||
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
||||
from guardian.shortcuts import assign_perm, get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerSuperuserPermissions
|
||||
from authentik.blueprints.api import ManagedSerializer
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
@ -138,8 +135,8 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
"managed",
|
||||
]
|
||||
ordering = ["identifier", "expires"]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
owner_field = "user"
|
||||
rbac_allow_create_without_perm = True
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user if self.request else get_anonymous_user()
|
||||
|
@ -585,7 +585,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"""Set password for user"""
|
||||
user: User = self.get_object()
|
||||
try:
|
||||
user.set_password(request.data.get("password"))
|
||||
user.set_password(request.data.get("password"), request=request)
|
||||
user.save()
|
||||
except (ValidationError, IntegrityError) as exc:
|
||||
LOGGER.debug("Failed to set password", exc=exc)
|
||||
|
@ -44,13 +44,12 @@ class TokenBackend(InbuiltBackend):
|
||||
self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
|
||||
) -> User | None:
|
||||
try:
|
||||
|
||||
user = User._default_manager.get_by_natural_key(username)
|
||||
|
||||
except User.DoesNotExist:
|
||||
# Run the default password hasher once to reduce the timing
|
||||
# difference between an existing and a nonexistent user (#20760).
|
||||
User().set_password(password)
|
||||
User().set_password(password, request=request)
|
||||
return None
|
||||
|
||||
tokens = Token.filter_not_expired(
|
||||
|
@ -58,6 +58,7 @@ class PropertyMappingEvaluator(BaseEvaluator):
|
||||
self._context["user"] = user
|
||||
if request:
|
||||
req.http_request = request
|
||||
self._context["http_request"] = request
|
||||
req.context.update(**kwargs)
|
||||
self._context["request"] = req
|
||||
self._context.update(**kwargs)
|
||||
|
@ -17,7 +17,9 @@ from authentik.events.middleware import should_log_model
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import model_to_dict
|
||||
|
||||
BANNER_TEXT = f"""### authentik shell ({get_full_version()})
|
||||
|
||||
def get_banner_text(shell_type="shell") -> str:
|
||||
return f"""### authentik {shell_type} ({get_full_version()})
|
||||
### Node {platform.node()} | Arch {platform.machine()} | Python {platform.python_version()} """
|
||||
|
||||
|
||||
@ -114,4 +116,4 @@ class Command(BaseCommand):
|
||||
readline.parse_and_bind("tab: complete")
|
||||
|
||||
# Run interactive shell
|
||||
code.interact(banner=BANNER_TEXT, local=namespace)
|
||||
code.interact(banner=get_banner_text(), local=namespace)
|
||||
|
@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0041_applicationentitlement"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="authenticatedsession",
|
||||
index=models.Index(fields=["expires"], name="authentik_c_expires_08251d_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="authenticatedsession",
|
||||
index=models.Index(fields=["expiring"], name="authentik_c_expirin_9cd839_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="authenticatedsession",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_c_expirin_195a84_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="authenticatedsession",
|
||||
index=models.Index(fields=["session_key"], name="authentik_c_session_d0f005_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="token",
|
||||
index=models.Index(fields=["expires"], name="authentik_c_expires_a62b4b_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="token",
|
||||
index=models.Index(fields=["expiring"], name="authentik_c_expirin_a1b838_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="token",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_c_expirin_ba04d9_idx"
|
||||
),
|
||||
),
|
||||
]
|
@ -356,13 +356,13 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
|
||||
"""superuser == staff user"""
|
||||
return self.is_superuser # type: ignore
|
||||
|
||||
def set_password(self, raw_password, signal=True, sender=None):
|
||||
def set_password(self, raw_password, signal=True, sender=None, request=None):
|
||||
if self.pk and signal:
|
||||
from authentik.core.signals import password_changed
|
||||
|
||||
if not sender:
|
||||
sender = self
|
||||
password_changed.send(sender=sender, user=self, password=raw_password)
|
||||
password_changed.send(sender=sender, user=self, password=raw_password, request=request)
|
||||
self.password_change_date = now()
|
||||
return super().set_password(raw_password)
|
||||
|
||||
@ -846,6 +846,11 @@ class ExpiringModel(models.Model):
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
indexes = [
|
||||
models.Index(fields=["expires"]),
|
||||
models.Index(fields=["expiring"]),
|
||||
models.Index(fields=["expiring", "expires"]),
|
||||
]
|
||||
|
||||
def expire_action(self, *args, **kwargs):
|
||||
"""Handler which is called when this object is expired. By
|
||||
@ -901,7 +906,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
|
||||
class Meta:
|
||||
verbose_name = _("Token")
|
||||
verbose_name_plural = _("Tokens")
|
||||
indexes = [
|
||||
indexes = ExpiringModel.Meta.indexes + [
|
||||
models.Index(fields=["identifier"]),
|
||||
models.Index(fields=["key"]),
|
||||
]
|
||||
@ -1001,6 +1006,9 @@ class AuthenticatedSession(ExpiringModel):
|
||||
class Meta:
|
||||
verbose_name = _("Authenticated Session")
|
||||
verbose_name_plural = _("Authenticated Sessions")
|
||||
indexes = ExpiringModel.Meta.indexes + [
|
||||
models.Index(fields=["session_key"]),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Authenticated Session {self.session_key[:10]}"
|
||||
|
@ -18,7 +18,6 @@ from authentik.core.models import (
|
||||
)
|
||||
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.db import qs_batch_iter
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -35,14 +34,14 @@ def clean_expired_models(self: SystemTask):
|
||||
cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now())
|
||||
)
|
||||
amount = objects.count()
|
||||
for obj in qs_batch_iter(objects):
|
||||
for obj in objects:
|
||||
obj.expire_action()
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
# Special case
|
||||
amount = 0
|
||||
|
||||
for session in qs_batch_iter(AuthenticatedSession.objects.all()):
|
||||
for session in AuthenticatedSession.objects.all():
|
||||
match CONFIG.get("session_storage", "cache"):
|
||||
case "cache":
|
||||
cache_key = f"{KEY_PREFIX}{session.session_key}"
|
||||
|
@ -28,7 +28,6 @@ from rest_framework.validators import UniqueValidator
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authorization import SecretKeyFilter
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.crypto.apps import MANAGED_KEY
|
||||
@ -36,7 +35,7 @@ from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.filters import ObjectFilter
|
||||
from authentik.rbac.filters import ObjectFilter, SecretKeyFilter
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_enterprise", "0003_remove_licenseusage_within_limits_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="licenseusage",
|
||||
index=models.Index(fields=["expires"], name="authentik_e_expires_3f2956_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="licenseusage",
|
||||
index=models.Index(fields=["expiring"], name="authentik_e_expirin_11d3d7_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="licenseusage",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_e_expirin_4d558f_idx"
|
||||
),
|
||||
),
|
||||
]
|
@ -93,3 +93,4 @@ class LicenseUsage(ExpiringModel):
|
||||
class Meta:
|
||||
verbose_name = _("License Usage")
|
||||
verbose_name_plural = _("License Usage Records")
|
||||
indexes = ExpiringModel.Meta.indexes
|
||||
|
@ -1,11 +1,8 @@
|
||||
"""RAC Provider API Views"""
|
||||
|
||||
from django_filters.rest_framework.backends import DjangoFilterBackend
|
||||
from rest_framework import mixins
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
@ -34,12 +31,6 @@ class ConnectionTokenSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
class ConnectionTokenOwnerFilter(OwnerFilter):
|
||||
"""Owner filter for connection tokens (checks session's user)"""
|
||||
|
||||
owner_key = "session__user"
|
||||
|
||||
|
||||
class ConnectionTokenViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@ -55,10 +46,4 @@ class ConnectionTokenViewSet(
|
||||
filterset_fields = ["endpoint", "session__user", "provider"]
|
||||
search_fields = ["endpoint__name", "provider__name"]
|
||||
ordering = ["endpoint__name", "provider__name"]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [
|
||||
ConnectionTokenOwnerFilter,
|
||||
DjangoFilterBackend,
|
||||
OrderingFilter,
|
||||
SearchFilter,
|
||||
]
|
||||
owner_field = "session__user"
|
||||
|
@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
|
||||
("authentik_providers_rac", "0005_alter_racpropertymapping_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="connectiontoken",
|
||||
index=models.Index(fields=["expires"], name="authentik_p_expires_91f148_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="connectiontoken",
|
||||
index=models.Index(fields=["expiring"], name="authentik_p_expirin_59a5a7_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="connectiontoken",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_p_expirin_aed3ca_idx"
|
||||
),
|
||||
),
|
||||
]
|
@ -159,9 +159,9 @@ class ConnectionToken(ExpiringModel):
|
||||
default_settings["port"] = str(port)
|
||||
else:
|
||||
default_settings["hostname"] = self.endpoint.host
|
||||
default_settings["client-name"] = "authentik"
|
||||
# default_settings["enable-drive"] = "true"
|
||||
# default_settings["drive-name"] = "authentik"
|
||||
if self.endpoint.protocol == Protocols.RDP:
|
||||
default_settings["resize-method"] = "display-update"
|
||||
default_settings["client-name"] = f"authentik - {self.session.user}"
|
||||
settings = {}
|
||||
always_merger.merge(settings, default_settings)
|
||||
always_merger.merge(settings, self.endpoint.provider.settings)
|
||||
@ -211,3 +211,4 @@ class ConnectionToken(ExpiringModel):
|
||||
class Meta:
|
||||
verbose_name = _("RAC Connection token")
|
||||
verbose_name_plural = _("RAC Connection tokens")
|
||||
indexes = ExpiringModel.Meta.indexes
|
||||
|
@ -50,9 +50,10 @@ class TestModels(TransactionTestCase):
|
||||
{
|
||||
"hostname": self.endpoint.host.split(":")[0],
|
||||
"port": "1324",
|
||||
"client-name": "authentik",
|
||||
"client-name": f"authentik - {self.user}",
|
||||
"drive-path": path,
|
||||
"create-drive-path": "true",
|
||||
"resize-method": "display-update",
|
||||
},
|
||||
)
|
||||
# Set settings in provider
|
||||
@ -63,10 +64,11 @@ class TestModels(TransactionTestCase):
|
||||
{
|
||||
"hostname": self.endpoint.host.split(":")[0],
|
||||
"port": "1324",
|
||||
"client-name": "authentik",
|
||||
"client-name": f"authentik - {self.user}",
|
||||
"drive-path": path,
|
||||
"create-drive-path": "true",
|
||||
"level": "provider",
|
||||
"resize-method": "display-update",
|
||||
},
|
||||
)
|
||||
# Set settings in endpoint
|
||||
@ -79,10 +81,11 @@ class TestModels(TransactionTestCase):
|
||||
{
|
||||
"hostname": self.endpoint.host.split(":")[0],
|
||||
"port": "1324",
|
||||
"client-name": "authentik",
|
||||
"client-name": f"authentik - {self.user}",
|
||||
"drive-path": path,
|
||||
"create-drive-path": "true",
|
||||
"level": "endpoint",
|
||||
"resize-method": "display-update",
|
||||
},
|
||||
)
|
||||
# Set settings in token
|
||||
@ -95,10 +98,11 @@ class TestModels(TransactionTestCase):
|
||||
{
|
||||
"hostname": self.endpoint.host.split(":")[0],
|
||||
"port": "1324",
|
||||
"client-name": "authentik",
|
||||
"client-name": f"authentik - {self.user}",
|
||||
"drive-path": path,
|
||||
"create-drive-path": "true",
|
||||
"level": "token",
|
||||
"resize-method": "display-update",
|
||||
},
|
||||
)
|
||||
# Set settings in property mapping (provider)
|
||||
@ -114,10 +118,11 @@ class TestModels(TransactionTestCase):
|
||||
{
|
||||
"hostname": self.endpoint.host.split(":")[0],
|
||||
"port": "1324",
|
||||
"client-name": "authentik",
|
||||
"client-name": f"authentik - {self.user}",
|
||||
"drive-path": path,
|
||||
"create-drive-path": "true",
|
||||
"level": "property_mapping_provider",
|
||||
"resize-method": "display-update",
|
||||
},
|
||||
)
|
||||
# Set settings in property mapping (endpoint)
|
||||
@ -135,11 +140,12 @@ class TestModels(TransactionTestCase):
|
||||
{
|
||||
"hostname": self.endpoint.host.split(":")[0],
|
||||
"port": "1324",
|
||||
"client-name": "authentik",
|
||||
"client-name": f"authentik - {self.user}",
|
||||
"drive-path": path,
|
||||
"create-drive-path": "true",
|
||||
"level": "property_mapping_endpoint",
|
||||
"foo": "true",
|
||||
"bar": "6",
|
||||
"resize-method": "display-update",
|
||||
},
|
||||
)
|
||||
|
@ -1,14 +1,11 @@
|
||||
"""AuthenticatorEndpointGDTCStage API Views"""
|
||||
|
||||
from django_filters.rest_framework.backends import DjangoFilterBackend
|
||||
from rest_framework import mixins
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
|
||||
@ -67,8 +64,7 @@ class EndpointDeviceViewSet(
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
permission_classes = [OwnerPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class EndpointAdminDeviceViewSet(ModelViewSet):
|
||||
|
@ -1,17 +1,15 @@
|
||||
"""Notification API Views"""
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import ReadOnlyField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.events.api.events import EventSerializer
|
||||
@ -57,8 +55,7 @@ class NotificationViewSet(
|
||||
"seen",
|
||||
"user",
|
||||
]
|
||||
permission_classes = [OwnerPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
owner_field = "user"
|
||||
|
||||
@extend_schema(
|
||||
request=OpenApiTypes.NONE,
|
||||
@ -66,7 +63,7 @@ class NotificationViewSet(
|
||||
204: OpenApiResponse(description="Marked tasks as read successfully."),
|
||||
},
|
||||
)
|
||||
@action(detail=False, methods=["post"])
|
||||
@action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
|
||||
def mark_all_seen(self, request: Request) -> Response:
|
||||
"""Mark all the user's notifications as seen"""
|
||||
Notification.objects.filter(user=request.user, seen=False).update(seen=True)
|
||||
|
@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0007_event_authentik_e_action_9a9dd9_idx_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="event",
|
||||
index=models.Index(fields=["expires"], name="authentik_e_expires_8c73a8_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="event",
|
||||
index=models.Index(fields=["expiring"], name="authentik_e_expirin_b5cb5e_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="event",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_e_expirin_e37180_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="systemtask",
|
||||
index=models.Index(fields=["expires"], name="authentik_e_expires_4d3985_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="systemtask",
|
||||
index=models.Index(fields=["expiring"], name="authentik_e_expirin_81d649_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="systemtask",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_e_expirin_eb3598_idx"
|
||||
),
|
||||
),
|
||||
]
|
@ -306,7 +306,7 @@ class Event(SerializerModel, ExpiringModel):
|
||||
class Meta:
|
||||
verbose_name = _("Event")
|
||||
verbose_name_plural = _("Events")
|
||||
indexes = [
|
||||
indexes = ExpiringModel.Meta.indexes + [
|
||||
models.Index(fields=["action"]),
|
||||
models.Index(fields=["user"]),
|
||||
models.Index(fields=["app"]),
|
||||
@ -694,3 +694,4 @@ class SystemTask(SerializerModel, ExpiringModel):
|
||||
permissions = [("run_task", _("Run task"))]
|
||||
verbose_name = _("System Task")
|
||||
verbose_name_plural = _("System Tasks")
|
||||
indexes = ExpiringModel.Meta.indexes
|
||||
|
@ -106,9 +106,9 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_
|
||||
|
||||
|
||||
@receiver(password_changed)
|
||||
def on_password_changed(sender, user: User, password: str, **_):
|
||||
def on_password_changed(sender, user: User, password: str, request: HttpRequest | None, **_):
|
||||
"""Log password change"""
|
||||
Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
|
||||
Event.new(EventAction.PASSWORD_SET).from_http(request, user=user)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Event)
|
||||
|
@ -15,7 +15,6 @@ from authentik.events.models import (
|
||||
TaskStatus,
|
||||
)
|
||||
from authentik.events.system_tasks import SystemTask, prefill_task
|
||||
from authentik.lib.utils.db import qs_batch_iter
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.models import PolicyBinding, PolicyEngineMode
|
||||
from authentik.root.celery import CELERY_APP
|
||||
@ -130,8 +129,7 @@ def gdpr_cleanup(user_pk: int):
|
||||
"""cleanup events from gdpr_compliance"""
|
||||
events = Event.objects.filter(user__pk=user_pk)
|
||||
LOGGER.debug("GDPR cleanup, removing events from user", events=events.count())
|
||||
for event in qs_batch_iter(events):
|
||||
event.delete()
|
||||
events.delete()
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=SystemTask)
|
||||
@ -140,7 +138,6 @@ def notification_cleanup(self: SystemTask):
|
||||
"""Cleanup seen notifications and notifications whose event expired."""
|
||||
notifications = Notification.objects.filter(Q(event=None) | Q(seen=True))
|
||||
amount = notifications.count()
|
||||
for notification in qs_batch_iter(notifications):
|
||||
notification.delete()
|
||||
notifications.delete()
|
||||
LOGGER.debug("Expired notifications", amount=amount)
|
||||
self.set_status(TaskStatus.SUCCESSFUL, f"Expired {amount} Notifications")
|
||||
|
@ -1,5 +1,7 @@
|
||||
"""Flow Stage API Views"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from django.urls.base import reverse
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import mixins
|
||||
@ -27,6 +29,11 @@ class StageSerializer(ModelSerializer, MetaNameSerializer):
|
||||
component = SerializerMethodField()
|
||||
flow_set = FlowSetSerializer(many=True, required=False)
|
||||
|
||||
def to_representation(self, instance: Stage):
|
||||
if isinstance(instance, Stage) and instance.is_in_memory:
|
||||
instance.stage_uuid = uuid4()
|
||||
return super().to_representation(instance)
|
||||
|
||||
def get_component(self, obj: Stage) -> str:
|
||||
"""Get object type so that we know how to edit the object"""
|
||||
if obj.__class__ == Stage:
|
||||
|
@ -88,7 +88,8 @@ class Migration(migrations.Migration):
|
||||
model_name="flowstagebinding",
|
||||
name="re_evaluate_policies",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Evaluate policies when the Stage is present to the user."
|
||||
default=False,
|
||||
help_text="Evaluate policies when the Stage is presented to the user.",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
|
@ -20,7 +20,7 @@ class Migration(migrations.Migration):
|
||||
model_name="flowstagebinding",
|
||||
name="re_evaluate_policies",
|
||||
field=models.BooleanField(
|
||||
default=True, help_text="Evaluate policies when the Stage is present to the user."
|
||||
default=True, help_text="Evaluate policies when the Stage is presented to the user."
|
||||
),
|
||||
),
|
||||
]
|
||||
|
@ -102,8 +102,12 @@ class Stage(SerializerModel):
|
||||
user settings are available, or a challenge."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_in_memory(self):
|
||||
return hasattr(self, "__in_memory_type")
|
||||
|
||||
def __str__(self):
|
||||
if hasattr(self, "__in_memory_type"):
|
||||
if self.is_in_memory:
|
||||
return f"In-memory Stage {getattr(self, '__in_memory_type')}"
|
||||
return f"Stage {self.name}"
|
||||
|
||||
@ -227,7 +231,7 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
|
||||
)
|
||||
re_evaluate_policies = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_("Evaluate policies when the Stage is present to the user."),
|
||||
help_text=_("Evaluate policies when the Stage is presented to the user."),
|
||||
)
|
||||
|
||||
invalid_response_action = models.TextField(
|
||||
|
@ -159,9 +159,17 @@ class FlowPlan:
|
||||
stage = final_stage(request=request, executor=temp_exec)
|
||||
return stage.dispatch(request)
|
||||
|
||||
get_qs = request.GET.copy()
|
||||
if request.user.is_authenticated and (
|
||||
# Object-scoped permission or global permission
|
||||
request.user.has_perm("authentik_flows.inspect_flow", flow)
|
||||
or request.user.has_perm("authentik_flows.inspect_flow")
|
||||
):
|
||||
get_qs["inspector"] = "available"
|
||||
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
request.GET,
|
||||
get_qs,
|
||||
flow_slug=flow.slug,
|
||||
)
|
||||
|
||||
|
@ -7,8 +7,8 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||
from authentik.flows.markers import ReevaluateMarker, StageMarker
|
||||
from authentik.flows.models import (
|
||||
FlowDeniedAction,
|
||||
@ -255,7 +255,11 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
|
||||
binding = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=0,
|
||||
evaluate_on_plan=True,
|
||||
re_evaluate_policies=False,
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
@ -278,8 +282,8 @@ class TestFlowExecutor(FlowTestCase):
|
||||
self.assertEqual(plan.bindings[0], binding)
|
||||
self.assertEqual(plan.bindings[1], binding2)
|
||||
|
||||
self.assertIsInstance(plan.markers[0], StageMarker)
|
||||
self.assertIsInstance(plan.markers[1], ReevaluateMarker)
|
||||
self.assertEqual(plan.markers[0].__class__, StageMarker)
|
||||
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker)
|
||||
|
||||
# Second request, this passes the first dummy stage
|
||||
response = self.client.post(exec_url)
|
||||
@ -301,7 +305,11 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
|
||||
binding = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=0,
|
||||
evaluate_on_plan=True,
|
||||
re_evaluate_policies=False,
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
@ -310,7 +318,11 @@ class TestFlowExecutor(FlowTestCase):
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
binding3 = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=2,
|
||||
evaluate_on_plan=True,
|
||||
re_evaluate_policies=False,
|
||||
)
|
||||
|
||||
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
||||
@ -328,9 +340,9 @@ class TestFlowExecutor(FlowTestCase):
|
||||
self.assertEqual(plan.bindings[1], binding2)
|
||||
self.assertEqual(plan.bindings[2], binding3)
|
||||
|
||||
self.assertIsInstance(plan.markers[0], StageMarker)
|
||||
self.assertIsInstance(plan.markers[1], ReevaluateMarker)
|
||||
self.assertIsInstance(plan.markers[2], StageMarker)
|
||||
self.assertEqual(plan.markers[0].__class__, StageMarker)
|
||||
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker)
|
||||
self.assertEqual(plan.markers[2].__class__, StageMarker)
|
||||
|
||||
# Second request, this passes the first dummy stage
|
||||
response = self.client.post(exec_url)
|
||||
@ -341,8 +353,8 @@ class TestFlowExecutor(FlowTestCase):
|
||||
self.assertEqual(plan.bindings[0], binding2)
|
||||
self.assertEqual(plan.bindings[1], binding3)
|
||||
|
||||
self.assertIsInstance(plan.markers[0], StageMarker)
|
||||
self.assertIsInstance(plan.markers[1], StageMarker)
|
||||
self.assertEqual(plan.markers[0].__class__, ReevaluateMarker)
|
||||
self.assertEqual(plan.markers[1].__class__, StageMarker)
|
||||
|
||||
# third request, this should trigger the re-evaluate
|
||||
# We do this request without the patch, so the policy results in false
|
||||
@ -360,7 +372,11 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
|
||||
binding = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=0,
|
||||
evaluate_on_plan=True,
|
||||
re_evaluate_policies=False,
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
@ -369,7 +385,11 @@ class TestFlowExecutor(FlowTestCase):
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
binding3 = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=2,
|
||||
evaluate_on_plan=True,
|
||||
re_evaluate_policies=False,
|
||||
)
|
||||
|
||||
PolicyBinding.objects.create(policy=true_policy, target=binding2, order=0)
|
||||
@ -387,9 +407,9 @@ class TestFlowExecutor(FlowTestCase):
|
||||
self.assertEqual(plan.bindings[1], binding2)
|
||||
self.assertEqual(plan.bindings[2], binding3)
|
||||
|
||||
self.assertIsInstance(plan.markers[0], StageMarker)
|
||||
self.assertIsInstance(plan.markers[1], ReevaluateMarker)
|
||||
self.assertIsInstance(plan.markers[2], StageMarker)
|
||||
self.assertEqual(plan.markers[0].__class__, StageMarker)
|
||||
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker)
|
||||
self.assertEqual(plan.markers[2].__class__, StageMarker)
|
||||
|
||||
# Second request, this passes the first dummy stage
|
||||
response = self.client.post(exec_url)
|
||||
@ -400,8 +420,8 @@ class TestFlowExecutor(FlowTestCase):
|
||||
self.assertEqual(plan.bindings[0], binding2)
|
||||
self.assertEqual(plan.bindings[1], binding3)
|
||||
|
||||
self.assertIsInstance(plan.markers[0], StageMarker)
|
||||
self.assertIsInstance(plan.markers[1], StageMarker)
|
||||
self.assertEqual(plan.markers[0].__class__, ReevaluateMarker)
|
||||
self.assertEqual(plan.markers[1].__class__, StageMarker)
|
||||
|
||||
# Third request, this passes the first dummy stage
|
||||
response = self.client.post(exec_url)
|
||||
@ -411,7 +431,7 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
self.assertEqual(plan.bindings[0], binding3)
|
||||
|
||||
self.assertIsInstance(plan.markers[0], StageMarker)
|
||||
self.assertEqual(plan.markers[0].__class__, StageMarker)
|
||||
|
||||
# third request, this should trigger the re-evaluate
|
||||
# We do this request without the patch, so the policy results in false
|
||||
@ -429,7 +449,11 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
|
||||
binding = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=0,
|
||||
evaluate_on_plan=True,
|
||||
re_evaluate_policies=False,
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
@ -444,7 +468,11 @@ class TestFlowExecutor(FlowTestCase):
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
binding4 = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=2,
|
||||
evaluate_on_plan=True,
|
||||
re_evaluate_policies=False,
|
||||
)
|
||||
|
||||
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
||||
@ -465,10 +493,10 @@ class TestFlowExecutor(FlowTestCase):
|
||||
self.assertEqual(plan.bindings[2], binding3)
|
||||
self.assertEqual(plan.bindings[3], binding4)
|
||||
|
||||
self.assertIsInstance(plan.markers[0], StageMarker)
|
||||
self.assertIsInstance(plan.markers[1], ReevaluateMarker)
|
||||
self.assertIsInstance(plan.markers[2], ReevaluateMarker)
|
||||
self.assertIsInstance(plan.markers[3], StageMarker)
|
||||
self.assertEqual(plan.markers[0].__class__, StageMarker)
|
||||
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker)
|
||||
self.assertEqual(plan.markers[2].__class__, ReevaluateMarker)
|
||||
self.assertEqual(plan.markers[3].__class__, StageMarker)
|
||||
|
||||
# Second request, this passes the first dummy stage
|
||||
response = self.client.post(exec_url)
|
||||
@ -519,9 +547,9 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
# Stage 0 is a deny stage that is added dynamically
|
||||
# when the reputation policy says so
|
||||
deny_stage = DenyStage.objects.create(name="deny")
|
||||
deny_stage = DenyStage.objects.create(name=generate_id())
|
||||
reputation_policy = ReputationPolicy.objects.create(
|
||||
name="reputation", threshold=-1, check_ip=False
|
||||
name=generate_id(), threshold=-1, check_ip=False
|
||||
)
|
||||
deny_binding = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
@ -534,7 +562,7 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
# Stage 1 is an identification stage
|
||||
ident_stage = IdentificationStage.objects.create(
|
||||
name="ident",
|
||||
name=generate_id(),
|
||||
user_fields=[UserFields.E_MAIL],
|
||||
pretend_user_exists=False,
|
||||
)
|
||||
@ -559,3 +587,64 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
response = self.client.post(exec_url, {"uid_field": "invalid-string"}, follow=True)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-access-denied")
|
||||
|
||||
def test_re_evaluate_group_binding(self):
|
||||
"""Test re-evaluate stage binding that has a policy binding to a group"""
|
||||
flow = create_test_flow()
|
||||
|
||||
user_group_membership = create_test_user()
|
||||
user_direct_binding = create_test_user()
|
||||
user_other = create_test_user()
|
||||
|
||||
group_a = Group.objects.create(name=generate_id())
|
||||
user_group_membership.ak_groups.add(group_a)
|
||||
|
||||
# Stage 0 is an identification stage
|
||||
ident_stage = IdentificationStage.objects.create(
|
||||
name=generate_id(),
|
||||
user_fields=[UserFields.USERNAME],
|
||||
pretend_user_exists=False,
|
||||
)
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
stage=ident_stage,
|
||||
order=0,
|
||||
)
|
||||
|
||||
# Stage 1 is a dummy stage that is only shown for users in group_a
|
||||
dummy_stage = DummyStage.objects.create(name=generate_id())
|
||||
dummy_binding = FlowStageBinding.objects.create(target=flow, stage=dummy_stage, order=1)
|
||||
PolicyBinding.objects.create(group=group_a, target=dummy_binding, order=0)
|
||||
PolicyBinding.objects.create(user=user_direct_binding, target=dummy_binding, order=0)
|
||||
|
||||
# Stage 2 is a deny stage that (in this case) only user_b will see
|
||||
deny_stage = DenyStage.objects.create(name=generate_id())
|
||||
FlowStageBinding.objects.create(target=flow, stage=deny_stage, order=2)
|
||||
|
||||
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
|
||||
with self.subTest(f"Test user access through group: {user_group_membership}"):
|
||||
self.client.logout()
|
||||
# First request, run the planner
|
||||
response = self.client.get(exec_url)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-identification")
|
||||
response = self.client.post(
|
||||
exec_url, {"uid_field": user_group_membership.username}, follow=True
|
||||
)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-dummy")
|
||||
with self.subTest(f"Test user access through user: {user_direct_binding}"):
|
||||
self.client.logout()
|
||||
# First request, run the planner
|
||||
response = self.client.get(exec_url)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-identification")
|
||||
response = self.client.post(
|
||||
exec_url, {"uid_field": user_direct_binding.username}, follow=True
|
||||
)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-dummy")
|
||||
with self.subTest(f"Test user has no access: {user_other}"):
|
||||
self.client.logout()
|
||||
# First request, run the planner
|
||||
response = self.client.get(exec_url)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-identification")
|
||||
response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-access-denied")
|
||||
|
@ -8,6 +8,7 @@ from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding, InvalidResponseAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.dummy.models import DummyStage
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
@ -26,7 +27,7 @@ class TestFlowInspector(APITestCase):
|
||||
|
||||
# Stage 1 is an identification stage
|
||||
ident_stage = IdentificationStage.objects.create(
|
||||
name="ident",
|
||||
name=generate_id(),
|
||||
user_fields=[UserFields.USERNAME],
|
||||
)
|
||||
FlowStageBinding.objects.create(
|
||||
@ -35,9 +36,8 @@ class TestFlowInspector(APITestCase):
|
||||
order=1,
|
||||
invalid_response_action=InvalidResponseAction.RESTART_WITH_CONTEXT,
|
||||
)
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy2"), order=1
|
||||
)
|
||||
dummy_stage = DummyStage.objects.create(name=generate_id())
|
||||
FlowStageBinding.objects.create(target=flow, stage=dummy_stage, order=1)
|
||||
|
||||
res = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
@ -68,9 +68,11 @@ class TestFlowInspector(APITestCase):
|
||||
)
|
||||
content = loads(ins.content)
|
||||
self.assertEqual(content["is_completed"], False)
|
||||
self.assertEqual(content["current_plan"]["current_stage"]["stage_obj"]["name"], "ident")
|
||||
self.assertEqual(
|
||||
content["current_plan"]["next_planned_stage"]["stage_obj"]["name"], "dummy2"
|
||||
content["current_plan"]["current_stage"]["stage_obj"]["name"], ident_stage.name
|
||||
)
|
||||
self.assertEqual(
|
||||
content["current_plan"]["next_planned_stage"]["stage_obj"]["name"], dummy_stage.name
|
||||
)
|
||||
|
||||
self.client.post(
|
||||
@ -84,8 +86,12 @@ class TestFlowInspector(APITestCase):
|
||||
)
|
||||
content = loads(ins.content)
|
||||
self.assertEqual(content["is_completed"], False)
|
||||
self.assertEqual(content["plans"][0]["current_stage"]["stage_obj"]["name"], "ident")
|
||||
self.assertEqual(content["current_plan"]["current_stage"]["stage_obj"]["name"], "dummy2")
|
||||
self.assertEqual(
|
||||
content["plans"][0]["current_stage"]["stage_obj"]["name"], ident_stage.name
|
||||
)
|
||||
self.assertEqual(
|
||||
content["current_plan"]["current_stage"]["stage_obj"]["name"], dummy_stage.name
|
||||
)
|
||||
self.assertEqual(
|
||||
content["current_plan"]["plan_context"]["pending_user"]["username"], self.admin.username
|
||||
)
|
||||
|
@ -29,6 +29,7 @@ from authentik.flows.planner import (
|
||||
cache_key,
|
||||
)
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import dummy_get_response
|
||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||
from authentik.outposts.models import Outpost
|
||||
@ -153,7 +154,7 @@ class TestFlowPlanner(TestCase):
|
||||
"""Test planner cache"""
|
||||
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
)
|
||||
request = self.request_factory.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
@ -172,7 +173,7 @@ class TestFlowPlanner(TestCase):
|
||||
"""Test planner with default_context"""
|
||||
flow = create_test_flow()
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
)
|
||||
|
||||
user = User.objects.create(username="test-user")
|
||||
@ -191,7 +192,7 @@ class TestFlowPlanner(TestCase):
|
||||
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name="dummy1"),
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=0,
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
@ -204,7 +205,7 @@ class TestFlowPlanner(TestCase):
|
||||
planner = FlowPlanner(flow)
|
||||
plan = planner.plan(request)
|
||||
|
||||
self.assertIsInstance(plan.markers[0], ReevaluateMarker)
|
||||
self.assertEqual(plan.markers[0].__class__, ReevaluateMarker)
|
||||
|
||||
def test_planner_reevaluate_actual(self):
|
||||
"""Test planner with re-evaluate"""
|
||||
@ -212,11 +213,14 @@ class TestFlowPlanner(TestCase):
|
||||
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
||||
|
||||
binding = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=0,
|
||||
re_evaluate_policies=False,
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name="dummy2"),
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=1,
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
@ -240,6 +244,8 @@ class TestFlowPlanner(TestCase):
|
||||
self.assertEqual(plan.bindings[0], binding)
|
||||
self.assertEqual(plan.bindings[1], binding2)
|
||||
|
||||
self.assertEqual(plan.markers[0].__class__, StageMarker)
|
||||
self.assertEqual(plan.markers[1].__class__, ReevaluateMarker)
|
||||
self.assertIsInstance(plan.markers[0], StageMarker)
|
||||
self.assertIsInstance(plan.markers[1], ReevaluateMarker)
|
||||
|
||||
|
@ -78,7 +78,9 @@ class FlowInspectorView(APIView):
|
||||
self.flow = get_object_or_404(Flow.objects.select_related(), slug=flow_slug)
|
||||
if settings.DEBUG:
|
||||
return
|
||||
if request.user.has_perm("authentik_flow.inspect_flow", self.flow):
|
||||
if request.user.has_perm(
|
||||
"authentik_flows.inspect_flow", self.flow
|
||||
) or request.user.has_perm("authentik_flows.inspect_flow"):
|
||||
return
|
||||
raise Http404
|
||||
|
||||
@ -94,6 +96,9 @@ class FlowInspectorView(APIView):
|
||||
"""Get current flow state and record it"""
|
||||
plans = []
|
||||
for plan in request.session.get(SESSION_KEY_HISTORY, []):
|
||||
plan: FlowPlan
|
||||
if plan.flow_pk != self.flow.pk.hex:
|
||||
continue
|
||||
plan_serializer = FlowInspectorPlanSerializer(
|
||||
instance=plan, context={"request": request}
|
||||
)
|
||||
|
@ -9,20 +9,25 @@ from typing import Any
|
||||
|
||||
from cachetools import TLRUCache, cached
|
||||
from django.core.exceptions import FieldError
|
||||
from django.http import HttpRequest
|
||||
from django.utils.text import slugify
|
||||
from django.utils.timezone import now
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.serializers import ValidationError
|
||||
from sentry_sdk import start_span
|
||||
from sentry_sdk.tracing import Span
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.events.models import Event
|
||||
from authentik.lib.expression.exceptions import ControlFlowException
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import Policy, PolicyBinding
|
||||
from authentik.policies.process import PolicyProcess
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
||||
from authentik.stages.authenticator import devices_for_user
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -56,6 +61,7 @@ class BaseEvaluator:
|
||||
"ak_logger": get_logger(self._filename).bind(),
|
||||
"ak_user_by": BaseEvaluator.expr_user_by,
|
||||
"ak_user_has_authenticator": BaseEvaluator.expr_func_user_has_authenticator,
|
||||
"ak_create_jwt": self.expr_create_jwt,
|
||||
"ip_address": ip_address,
|
||||
"ip_network": ip_network,
|
||||
"list_flatten": BaseEvaluator.expr_flatten,
|
||||
@ -182,6 +188,36 @@ class BaseEvaluator:
|
||||
proc = PolicyProcess(PolicyBinding(policy=policy), request=req, connection=None)
|
||||
return proc.profiling_wrapper()
|
||||
|
||||
def expr_create_jwt(
|
||||
self,
|
||||
user: User,
|
||||
provider: OAuth2Provider | str,
|
||||
scopes: list[str],
|
||||
validity: str = "seconds=60",
|
||||
) -> str | None:
|
||||
"""Issue a JWT for a given provider"""
|
||||
request: HttpRequest = self._context.get("http_request")
|
||||
if not request:
|
||||
return None
|
||||
if not isinstance(provider, OAuth2Provider):
|
||||
provider = OAuth2Provider.objects.get(name=provider)
|
||||
session = None
|
||||
if hasattr(request, "session") and request.session.session_key:
|
||||
session = AuthenticatedSession.objects.filter(
|
||||
session_key=request.session.session_key
|
||||
).first()
|
||||
access_token = AccessToken(
|
||||
provider=provider,
|
||||
user=user,
|
||||
expires=now() + timedelta_from_string(validity),
|
||||
scope=scopes,
|
||||
auth_time=now(),
|
||||
session=session,
|
||||
)
|
||||
access_token.id_token = IDToken.new(provider, access_token, request)
|
||||
access_token.save()
|
||||
return access_token.token
|
||||
|
||||
def wrap_expression(self, expression: str) -> str:
|
||||
"""Wrap expression in a function, call it, and save the result as `result`"""
|
||||
handler_signature = ",".join(sanitize_arg(x) for x in self._context.keys())
|
||||
|
@ -1,11 +1,15 @@
|
||||
"""Test Evaluator base functions"""
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
from jwt import decode
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user
|
||||
from authentik.events.models import Event
|
||||
from authentik.lib.expression.evaluator import BaseEvaluator
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
|
||||
|
||||
class TestEvaluator(TestCase):
|
||||
@ -41,3 +45,35 @@ class TestEvaluator(TestCase):
|
||||
event = Event.objects.filter(action="custom_foo").first()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event.context, {"bar": "baz", "foo": "bar"})
|
||||
|
||||
@apply_blueprint("system/providers-oauth2.yaml")
|
||||
def test_expr_create_jwt(self):
|
||||
"""Test expr_create_jwt"""
|
||||
rf = RequestFactory()
|
||||
user = create_test_user()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
)
|
||||
provider.property_mappings.set(
|
||||
ScopeMapping.objects.filter(
|
||||
managed__in=[
|
||||
"goauthentik.io/providers/oauth2/scope-openid",
|
||||
"goauthentik.io/providers/oauth2/scope-email",
|
||||
"goauthentik.io/providers/oauth2/scope-profile",
|
||||
]
|
||||
)
|
||||
)
|
||||
evaluator = BaseEvaluator(generate_id())
|
||||
evaluator._context = {
|
||||
"http_request": rf.get(reverse("authentik_core:root-redirect")),
|
||||
"user": user,
|
||||
"provider": provider.name,
|
||||
}
|
||||
jwt = evaluator.evaluate(
|
||||
"return ak_create_jwt(user, provider, ['openid', 'email', 'profile'])"
|
||||
)
|
||||
decoded = decode(
|
||||
jwt, provider.client_secret, algorithms=["HS256"], audience=provider.client_id
|
||||
)
|
||||
self.assertEqual(decoded["preferred_username"], user.username)
|
||||
|
@ -1,22 +0,0 @@
|
||||
"""authentik database utilities"""
|
||||
|
||||
import gc
|
||||
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
def qs_batch_iter(qs: QuerySet, batch_size: int = 10_000, gc_collect: bool = True):
|
||||
pk_iter = qs.values_list("pk", flat=True).order_by("pk").distinct().iterator()
|
||||
eof = False
|
||||
while not eof:
|
||||
pk_buffer = []
|
||||
i = 0
|
||||
try:
|
||||
while i < batch_size:
|
||||
pk_buffer.append(pk_iter.next())
|
||||
i += 1
|
||||
except StopIteration:
|
||||
eof = True
|
||||
yield from qs.filter(pk__in=pk_buffer).order_by("pk").iterator()
|
||||
if gc_collect:
|
||||
gc.collect()
|
@ -207,7 +207,7 @@ class KubernetesObjectReconciler(Generic[T]):
|
||||
"app.kubernetes.io/instance": slugify(self.controller.outpost.name),
|
||||
"app.kubernetes.io/managed-by": "goauthentik.io",
|
||||
"app.kubernetes.io/name": f"authentik-{self.controller.outpost.type.lower()}",
|
||||
"app.kubernetes.io/version": get_version(),
|
||||
"app.kubernetes.io/version": get_version().replace("+", "-"),
|
||||
"goauthentik.io/outpost-name": slugify(self.controller.outpost.name),
|
||||
"goauthentik.io/outpost-type": str(self.controller.outpost.type),
|
||||
"goauthentik.io/outpost-uuid": self.controller.outpost.uuid.hex,
|
||||
|
@ -94,7 +94,7 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
||||
meta = self.get_object_meta(name=self.name)
|
||||
image_name = self.controller.get_container_image()
|
||||
image_pull_secrets = self.outpost.config.kubernetes_image_pull_secrets
|
||||
version = get_full_version()
|
||||
version = get_full_version().replace("+", "-")
|
||||
return V1Deployment(
|
||||
metadata=meta,
|
||||
spec=V1DeploymentSpec(
|
||||
|
@ -13,7 +13,7 @@ if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class PrometheusServiceMonitorSpecEndpoint:
|
||||
"""Prometheus ServiceMonitor endpoint spec"""
|
||||
|
||||
@ -21,14 +21,14 @@ class PrometheusServiceMonitorSpecEndpoint:
|
||||
path: str = field(default="/metrics")
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class PrometheusServiceMonitorSpecSelector:
|
||||
"""Prometheus ServiceMonitor selector spec"""
|
||||
|
||||
matchLabels: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class PrometheusServiceMonitorSpec:
|
||||
"""Prometheus ServiceMonitor spec"""
|
||||
|
||||
@ -37,7 +37,7 @@ class PrometheusServiceMonitorSpec:
|
||||
selector: PrometheusServiceMonitorSpecSelector
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class PrometheusServiceMonitorMetadata:
|
||||
"""Prometheus ServiceMonitor metadata"""
|
||||
|
||||
@ -46,7 +46,7 @@ class PrometheusServiceMonitorMetadata:
|
||||
labels: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class PrometheusServiceMonitor:
|
||||
"""Prometheus ServiceMonitor"""
|
||||
|
||||
|
@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_policies_reputation",
|
||||
"0007_reputation_authentik_p_identif_9434d7_idx_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="reputation",
|
||||
index=models.Index(fields=["expires"], name="authentik_p_expires_da493f_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="reputation",
|
||||
index=models.Index(fields=["expiring"], name="authentik_p_expirin_2ab34f_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="reputation",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_p_expirin_2a8ec7_idx"
|
||||
),
|
||||
),
|
||||
]
|
@ -96,7 +96,7 @@ class Reputation(ExpiringModel, SerializerModel):
|
||||
verbose_name = _("Reputation Score")
|
||||
verbose_name_plural = _("Reputation Scores")
|
||||
unique_together = ("identifier", "ip")
|
||||
indexes = [
|
||||
indexes = ExpiringModel.Meta.indexes + [
|
||||
models.Index(fields=["identifier"]),
|
||||
models.Index(fields=["ip"]),
|
||||
models.Index(fields=["ip", "identifier"]),
|
||||
|
@ -0,0 +1,72 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
|
||||
("authentik_providers_oauth2", "0026_alter_accesstoken_session_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="accesstoken",
|
||||
index=models.Index(fields=["expires"], name="authentik_p_expires_9f24a5_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="accesstoken",
|
||||
index=models.Index(fields=["expiring"], name="authentik_p_expirin_2d9205_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="accesstoken",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_p_expirin_c74005_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="authorizationcode",
|
||||
index=models.Index(fields=["expires"], name="authentik_p_expires_f594b2_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="authorizationcode",
|
||||
index=models.Index(fields=["expiring"], name="authentik_p_expirin_6a5e2c_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="authorizationcode",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_p_expirin_c0f353_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="devicetoken",
|
||||
index=models.Index(fields=["expires"], name="authentik_p_expires_961437_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="devicetoken",
|
||||
index=models.Index(fields=["expiring"], name="authentik_p_expirin_4fd278_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="devicetoken",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_p_expirin_cd6b1c_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="refreshtoken",
|
||||
index=models.Index(fields=["expires"], name="authentik_p_expires_c479a7_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="refreshtoken",
|
||||
index=models.Index(fields=["expiring"], name="authentik_p_expirin_d4d17f_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="refreshtoken",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_p_expirin_acb4a5_idx"
|
||||
),
|
||||
),
|
||||
]
|
@ -425,6 +425,7 @@ class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
class Meta:
|
||||
verbose_name = _("Authorization Code")
|
||||
verbose_name_plural = _("Authorization Codes")
|
||||
indexes = ExpiringModel.Meta.indexes
|
||||
|
||||
def __str__(self):
|
||||
return f"Authorization code for {self.provider_id} for user {self.user_id}"
|
||||
@ -453,7 +454,7 @@ class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
_id_token = models.TextField()
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
indexes = ExpiringModel.Meta.indexes + [
|
||||
HashIndex(fields=["token"]),
|
||||
]
|
||||
verbose_name = _("OAuth2 Access Token")
|
||||
@ -504,7 +505,7 @@ class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
indexes = ExpiringModel.Meta.indexes + [
|
||||
HashIndex(fields=["token"]),
|
||||
]
|
||||
verbose_name = _("OAuth2 Refresh Token")
|
||||
@ -556,6 +557,7 @@ class DeviceToken(ExpiringModel):
|
||||
class Meta:
|
||||
verbose_name = _("Device Token")
|
||||
verbose_name_plural = _("Device Tokens")
|
||||
indexes = ExpiringModel.Meta.indexes
|
||||
|
||||
def __str__(self):
|
||||
return f"Device Token for {self.provider_id}"
|
||||
|
@ -49,7 +49,9 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
kwargs={
|
||||
"flow_slug": self.device_flow.slug,
|
||||
},
|
||||
),
|
||||
)
|
||||
+ "?"
|
||||
+ urlencode({"inspector": "available"}),
|
||||
)
|
||||
|
||||
def test_device_init_post(self):
|
||||
@ -63,7 +65,9 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
kwargs={
|
||||
"flow_slug": self.device_flow.slug,
|
||||
},
|
||||
),
|
||||
)
|
||||
+ "?"
|
||||
+ urlencode({"inspector": "available"}),
|
||||
)
|
||||
res = self.api_client.get(
|
||||
reverse(
|
||||
@ -118,7 +122,9 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
kwargs={
|
||||
"flow_slug": provider.authorization_flow.slug,
|
||||
},
|
||||
),
|
||||
)
|
||||
+ "?"
|
||||
+ urlencode({"inspector": "available"}),
|
||||
},
|
||||
)
|
||||
|
||||
@ -150,7 +156,7 @@ class TesOAuth2DeviceInit(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
+ "?"
|
||||
+ urlencode({QS_KEY_CODE: token.user_code}),
|
||||
+ urlencode({QS_KEY_CODE: token.user_code, "inspector": "available"}),
|
||||
)
|
||||
|
||||
def test_device_init_denied(self):
|
||||
|
@ -15,7 +15,7 @@ if TYPE_CHECKING:
|
||||
from authentik.outposts.controllers.kubernetes import KubernetesController
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class TraefikMiddlewareSpecForwardAuth:
|
||||
"""traefik middleware forwardAuth spec"""
|
||||
|
||||
@ -28,14 +28,14 @@ class TraefikMiddlewareSpecForwardAuth:
|
||||
trustForwardHeader: bool = field(default=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class TraefikMiddlewareSpec:
|
||||
"""Traefik middleware spec"""
|
||||
|
||||
forwardAuth: TraefikMiddlewareSpecForwardAuth
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class TraefikMiddlewareMetadata:
|
||||
"""Traefik Middleware metadata"""
|
||||
|
||||
@ -44,7 +44,7 @@ class TraefikMiddlewareMetadata:
|
||||
labels: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(slots=True)
|
||||
class TraefikMiddleware:
|
||||
"""Traefik Middleware"""
|
||||
|
||||
|
@ -16,6 +16,7 @@ from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, FileField, SerializerMethodField
|
||||
from rest_framework.parsers import MultiPartParser
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.renderers import BaseRenderer, JSONRenderer
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import PrimaryKeyRelatedField, ValidationError
|
||||
@ -38,6 +39,16 @@ from authentik.sources.saml.processors.constants import SAML_BINDING_POST, SAML_
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class RawXMLDataRenderer(BaseRenderer):
|
||||
"""Renderer to allow application/xml as value for 'Accept' in the metadata endpoint."""
|
||||
|
||||
media_type = "application/xml"
|
||||
format = "xml"
|
||||
|
||||
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||
return data
|
||||
|
||||
|
||||
class SAMLProviderSerializer(ProviderSerializer):
|
||||
"""SAMLProvider Serializer"""
|
||||
|
||||
@ -238,9 +249,21 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
],
|
||||
description="Optionally force the metadata to only include one binding.",
|
||||
),
|
||||
# Explicitly excluded, because otherwise spectacular automatically
|
||||
# add it when using multiple renderer_classes
|
||||
OpenApiParameter(
|
||||
name="format",
|
||||
exclude=True,
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
@action(methods=["GET"], detail=True, permission_classes=[AllowAny])
|
||||
@action(
|
||||
methods=["GET"],
|
||||
detail=True,
|
||||
permission_classes=[AllowAny],
|
||||
renderer_classes=[JSONRenderer, RawXMLDataRenderer],
|
||||
)
|
||||
def metadata(self, request: Request, pk: int) -> Response:
|
||||
"""Return metadata as XML string"""
|
||||
# We don't use self.get_object() on purpose as this view is un-authenticated
|
||||
@ -258,9 +281,9 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
f'attachment; filename="{provider.name}_authentik_meta.xml"'
|
||||
)
|
||||
return response
|
||||
return Response({"metadata": metadata})
|
||||
return Response({"metadata": metadata}, content_type="application/json")
|
||||
except Provider.application.RelatedObjectDoesNotExist:
|
||||
return Response({"metadata": ""})
|
||||
return Response({"metadata": ""}, content_type="application/json")
|
||||
|
||||
@permission_required(
|
||||
None,
|
||||
|
@ -256,7 +256,7 @@ class AssertionProcessor:
|
||||
assertion.attrib["IssueInstant"] = self._issue_instant
|
||||
assertion.append(self.get_issuer())
|
||||
|
||||
if self.provider.signing_kp:
|
||||
if self.provider.signing_kp and self.provider.sign_assertion:
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
@ -295,6 +295,18 @@ class AssertionProcessor:
|
||||
|
||||
response.append(self.get_issuer())
|
||||
|
||||
if self.provider.signing_kp and self.provider.sign_response:
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.provider.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
signature = xmlsec.template.create(
|
||||
response,
|
||||
xmlsec.constants.TransformExclC14N,
|
||||
sign_algorithm_transform,
|
||||
ns=xmlsec.constants.DSigNs,
|
||||
)
|
||||
response.append(signature)
|
||||
|
||||
status = SubElement(response, f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
status_code = SubElement(status, f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
status_code.attrib["Value"] = "urn:oasis:names:tc:SAML:2.0:status:Success"
|
||||
|
@ -104,6 +104,22 @@ class TestSAMLProviderAPI(APITestCase):
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertIn("Content-Disposition", response)
|
||||
# Test download with Accept: application/xml
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk})
|
||||
+ "?download",
|
||||
HTTP_ACCEPT="application/xml",
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertIn("Content-Disposition", response)
|
||||
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk})
|
||||
+ "?download",
|
||||
HTTP_ACCEPT="application/xml;charset=UTF-8",
|
||||
)
|
||||
self.assertEqual(200, response.status_code)
|
||||
self.assertIn("Content-Disposition", response)
|
||||
|
||||
def test_metadata_invalid(self):
|
||||
"""Test metadata export (invalid)"""
|
||||
@ -121,6 +137,11 @@ class TestSAMLProviderAPI(APITestCase):
|
||||
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": "abc"}),
|
||||
)
|
||||
self.assertEqual(404, response.status_code)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}),
|
||||
HTTP_ACCEPT="application/invalid-mime-type",
|
||||
)
|
||||
self.assertEqual(406, response.status_code)
|
||||
|
||||
def test_import_success(self):
|
||||
"""Test metadata import (success case)"""
|
||||
|
@ -2,8 +2,10 @@
|
||||
|
||||
from base64 import b64encode
|
||||
|
||||
from defusedxml.lxml import fromstring
|
||||
from django.http.request import QueryDict
|
||||
from django.test import TestCase
|
||||
from lxml import etree # nosec
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
@ -11,12 +13,14 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import get_request
|
||||
from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
||||
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
|
||||
from authentik.sources.saml.exceptions import MismatchedRequestID
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
NS_MAP,
|
||||
SAML_BINDING_REDIRECT,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
@ -185,6 +189,19 @@ class TestAuthNRequest(TestCase):
|
||||
self.assertEqual(response.count(response_proc._assertion_id), 2)
|
||||
self.assertEqual(response.count(response_proc._response_id), 2)
|
||||
|
||||
schema = etree.XMLSchema(
|
||||
etree.parse("schemas/saml-schema-protocol-2.0.xsd", parser=etree.XMLParser()) # nosec
|
||||
)
|
||||
self.assertTrue(schema.validate(lxml_from_string(response)))
|
||||
|
||||
response_xml = fromstring(response)
|
||||
self.assertEqual(
|
||||
len(response_xml.xpath("//saml:Assertion/ds:Signature", namespaces=NS_MAP)), 1
|
||||
)
|
||||
self.assertEqual(
|
||||
len(response_xml.xpath("//samlp:Response/ds:Signature", namespaces=NS_MAP)), 1
|
||||
)
|
||||
|
||||
# Now parse the response (source)
|
||||
http_request.POST = QueryDict(mutable=True)
|
||||
http_request.POST["SAMLResponse"] = b64encode(response.encode()).decode()
|
||||
|
@ -5,6 +5,7 @@ from django.contrib.auth.models import Permission
|
||||
from django.db.models import QuerySet
|
||||
from django_filters.filters import ModelChoiceFilter
|
||||
from django_filters.filterset import FilterSet
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import (
|
||||
CharField,
|
||||
@ -13,6 +14,8 @@ from rest_framework.fields import (
|
||||
ReadOnlyField,
|
||||
SerializerMethodField,
|
||||
)
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
@ -92,7 +95,9 @@ class RBACPermissionViewSet(ReadOnlyModelViewSet):
|
||||
queryset = Permission.objects.none()
|
||||
serializer_class = PermissionSerializer
|
||||
ordering = ["name"]
|
||||
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
filterset_class = PermissionFilter
|
||||
permission_classes = [IsAuthenticated]
|
||||
search_fields = [
|
||||
"codename",
|
||||
"content_type__model",
|
||||
|
@ -1,10 +1,15 @@
|
||||
"""RBAC API Filter"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import QuerySet
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.authentication import get_authorization_header
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_guardian.filters import ObjectPermissionsFilter
|
||||
|
||||
from authentik.api.authentication import validate_auth
|
||||
from authentik.core.models import UserTypes
|
||||
|
||||
|
||||
@ -12,7 +17,7 @@ class ObjectFilter(ObjectPermissionsFilter):
|
||||
"""Object permission filter that grants global permission higher priority than
|
||||
per-object permissions"""
|
||||
|
||||
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
|
||||
def filter_queryset(self, request: Request, queryset: QuerySet, view: APIView) -> QuerySet:
|
||||
permission = self.perm_format % {
|
||||
"app_label": queryset.model._meta.app_label,
|
||||
"model_name": queryset.model._meta.model_name,
|
||||
@ -21,6 +26,9 @@ class ObjectFilter(ObjectPermissionsFilter):
|
||||
# per-object permissions
|
||||
if request.user.has_perm(permission):
|
||||
return queryset
|
||||
# User does not have permissions, but we have an owner field defined, so filter by that
|
||||
if owner_field := getattr(view, "owner_field", None):
|
||||
return queryset.filter(**{owner_field: request.user})
|
||||
queryset = super().filter_queryset(request, queryset, view)
|
||||
# Outposts (which are the only objects using internal service accounts)
|
||||
# except requests to return an empty list when they have no objects
|
||||
@ -32,3 +40,17 @@ class ObjectFilter(ObjectPermissionsFilter):
|
||||
# and also no object permissions assigned (directly or via role)
|
||||
raise PermissionDenied()
|
||||
return queryset
|
||||
|
||||
|
||||
class SecretKeyFilter(DjangoFilterBackend):
|
||||
"""Allow access to all objects when authenticated with secret key as token.
|
||||
|
||||
Replaces both DjangoFilterBackend and ObjectFilter"""
|
||||
|
||||
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
|
||||
auth_header = get_authorization_header(request)
|
||||
token = validate_auth(auth_header)
|
||||
if token and token == settings.SECRET_KEY:
|
||||
return queryset
|
||||
queryset = ObjectFilter().filter_queryset(request, queryset, view)
|
||||
return super().filter_queryset(request, queryset, view)
|
||||
|
@ -15,6 +15,17 @@ class ObjectPermissions(DjangoObjectPermissions):
|
||||
lookup = getattr(view, "lookup_url_kwarg", None) or getattr(view, "lookup_field", None)
|
||||
if lookup and lookup in view.kwargs:
|
||||
return True
|
||||
# Legacy behaviour:
|
||||
# Allow creation of objects even without explicit permission
|
||||
queryset = self._queryset(view)
|
||||
required_perms = self.get_required_permissions(request.method, queryset.model)
|
||||
if (
|
||||
len(required_perms) == 1
|
||||
and f"{queryset.model._meta.app_label}.add_{queryset.model._meta.model_name}"
|
||||
in required_perms
|
||||
and getattr(view, "rbac_allow_create_without_perm", False)
|
||||
):
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
|
||||
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
|
||||
@ -24,6 +35,10 @@ class ObjectPermissions(DjangoObjectPermissions):
|
||||
# Rank global permissions higher than per-object permissions
|
||||
if request.user.has_perms(perms):
|
||||
return True
|
||||
# Allow access for owners if configured
|
||||
if owner_field := getattr(view, "owner_field", None):
|
||||
if getattr(obj, owner_field) == request.user:
|
||||
return True
|
||||
return super().has_object_permission(request, view, obj)
|
||||
|
||||
|
||||
|
@ -18,6 +18,7 @@ from celery.signals import (
|
||||
task_prerun,
|
||||
worker_ready,
|
||||
)
|
||||
from celery.worker.control import inspect_command
|
||||
from django.conf import settings
|
||||
from django.db import ProgrammingError
|
||||
from django_tenants.utils import get_public_schema_name
|
||||
@ -25,6 +26,7 @@ from structlog.contextvars import STRUCTLOG_KEY_PREFIX
|
||||
from structlog.stdlib import get_logger
|
||||
from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp
|
||||
|
||||
from authentik import get_full_version
|
||||
from authentik.lib.sentry import before_send
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
|
||||
@ -159,6 +161,12 @@ class LivenessProbe(bootsteps.StartStopStep):
|
||||
HEARTBEAT_FILE.touch()
|
||||
|
||||
|
||||
@inspect_command(default_timeout=0.2)
|
||||
def ping(state, **kwargs):
|
||||
"""Ping worker(s)."""
|
||||
return {"ok": "pong", "version": get_full_version()}
|
||||
|
||||
|
||||
CELERY_APP.config_from_object(settings.CELERY)
|
||||
|
||||
# Load task modules from all registered Django app configs.
|
||||
|
@ -31,6 +31,8 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
|
||||
if kwargs.get("randomly_seed", None):
|
||||
self.args.append(f"--randomly-seed={kwargs['randomly_seed']}")
|
||||
if kwargs.get("no_capture"):
|
||||
self.args.append("-s")
|
||||
|
||||
settings.TEST = True
|
||||
settings.CELERY["task_always_eager"] = True
|
||||
@ -56,6 +58,10 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
|
||||
def add_arguments(cls, parser: ArgumentParser):
|
||||
"""Add more pytest-specific arguments"""
|
||||
DiscoverRunner.add_arguments(parser)
|
||||
parser.add_argument(
|
||||
"--no-capture",
|
||||
action="store_true",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--randomly-seed",
|
||||
type=int,
|
||||
|
@ -1,10 +1,7 @@
|
||||
"""Kerberos Source Serializer"""
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
|
||||
from authentik.core.api.sources import (
|
||||
GroupSourceConnectionSerializer,
|
||||
GroupSourceConnectionViewSet,
|
||||
@ -32,9 +29,8 @@ class UserKerberosSourceConnectionViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = UserKerberosSourceConnectionSerializer
|
||||
filterset_fields = ["source__slug"]
|
||||
search_fields = ["source__slug"]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
ordering = ["source__slug"]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class GroupKerberosSourceConnectionSerializer(GroupSourceConnectionSerializer):
|
||||
|
@ -28,17 +28,19 @@ class KerberosBackend(InbuiltBackend):
|
||||
if "@" in username:
|
||||
username, realm = username.rsplit("@", 1)
|
||||
|
||||
user, source = self.auth_user(username, realm, **kwargs)
|
||||
user, source = self.auth_user(request, username, realm, **kwargs)
|
||||
if user:
|
||||
self.set_method("kerberos", request, source=source)
|
||||
return user
|
||||
return None
|
||||
|
||||
def auth_user(
|
||||
self, username: str, realm: str | None, password: str, **filters
|
||||
self, request: HttpRequest, username: str, realm: str | None, password: str, **filters
|
||||
) -> tuple[User | None, KerberosSource | None]:
|
||||
sources = KerberosSource.objects.filter(enabled=True)
|
||||
user = User.objects.filter(usersourceconnection__source__in=sources, **filters).first()
|
||||
user = User.objects.filter(
|
||||
usersourceconnection__source__in=sources, username=username, **filters
|
||||
).first()
|
||||
|
||||
if user is not None:
|
||||
# User found, let's get its connections for the sources that are available
|
||||
@ -74,10 +76,10 @@ class KerberosBackend(InbuiltBackend):
|
||||
user=user_source_connection.user,
|
||||
)
|
||||
user_source_connection.user.set_password(
|
||||
password, sender=user_source_connection.source
|
||||
password, sender=user_source_connection.source, request=request
|
||||
)
|
||||
user_source_connection.user.save()
|
||||
return user, user_source_connection.source
|
||||
return user_source_connection.user, user_source_connection.source
|
||||
# Password doesn't match, onto next source
|
||||
LOGGER.debug(
|
||||
"failed to kinit, password invalid",
|
||||
|
@ -20,13 +20,15 @@ class LDAPBackend(InbuiltBackend):
|
||||
return None
|
||||
for source in LDAPSource.objects.filter(enabled=True):
|
||||
LOGGER.debug("LDAP Auth attempt", source=source)
|
||||
user = self.auth_user(source, **kwargs)
|
||||
user = self.auth_user(request, source, **kwargs)
|
||||
if user:
|
||||
self.set_method("ldap", request, source=source)
|
||||
return user
|
||||
return None
|
||||
|
||||
def auth_user(self, source: LDAPSource, password: str, **filters: str) -> User | None:
|
||||
def auth_user(
|
||||
self, request: HttpRequest, source: LDAPSource, password: str, **filters: str
|
||||
) -> User | None:
|
||||
"""Try to bind as either user_dn or mail with password.
|
||||
Returns True on success, otherwise False"""
|
||||
users = User.objects.filter(**filters)
|
||||
@ -43,7 +45,7 @@ class LDAPBackend(InbuiltBackend):
|
||||
if source.password_login_update_internal_password:
|
||||
# Password given successfully binds to LDAP, so we save it in our Database
|
||||
LOGGER.debug("Updating user's password in DB", user=user)
|
||||
user.set_password(password, sender=source)
|
||||
user.set_password(password, sender=source, request=request)
|
||||
user.save()
|
||||
return user
|
||||
# Password doesn't match
|
||||
|
@ -88,6 +88,55 @@ class TestSCIMUsers(APITestCase):
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_user_create_duplicate_by_username(self):
|
||||
"""Test user create"""
|
||||
user = create_test_user()
|
||||
username = generate_id()
|
||||
obj1 = {
|
||||
"userName": username,
|
||||
"externalId": generate_id(),
|
||||
"emails": [
|
||||
{
|
||||
"primary": True,
|
||||
"value": user.email,
|
||||
}
|
||||
],
|
||||
}
|
||||
obj2 = obj1.copy()
|
||||
obj2.update({"externalId": generate_id()})
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data=dumps(obj1),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(
|
||||
SCIMSourceUser.objects.filter(source=self.source, user__username=username).exists()
|
||||
)
|
||||
self.assertTrue(
|
||||
Event.objects.filter(
|
||||
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
|
||||
).exists()
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_sources_scim:v2-users",
|
||||
kwargs={
|
||||
"source_slug": self.source.slug,
|
||||
},
|
||||
),
|
||||
data=dumps(obj2),
|
||||
content_type=SCIM_CONTENT_TYPE,
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
|
||||
)
|
||||
self.assertEqual(response.status_code, 409)
|
||||
|
||||
def test_user_property_mappings(self):
|
||||
"""Test user property_mappings"""
|
||||
self.source.user_property_mappings.set(
|
||||
|
@ -2,6 +2,7 @@
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db.models import Q
|
||||
from django.db.transaction import atomic
|
||||
from django.http import Http404, QueryDict
|
||||
from django.urls import reverse
|
||||
@ -113,8 +114,11 @@ class UsersView(SCIMObjectView):
|
||||
def post(self, request: Request, **kwargs) -> Response:
|
||||
"""Create user handler"""
|
||||
connection = SCIMSourceUser.objects.filter(
|
||||
Q(
|
||||
Q(user__uuid=request.data.get("id"))
|
||||
| Q(user__username=request.data.get("userName"))
|
||||
),
|
||||
source=self.source,
|
||||
user__uuid=request.data.get("id"),
|
||||
).first()
|
||||
if connection:
|
||||
self.logger.debug("Found existing user")
|
||||
|
@ -1,20 +1,18 @@
|
||||
"""AuthenticatorDuoStage API Views"""
|
||||
|
||||
from django.http import Http404
|
||||
from django_filters.rest_framework.backends import DjangoFilterBackend
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, ChoiceField, IntegerField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
@ -168,9 +166,11 @@ class AuthenticatorDuoStageViewSet(UsedByMixin, ModelViewSet):
|
||||
class DuoDeviceSerializer(ModelSerializer):
|
||||
"""Serializer for Duo authenticator devices"""
|
||||
|
||||
user = GroupMemberSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DuoDevice
|
||||
fields = ["pk", "name"]
|
||||
fields = ["pk", "name", "user"]
|
||||
depth = 2
|
||||
|
||||
|
||||
@ -189,8 +189,7 @@ class DuoDeviceViewSet(
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
permission_classes = [OwnerPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class DuoAdminDeviceViewSet(ModelViewSet):
|
||||
|
@ -1,11 +1,9 @@
|
||||
"""AuthenticatorSMSStage API Views"""
|
||||
|
||||
from django_filters.rest_framework.backends import DjangoFilterBackend
|
||||
from rest_framework import mixins
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
@ -44,9 +42,11 @@ class AuthenticatorSMSStageViewSet(UsedByMixin, ModelViewSet):
|
||||
class SMSDeviceSerializer(ModelSerializer):
|
||||
"""Serializer for sms authenticator devices"""
|
||||
|
||||
user = GroupMemberSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SMSDevice
|
||||
fields = ["name", "pk", "phone_number"]
|
||||
fields = ["name", "pk", "phone_number", "user"]
|
||||
depth = 2
|
||||
extra_kwargs = {
|
||||
"phone_number": {"read_only": True},
|
||||
@ -65,11 +65,10 @@ class SMSDeviceViewSet(
|
||||
|
||||
queryset = SMSDevice.objects.all()
|
||||
serializer_class = SMSDeviceSerializer
|
||||
permission_classes = [OwnerPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class SMSAdminDeviceViewSet(ModelViewSet):
|
||||
|
@ -1,11 +1,9 @@
|
||||
"""AuthenticatorStaticStage API Views"""
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import mixins
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
@ -51,10 +49,11 @@ class StaticDeviceSerializer(ModelSerializer):
|
||||
"""Serializer for static authenticator devices"""
|
||||
|
||||
token_set = StaticDeviceTokenSerializer(many=True, read_only=True)
|
||||
user = GroupMemberSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = StaticDevice
|
||||
fields = ["name", "token_set", "pk"]
|
||||
fields = ["name", "token_set", "pk", "user"]
|
||||
|
||||
|
||||
class StaticDeviceViewSet(
|
||||
@ -69,11 +68,10 @@ class StaticDeviceViewSet(
|
||||
|
||||
queryset = StaticDevice.objects.filter(confirmed=True)
|
||||
serializer_class = StaticDeviceSerializer
|
||||
permission_classes = [OwnerPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class StaticAdminDeviceViewSet(ModelViewSet):
|
||||
|
@ -1,12 +1,10 @@
|
||||
"""AuthenticatorTOTPStage API Views"""
|
||||
|
||||
from django_filters.rest_framework.backends import DjangoFilterBackend
|
||||
from rest_framework import mixins
|
||||
from rest_framework.fields import ChoiceField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
@ -40,11 +38,14 @@ class AuthenticatorTOTPStageViewSet(UsedByMixin, ModelViewSet):
|
||||
class TOTPDeviceSerializer(ModelSerializer):
|
||||
"""Serializer for totp authenticator devices"""
|
||||
|
||||
user = GroupMemberSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TOTPDevice
|
||||
fields = [
|
||||
"name",
|
||||
"pk",
|
||||
"user",
|
||||
]
|
||||
depth = 2
|
||||
|
||||
@ -61,11 +62,10 @@ class TOTPDeviceViewSet(
|
||||
|
||||
queryset = TOTPDevice.objects.filter(confirmed=True)
|
||||
serializer_class = TOTPDeviceSerializer
|
||||
permission_classes = [OwnerPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class TOTPAdminDeviceViewSet(ModelViewSet):
|
||||
|
@ -1,11 +1,9 @@
|
||||
"""AuthenticatorWebAuthnStage API Views"""
|
||||
|
||||
from django_filters.rest_framework.backends import DjangoFilterBackend
|
||||
from rest_framework import mixins
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.stages.authenticator_webauthn.api.device_types import WebAuthnDeviceTypeSerializer
|
||||
@ -16,10 +14,11 @@ class WebAuthnDeviceSerializer(ModelSerializer):
|
||||
"""Serializer for WebAuthn authenticator devices"""
|
||||
|
||||
device_type = WebAuthnDeviceTypeSerializer(read_only=True, allow_null=True)
|
||||
user = GroupMemberSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = WebAuthnDevice
|
||||
fields = ["pk", "name", "created_on", "device_type", "aaguid"]
|
||||
fields = ["pk", "name", "created_on", "device_type", "aaguid", "user"]
|
||||
extra_kwargs = {
|
||||
"aaguid": {"read_only": True},
|
||||
}
|
||||
@ -40,8 +39,7 @@ class WebAuthnDeviceViewSet(
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
permission_classes = [OwnerPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
owner_field = "user"
|
||||
|
||||
|
||||
class WebAuthnAdminDeviceViewSet(ModelViewSet):
|
||||
|
File diff suppressed because one or more lines are too long
@ -1,12 +1,8 @@
|
||||
"""ConsentStage API Views"""
|
||||
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from guardian.utils import get_anonymous_user
|
||||
from rest_framework import mixins
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
|
||||
from authentik.core.api.applications import ApplicationSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
@ -57,11 +53,4 @@ class UserConsentViewSet(
|
||||
filterset_fields = ["user", "application"]
|
||||
ordering = ["application", "expires"]
|
||||
search_fields = ["user__username"]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user if self.request else get_anonymous_user()
|
||||
if user.is_superuser:
|
||||
return super().get_queryset()
|
||||
return super().get_queryset().filter(user=user.pk)
|
||||
owner_field = "user"
|
||||
|
@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
|
||||
("authentik_stages_consent", "0006_alter_userconsent_expires"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="userconsent",
|
||||
index=models.Index(fields=["expires"], name="authentik_s_expires_0e99e8_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userconsent",
|
||||
index=models.Index(fields=["expiring"], name="authentik_s_expirin_8f51e5_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="userconsent",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_s_expirin_e50090_idx"
|
||||
),
|
||||
),
|
||||
]
|
@ -71,3 +71,4 @@ class UserConsent(SerializerModel, ExpiringModel):
|
||||
unique_together = (("user", "application", "permissions"),)
|
||||
verbose_name = _("User Consent")
|
||||
verbose_name_plural = _("User Consents")
|
||||
indexes = ExpiringModel.Meta.indexes
|
||||
|
@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.0.10 on 2025-01-13 18:05
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0027_auto_20231028_1424"),
|
||||
("authentik_stages_invitation", "0008_alter_invitation_expires"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="invitation",
|
||||
index=models.Index(fields=["expires"], name="authentik_s_expires_96f4b8_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="invitation",
|
||||
index=models.Index(fields=["expiring"], name="authentik_s_expirin_4f8f35_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="invitation",
|
||||
index=models.Index(
|
||||
fields=["expiring", "expires"], name="authentik_s_expirin_4f8096_idx"
|
||||
),
|
||||
),
|
||||
]
|
@ -84,3 +84,4 @@ class Invitation(SerializerModel, ExpiringModel):
|
||||
class Meta:
|
||||
verbose_name = _("Invitation")
|
||||
verbose_name_plural = _("Invitations")
|
||||
indexes = ExpiringModel.Meta.indexes
|
||||
|
@ -104,7 +104,9 @@ class UserWriteStageView(StageView):
|
||||
for key, value in data.items():
|
||||
setter_name = f"set_{key}"
|
||||
# Check if user has a setter for this key, like set_password
|
||||
if hasattr(user, setter_name):
|
||||
if key == "password":
|
||||
user.set_password(value, request=self.request)
|
||||
elif hasattr(user, setter_name):
|
||||
setter = getattr(user, setter_name)
|
||||
if callable(setter):
|
||||
setter(value)
|
||||
|
@ -8,11 +8,11 @@ from django.http import HttpResponseNotFound
|
||||
from django.http.request import urljoin
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import permissions
|
||||
from rest_framework.authentication import get_authorization_header
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, IntegerField
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import DateTimeField, ModelSerializer
|
||||
@ -27,7 +27,7 @@ from authentik.recovery.lib import create_admin_group, create_recovery_token
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
class TenantApiKeyPermission(permissions.BasePermission):
|
||||
class TenantApiKeyPermission(BasePermission):
|
||||
"""Authentication based on tenants.api_key"""
|
||||
|
||||
def has_permission(self, request: Request, view: View) -> bool:
|
||||
|
@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2024.12.0 Blueprint schema",
|
||||
"title": "authentik 2024.12.2 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@ -4159,7 +4159,7 @@
|
||||
"re_evaluate_policies": {
|
||||
"type": "boolean",
|
||||
"title": "Re evaluate policies",
|
||||
"description": "Evaluate policies when the Stage is present to the user."
|
||||
"description": "Evaluate policies when the Stage is presented to the user."
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
|
@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redis:/data
|
||||
server:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.12.0}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.12.2}
|
||||
restart: unless-stopped
|
||||
command: server
|
||||
environment:
|
||||
@ -54,7 +54,7 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
worker:
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.12.0}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.12.2}
|
||||
restart: unless-stopped
|
||||
command: worker
|
||||
environment:
|
||||
|
10
go.mod
10
go.mod
@ -6,10 +6,10 @@ toolchain go1.23.0
|
||||
|
||||
require (
|
||||
beryju.io/ldap v0.1.0
|
||||
github.com/coreos/go-oidc/v3 v3.11.0
|
||||
github.com/getsentry/sentry-go v0.30.0
|
||||
github.com/coreos/go-oidc/v3 v3.12.0
|
||||
github.com/getsentry/sentry-go v0.31.1
|
||||
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
|
||||
github.com/go-ldap/ldap/v3 v3.4.9
|
||||
github.com/go-ldap/ldap/v3 v3.4.10
|
||||
github.com/go-openapi/runtime v0.28.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
@ -29,9 +29,9 @@ require (
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2024105.5
|
||||
goauthentik.io/api/v3 v3.2024122.2
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.24.0
|
||||
golang.org/x/oauth2 v0.25.0
|
||||
golang.org/x/sync v0.10.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
layeh.com/radius v0.0.0-20210819152912-ad72663a72ab
|
||||
|
25
go.sum
25
go.sum
@ -55,8 +55,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
|
||||
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@ -69,8 +69,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/getsentry/sentry-go v0.30.0 h1:lWUwDnY7sKHaVIoZ9wYqRHJ5iEmoc0pqcRqFkosKzBo=
|
||||
github.com/getsentry/sentry-go v0.30.0/go.mod h1:WU9B9/1/sHDqeV8T+3VwwbjeR5MSXs/6aqG3mqZrezA=
|
||||
github.com/getsentry/sentry-go v0.31.1 h1:ELVc0h7gwyhnXHDouXkhqTFSO5oslsRDk0++eyE0KJ4=
|
||||
github.com/getsentry/sentry-go v0.31.1/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
@ -86,8 +86,8 @@ github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9
|
||||
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
||||
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.9 h1:KxX9eO44/MpqPXVVMPJDB+k/35GEePHE/Jfvl7oRMUo=
|
||||
github.com/go-ldap/ldap/v3 v3.4.9/go.mod h1:+CE/4PPOOdEPGTi2B7qXKQOq+pNBvXZtlBNcVZY0AWI=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@ -299,8 +299,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
|
||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
goauthentik.io/api/v3 v3.2024105.5 h1:zBDqIjWN5QNuL6iBLL4o9QwBsSkFQdAnyTjASsyE/fw=
|
||||
goauthentik.io/api/v3 v3.2024105.5/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
goauthentik.io/api/v3 v3.2024122.2 h1:QC+ZQ+AxlPwl9OG1X/Z62EVepmTGyfvJUxhUdFjs+4s=
|
||||
goauthentik.io/api/v3 v3.2024122.2/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
@ -312,7 +312,6 @@ golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
@ -386,16 +385,16 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
|
||||
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
|
||||
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
@ -26,14 +26,14 @@ type Config struct {
|
||||
}
|
||||
|
||||
type RedisConfig struct {
|
||||
Host string `yaml:"host" env:"HOST, overwrite"`
|
||||
Port int `yaml:"port" env:"PORT, overwrite"`
|
||||
DB int `yaml:"db" env:"DB, overwrite"`
|
||||
Username string `yaml:"username" env:"USERNAME, overwrite"`
|
||||
Password string `yaml:"password" env:"PASSWORD, overwrite"`
|
||||
TLS bool `yaml:"tls" env:"TLS, overwrite"`
|
||||
TLSReqs string `yaml:"tls_reqs" env:"TLS_REQS, overwrite"`
|
||||
TLSCaCert *string `yaml:"tls_ca_certs" env:"TLS_CA_CERT, overwrite"`
|
||||
Host string `yaml:"host" env:"HOST, overwrite"`
|
||||
Port int `yaml:"port" env:"PORT, overwrite"`
|
||||
DB int `yaml:"db" env:"DB, overwrite"`
|
||||
Username string `yaml:"username" env:"USERNAME, overwrite"`
|
||||
Password string `yaml:"password" env:"PASSWORD, overwrite"`
|
||||
TLS bool `yaml:"tls" env:"TLS, overwrite"`
|
||||
TLSReqs string `yaml:"tls_reqs" env:"TLS_REQS, overwrite"`
|
||||
TLSCaCert string `yaml:"tls_ca_certs" env:"TLS_CA_CERT, overwrite"`
|
||||
}
|
||||
|
||||
type ListenConfig struct {
|
||||
|
@ -16,7 +16,7 @@ func BUILD(def string) string {
|
||||
func FullVersion() string {
|
||||
ver := VERSION
|
||||
if b := BUILD(""); b != "" {
|
||||
ver = fmt.Sprintf("%s.%s", ver, b)
|
||||
return fmt.Sprintf("%s+%s", ver, b)
|
||||
}
|
||||
return ver
|
||||
}
|
||||
@ -29,4 +29,4 @@ func UserAgent() string {
|
||||
return fmt.Sprintf("authentik@%s", FullVersion())
|
||||
}
|
||||
|
||||
const VERSION = "2024.12.0"
|
||||
const VERSION = "2024.12.2"
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@ -16,11 +17,22 @@ import (
|
||||
"goauthentik.io/internal/constants"
|
||||
)
|
||||
|
||||
func (ac *APIController) getWebsocketURL(akURL url.URL, outpostUUID string, query url.Values) *url.URL {
|
||||
wsUrl := &url.URL{}
|
||||
wsUrl.Scheme = strings.ReplaceAll(akURL.Scheme, "http", "ws")
|
||||
wsUrl.Host = akURL.Host
|
||||
_p, _ := url.JoinPath(akURL.Path, "ws/outpost/", outpostUUID, "/")
|
||||
wsUrl.Path = _p
|
||||
v := url.Values{}
|
||||
maps.Insert(v, maps.All(akURL.Query()))
|
||||
maps.Insert(v, maps.All(query))
|
||||
wsUrl.RawQuery = v.Encode()
|
||||
return wsUrl
|
||||
}
|
||||
|
||||
func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
|
||||
pathTemplate := "%s://%s%sws/outpost/%s/?%s"
|
||||
query := akURL.Query()
|
||||
query.Set("instance_uuid", ac.instanceUUID.String())
|
||||
scheme := strings.ReplaceAll(akURL.Scheme, "http", "ws")
|
||||
|
||||
authHeader := fmt.Sprintf("Bearer %s", ac.token)
|
||||
|
||||
@ -37,7 +49,9 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
|
||||
},
|
||||
}
|
||||
|
||||
ws, _, err := dialer.Dial(fmt.Sprintf(pathTemplate, scheme, akURL.Host, akURL.Path, outpostUUID, akURL.Query().Encode()), header)
|
||||
wsu := ac.getWebsocketURL(akURL, outpostUUID, query).String()
|
||||
ac.logger.WithField("url", wsu).Debug("connecting to websocket")
|
||||
ws, _, err := dialer.Dial(wsu, header)
|
||||
if err != nil {
|
||||
ac.logger.WithError(err).Warning("failed to connect websocket")
|
||||
return err
|
||||
|
42
internal/outpost/ak/api_ws_test.go
Normal file
42
internal/outpost/ak/api_ws_test.go
Normal file
@ -0,0 +1,42 @@
|
||||
package ak
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func URLMustParse(u string) *url.URL {
|
||||
ur, err := url.Parse(u)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return ur
|
||||
}
|
||||
|
||||
func TestWebsocketURL(t *testing.T) {
|
||||
u := URLMustParse("http://localhost:9000?foo=bar")
|
||||
uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77"
|
||||
ac := &APIController{}
|
||||
nu := ac.getWebsocketURL(*u, uuid, url.Values{})
|
||||
assert.Equal(t, "ws://localhost:9000/ws/outpost/23470845-7263-4fe3-bd79-ec1d7bf77d77/?foo=bar", nu.String())
|
||||
}
|
||||
|
||||
func TestWebsocketURL_Query(t *testing.T) {
|
||||
u := URLMustParse("http://localhost:9000?foo=bar")
|
||||
uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77"
|
||||
ac := &APIController{}
|
||||
v := url.Values{}
|
||||
v.Set("bar", "baz")
|
||||
nu := ac.getWebsocketURL(*u, uuid, v)
|
||||
assert.Equal(t, "ws://localhost:9000/ws/outpost/23470845-7263-4fe3-bd79-ec1d7bf77d77/?bar=baz&foo=bar", nu.String())
|
||||
}
|
||||
|
||||
func TestWebsocketURL_Subpath(t *testing.T) {
|
||||
u := URLMustParse("http://localhost:9000/foo/bar/")
|
||||
uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77"
|
||||
ac := &APIController{}
|
||||
nu := ac.getWebsocketURL(*u, uuid, url.Values{})
|
||||
assert.Equal(t, "ws://localhost:9000/foo/bar/ws/outpost/23470845-7263-4fe3-bd79-ec1d7bf77d77/", nu.String())
|
||||
}
|
@ -45,15 +45,15 @@ func (a *Application) getStore(p api.ProxyOutpostConfig, externalHost *url.URL)
|
||||
break
|
||||
}
|
||||
ca := config.Get().Redis.TLSCaCert
|
||||
if ca != nil {
|
||||
if ca != "" {
|
||||
// Get the SystemCertPool, continue with an empty pool on error
|
||||
rootCAs, _ := x509.SystemCertPool()
|
||||
if rootCAs == nil {
|
||||
rootCAs = x509.NewCertPool()
|
||||
}
|
||||
certs, err := os.ReadFile(*ca)
|
||||
certs, err := os.ReadFile(ca)
|
||||
if err != nil {
|
||||
a.log.WithError(err).Fatalf("Failed to append %s to RootCAs", *ca)
|
||||
a.log.WithError(err).Fatalf("Failed to append %s to RootCAs", ca)
|
||||
}
|
||||
// Append our cert to the system pool
|
||||
if ok := rootCAs.AppendCertsFromPEM(certs); !ok {
|
||||
|
@ -13,6 +13,10 @@ import (
|
||||
"goauthentik.io/internal/utils/sentry"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthentikStarting = errors.New("authentik starting")
|
||||
)
|
||||
|
||||
func (ws *WebServer) configureProxy() {
|
||||
// Reverse proxy to the application server
|
||||
director := func(req *http.Request) {
|
||||
@ -38,7 +42,7 @@ func (ws *WebServer) configureProxy() {
|
||||
}))
|
||||
ws.mainRouter.PathPrefix(config.Get().Web.Path).HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if !ws.g.IsRunning() {
|
||||
ws.proxyErrorHandler(rw, r, errors.New("authentik starting"))
|
||||
ws.proxyErrorHandler(rw, r, ErrAuthentikStarting)
|
||||
return
|
||||
}
|
||||
before := time.Now()
|
||||
@ -59,7 +63,9 @@ func (ws *WebServer) configureProxy() {
|
||||
}
|
||||
|
||||
func (ws *WebServer) proxyErrorHandler(rw http.ResponseWriter, req *http.Request, err error) {
|
||||
ws.log.WithError(err).Warning("failed to proxy to backend")
|
||||
if !errors.Is(err, ErrAuthentikStarting) {
|
||||
ws.log.WithError(err).Warning("failed to proxy to backend")
|
||||
}
|
||||
rw.WriteHeader(http.StatusBadGateway)
|
||||
em := fmt.Sprintf("failed to connect to authentik backend: %v", err)
|
||||
// return json if the client asks for json
|
||||
|
3603
locale/fi/LC_MESSAGES/django.po
Normal file
3603
locale/fi/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load Diff
@ -12,16 +12,16 @@
|
||||
# Charles Leclerc, 2024
|
||||
# nerdinator <florian.dupret@gmail.com>, 2024
|
||||
# Tina, 2024
|
||||
# Marc Schmitt, 2024
|
||||
# Marc Schmitt, 2025
|
||||
#
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-18 13:31+0000\n"
|
||||
"POT-Creation-Date: 2024-12-20 00:08+0000\n"
|
||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
||||
"Last-Translator: Marc Schmitt, 2024\n"
|
||||
"Last-Translator: Marc Schmitt, 2025\n"
|
||||
"Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
@ -121,6 +121,10 @@ msgstr "Marque"
|
||||
msgid "Brands"
|
||||
msgstr "Marques"
|
||||
|
||||
#: authentik/core/api/application_entitlements.py
|
||||
msgid "User does not have access to application."
|
||||
msgstr "L'utilisateur n'a pas accès à l'application."
|
||||
|
||||
#: authentik/core/api/devices.py
|
||||
msgid "Extra description not available"
|
||||
msgstr "Description supplémentaire indisponible"
|
||||
@ -256,6 +260,14 @@ msgstr "Application"
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application Entitlement"
|
||||
msgstr "Droit applicatif"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application Entitlements"
|
||||
msgstr "Droits applicatifs"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Use the source-specific identifier"
|
||||
msgstr "Utiliser l'identifiant spécifique à la source"
|
||||
|
@ -15,7 +15,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-18 13:31+0000\n"
|
||||
"POT-Creation-Date: 2024-12-20 00:08+0000\n"
|
||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
||||
"Last-Translator: deluxghost, 2024\n"
|
||||
"Language-Team: Chinese Simplified (https://app.transifex.com/authentik/teams/119923/zh-Hans/)\n"
|
||||
@ -110,6 +110,10 @@ msgstr "品牌"
|
||||
msgid "Brands"
|
||||
msgstr "品牌"
|
||||
|
||||
#: authentik/core/api/application_entitlements.py
|
||||
msgid "User does not have access to application."
|
||||
msgstr "用户没有访问此应用程序的权限。"
|
||||
|
||||
#: authentik/core/api/devices.py
|
||||
msgid "Extra description not available"
|
||||
msgstr "额外描述不可用"
|
||||
@ -235,6 +239,14 @@ msgstr "应用程序"
|
||||
msgid "Applications"
|
||||
msgstr "应用程序"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application Entitlement"
|
||||
msgstr "应用程序授权"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application Entitlements"
|
||||
msgstr "应用程序授权"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Use the source-specific identifier"
|
||||
msgstr "使用源特定的标识符"
|
||||
|
@ -14,7 +14,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-12-18 13:31+0000\n"
|
||||
"POT-Creation-Date: 2024-12-20 00:08+0000\n"
|
||||
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
|
||||
"Last-Translator: deluxghost, 2024\n"
|
||||
"Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n"
|
||||
@ -109,6 +109,10 @@ msgstr "品牌"
|
||||
msgid "Brands"
|
||||
msgstr "品牌"
|
||||
|
||||
#: authentik/core/api/application_entitlements.py
|
||||
msgid "User does not have access to application."
|
||||
msgstr "用户没有访问此应用程序的权限。"
|
||||
|
||||
#: authentik/core/api/devices.py
|
||||
msgid "Extra description not available"
|
||||
msgstr "额外描述不可用"
|
||||
@ -234,6 +238,14 @@ msgstr "应用程序"
|
||||
msgid "Applications"
|
||||
msgstr "应用程序"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application Entitlement"
|
||||
msgstr "应用程序授权"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Application Entitlements"
|
||||
msgstr "应用程序授权"
|
||||
|
||||
#: authentik/core/models.py
|
||||
msgid "Use the source-specific identifier"
|
||||
msgstr "使用源特定的标识符"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user