Compare commits

..

1 Commits

Author SHA1 Message Date
ee6b8b596e web/NPM Workspaces: Prep SFE package. 2025-05-20 02:53:50 +02:00
607 changed files with 13509 additions and 33528 deletions

View File

@ -1,16 +1,16 @@
[bumpversion] [bumpversion]
current_version = 2025.6.0 current_version = 2025.4.1
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
serialize = serialize =
{major}.{minor}.{patch}-{rc_t}{rc_n} {major}.{minor}.{patch}-{rc_t}{rc_n}
{major}.{minor}.{patch} {major}.{minor}.{patch}
message = release: {new_version} message = release: {new_version}
tag_name = version/{new_version} tag_name = version/{new_version}
[bumpversion:part:rc_t] [bumpversion:part:rc_t]
values = values =
rc rc
final final
optional_value = final optional_value = final

View File

@ -5,7 +5,6 @@ dist/**
build/** build/**
build_docs/** build_docs/**
*Dockerfile *Dockerfile
**/*Dockerfile
blueprints/local blueprints/local
.git .git
!gen-ts-api/node_modules !gen-ts-api/node_modules

View File

@ -36,7 +36,7 @@ runs:
with: with:
go-version-file: "go.mod" go-version-file: "go.mod"
- name: Setup docker cache - name: Setup docker cache
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7 uses: ScribeMD/docker-cache@0.5.0
with: with:
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }} key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
- name: Setup dependencies - name: Setup dependencies

View File

@ -23,13 +23,7 @@ updates:
- package-ecosystem: npm - package-ecosystem: npm
directories: directories:
- "/web" - "/web"
- "/web/packages/sfe" - "/web/sfe"
- "/web/packages/core"
- "/web/packages/esbuild-plugin-live-reload"
- "/packages/prettier-config"
- "/packages/tsconfig"
- "/packages/docusaurus-config"
- "/packages/eslint-config"
schedule: schedule:
interval: daily interval: daily
time: "04:00" time: "04:00"
@ -74,9 +68,6 @@ updates:
wdio: wdio:
patterns: patterns:
- "@wdio/*" - "@wdio/*"
goauthentik:
patterns:
- "@goauthentik/*"
- package-ecosystem: npm - package-ecosystem: npm
directory: "/website" directory: "/website"
schedule: schedule:
@ -97,9 +88,6 @@ updates:
- "swc-*" - "swc-*"
- "lightningcss*" - "lightningcss*"
- "@rspack/binding*" - "@rspack/binding*"
goauthentik:
patterns:
- "@goauthentik/*"
- package-ecosystem: npm - package-ecosystem: npm
directory: "/lifecycle/aws" directory: "/lifecycle/aws"
schedule: schedule:

View File

@ -62,7 +62,6 @@ jobs:
psql: psql:
- 15-alpine - 15-alpine
- 16-alpine - 16-alpine
- 17-alpine
run_id: [1, 2, 3, 4, 5] run_id: [1, 2, 3, 4, 5]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -117,7 +116,6 @@ jobs:
psql: psql:
- 15-alpine - 15-alpine
- 16-alpine - 16-alpine
- 17-alpine
run_id: [1, 2, 3, 4, 5] run_id: [1, 2, 3, 4, 5]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

View File

@ -41,60 +41,32 @@ jobs:
- name: test - name: test
working-directory: website/ working-directory: website/
run: npm test run: npm test
build-container: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: name: ${{ matrix.job }}
# Needed to upload container images to ghcr.io strategy:
packages: write fail-fast: false
# Needed for attestation matrix:
id-token: write job:
attestations: write - build
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: with:
ref: ${{ github.event.pull_request.head.sha }} node-version-file: website/package.json
- name: Set up QEMU cache: "npm"
uses: docker/setup-qemu-action@v3.6.0 cache-dependency-path: website/package-lock.json
- name: Set up Docker Buildx - working-directory: website/
uses: docker/setup-buildx-action@v3 run: npm ci
- name: prepare variables - name: build
uses: ./.github/actions/docker-push-variables working-directory: website/
id: ev run: npm run ${{ matrix.job }}
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/dev-docs
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
id: push
uses: docker/build-push-action@v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: website/Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
platforms: linux/amd64,linux/arm64
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
- uses: actions/attest-build-provenance@v2
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
ci-website-mark: ci-website-mark:
if: always() if: always()
needs: needs:
- lint - lint
- test - test
- build-container - build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: re-actors/alls-green@release/v1 - uses: re-actors/alls-green@release/v1

View File

@ -7,7 +7,6 @@ on:
- packages/eslint-config/** - packages/eslint-config/**
- packages/prettier-config/** - packages/prettier-config/**
- packages/tsconfig/** - packages/tsconfig/**
- web/packages/esbuild-plugin-live-reload/**
workflow_dispatch: workflow_dispatch:
jobs: jobs:
publish: publish:
@ -17,28 +16,27 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
package: package:
- packages/docusaurus-config - docusaurus-config
- packages/eslint-config - eslint-config
- packages/prettier-config - prettier-config
- packages/tsconfig - tsconfig
- web/packages/esbuild-plugin-live-reload
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 2 fetch-depth: 2
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version-file: ${{ matrix.package }}/package.json node-version-file: packages/${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- name: Get changed files - name: Get changed files
id: changed-files id: changed-files
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c
with: with:
files: | files: |
${{ matrix.package }}/package.json packages/${{ matrix.package }}/package.json
- name: Publish package - name: Publish package
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ${{ matrix.package }} working-directory: packages/${{ matrix.package}}
run: | run: |
npm ci npm ci
npm run build npm run build

View File

@ -20,49 +20,6 @@ jobs:
release: true release: true
registry_dockerhub: true registry_dockerhub: true
registry_ghcr: true registry_ghcr: true
build-docs:
runs-on: ubuntu-latest
permissions:
# Needed to upload container images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/docs
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
id: push
uses: docker/build-push-action@v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: website/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@v2
id: attest
if: true
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
build-outpost: build-outpost:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@ -236,6 +193,6 @@ jobs:
SENTRY_ORG: authentik-security-inc SENTRY_ORG: authentik-security-inc
SENTRY_PROJECT: authentik SENTRY_PROJECT: authentik
with: with:
release: authentik@${{ steps.ev.outputs.version }} version: authentik@${{ steps.ev.outputs.version }}
sourcemaps: "./web/dist" sourcemaps: "./web/dist"
url_prefix: "~/static/dist" url_prefix: "~/static/dist"

View File

@ -15,7 +15,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}} if: ${{ github.event.pull_request.user.login == 'transifex-integration[bot]'}}
steps: steps:
- uses: actions/checkout@v4
- id: generate_token - id: generate_token
uses: tibdex/github-app-token@v2 uses: tibdex/github-app-token@v2
with: with:
@ -32,7 +31,7 @@ jobs:
env: env:
GH_TOKEN: ${{ steps.generate_token.outputs.token }} GH_TOKEN: ${{ steps.generate_token.outputs.token }}
run: | run: |
gh pr edit ${{ github.event.pull_request.number }} -t "translate: ${{ steps.title.outputs.title }}" --add-label dependencies gh pr edit -t "translate: ${{ steps.title.outputs.title }}" --add-label dependencies
- uses: peter-evans/enable-pull-request-automerge@v3 - uses: peter-evans/enable-pull-request-automerge@v3
with: with:
token: ${{ steps.generate_token.outputs.token }} token: ${{ steps.generate_token.outputs.token }}

View File

@ -1,7 +1,26 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Stage 1: Build webui # Stage 1: Build website
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-slim AS node-builder FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS website-builder
ENV NODE_ENV=production
WORKDIR /work/website
RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.json \
--mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \
--mount=type=cache,id=npm-website,sharing=shared,target=/root/.npm \
npm ci --include=dev
COPY ./website /work/website/
COPY ./blueprints /work/blueprints/
COPY ./schema.yml /work/
COPY ./SECURITY.md /work/
RUN npm run build-bundled
# Stage 2: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/library/node:22 AS web-builder
ARG GIT_BUILD_HASH ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
@ -13,7 +32,7 @@ RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \ --mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \ --mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-ak,sharing=shared,target=/root/.npm \ --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
npm ci --include=dev npm ci --include=dev
COPY ./package.json /work COPY ./package.json /work
@ -24,7 +43,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build && \ RUN npm run build && \
npm run build:sfe npm run build:sfe
# Stage 2: Build go proxy # Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
ARG TARGETOS ARG TARGETOS
@ -49,8 +68,8 @@ RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
COPY ./cmd /go/src/goauthentik.io/cmd COPY ./cmd /go/src/goauthentik.io/cmd
COPY ./authentik/lib /go/src/goauthentik.io/authentik/lib COPY ./authentik/lib /go/src/goauthentik.io/authentik/lib
COPY ./web/static.go /go/src/goauthentik.io/web/static.go COPY ./web/static.go /go/src/goauthentik.io/web/static.go
COPY --from=node-builder /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt COPY --from=web-builder /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt
COPY --from=node-builder /work/web/security.txt /go/src/goauthentik.io/web/security.txt COPY --from=web-builder /work/web/security.txt /go/src/goauthentik.io/web/security.txt
COPY ./internal /go/src/goauthentik.io/internal COPY ./internal /go/src/goauthentik.io/internal
COPY ./go.mod /go/src/goauthentik.io/go.mod COPY ./go.mod /go/src/goauthentik.io/go.mod
COPY ./go.sum /go/src/goauthentik.io/go.sum COPY ./go.sum /go/src/goauthentik.io/go.sum
@ -61,7 +80,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server go build -o /go/authentik ./cmd/server
# Stage 3: MaxMind GeoIP # Stage 4: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
@ -74,9 +93,9 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
mkdir -p /usr/share/GeoIP && \ mkdir -p /usr/share/GeoIP && \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv # Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.7.11 AS uv FROM ghcr.io/astral-sh/uv:0.7.5 AS uv
# Stage 5: Base python image # Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \ ENV VENV_PATH="/ak-root/.venv" \
@ -90,7 +109,7 @@ WORKDIR /ak-root/
COPY --from=uv /uv /uvx /bin/ COPY --from=uv /uv /uvx /bin/
# Stage 6: Python dependencies # Stage 7: Python dependencies
FROM python-base AS python-deps FROM python-base AS python-deps
ARG TARGETARCH ARG TARGETARCH
@ -125,7 +144,7 @@ RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
--mount=type=cache,target=/root/.cache/uv \ --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev uv sync --frozen --no-install-project --no-dev
# Stage 7: Run # Stage 8: Run
FROM python-base AS final-image FROM python-base AS final-image
ARG VERSION ARG VERSION
@ -168,8 +187,9 @@ COPY ./lifecycle/ /lifecycle
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
COPY --from=go-builder /go/authentik /bin/authentik COPY --from=go-builder /go/authentik /bin/authentik
COPY --from=python-deps /ak-root/.venv /ak-root/.venv COPY --from=python-deps /ak-root/.venv /ak-root/.venv
COPY --from=node-builder /work/web/dist/ /web/dist/ COPY --from=web-builder /work/web/dist/ /web/dist/
COPY --from=node-builder /work/web/authentik/ /web/authentik/ COPY --from=web-builder /work/web/authentik/ /web/authentik/
COPY --from=website-builder /work/website/build/ /website/help/
COPY --from=geoip /usr/share/GeoIP /geoip COPY --from=geoip /usr/share/GeoIP /geoip
USER 1000 USER 1000

View File

@ -1,6 +1,6 @@
.PHONY: gen dev-reset all clean test web website .PHONY: gen dev-reset all clean test web website
SHELL := /usr/bin/env bash SHELL := /bin/bash
.SHELLFLAGS += ${SHELLFLAGS} -e -o pipefail .SHELLFLAGS += ${SHELLFLAGS} -e -o pipefail
PWD = $(shell pwd) PWD = $(shell pwd)
UID = $(shell id -u) UID = $(shell id -u)

View File

@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported | | Version | Supported |
| --------- | --------- | | --------- | --------- |
| 2025.2.x | ✅ |
| 2025.4.x | ✅ | | 2025.4.x | ✅ |
| 2025.6.x | ✅ |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2025.6.0" __version__ = "2025.4.1"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -1,7 +1,6 @@
"""authentik administration overview""" """authentik administration overview"""
from django.core.cache import cache from django.core.cache import cache
from django_tenants.utils import get_public_schema_name
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from packaging.version import parse from packaging.version import parse
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
@ -14,7 +13,6 @@ from authentik import __version__, get_build_hash
from authentik.admin.tasks import VERSION_CACHE_KEY, VERSION_NULL, update_latest_version from authentik.admin.tasks import VERSION_CACHE_KEY, VERSION_NULL, update_latest_version
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.outposts.models import Outpost from authentik.outposts.models import Outpost
from authentik.tenants.utils import get_current_tenant
class VersionSerializer(PassiveSerializer): class VersionSerializer(PassiveSerializer):
@ -37,8 +35,6 @@ class VersionSerializer(PassiveSerializer):
def get_version_latest(self, _) -> str: def get_version_latest(self, _) -> str:
"""Get latest version from cache""" """Get latest version from cache"""
if get_current_tenant().schema_name == get_public_schema_name():
return __version__
version_in_cache = cache.get(VERSION_CACHE_KEY) version_in_cache = cache.get(VERSION_CACHE_KEY)
if not version_in_cache: # pragma: no cover if not version_in_cache: # pragma: no cover
update_latest_version.delay() update_latest_version.delay()

View File

@ -14,19 +14,3 @@ class AuthentikAdminConfig(ManagedAppConfig):
label = "authentik_admin" label = "authentik_admin"
verbose_name = "authentik Admin" verbose_name = "authentik Admin"
default = True default = True
@ManagedAppConfig.reconcile_global
def clear_update_notifications(self):
"""Clear update notifications on startup if the notification was for the version
we're running now."""
from packaging.version import parse
from authentik.admin.tasks import LOCAL_VERSION
from authentik.events.models import EventAction, Notification
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
if "new_version" not in notification.event.context:
continue
notification_version = notification.event.context["new_version"]
if LOCAL_VERSION >= parse(notification_version):
notification.delete()

View File

@ -1,7 +1,6 @@
"""authentik admin settings""" """authentik admin settings"""
from celery.schedules import crontab from celery.schedules import crontab
from django_tenants.utils import get_public_schema_name
from authentik.lib.utils.time import fqdn_rand from authentik.lib.utils.time import fqdn_rand
@ -9,7 +8,6 @@ CELERY_BEAT_SCHEDULE = {
"admin_latest_version": { "admin_latest_version": {
"task": "authentik.admin.tasks.update_latest_version", "task": "authentik.admin.tasks.update_latest_version",
"schedule": crontab(minute=fqdn_rand("admin_latest_version"), hour="*"), "schedule": crontab(minute=fqdn_rand("admin_latest_version"), hour="*"),
"tenant_schemas": [get_public_schema_name()],
"options": {"queue": "authentik_scheduled"}, "options": {"queue": "authentik_scheduled"},
} }
} }

View File

@ -1,6 +1,7 @@
"""authentik admin tasks""" """authentik admin tasks"""
from django.core.cache import cache from django.core.cache import cache
from django.db import DatabaseError, InternalError, ProgrammingError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from packaging.version import parse from packaging.version import parse
from requests import RequestException from requests import RequestException
@ -8,7 +9,7 @@ from structlog.stdlib import get_logger
from authentik import __version__, get_build_hash from authentik import __version__, get_build_hash
from authentik.admin.apps import PROM_INFO from authentik.admin.apps import PROM_INFO
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction, Notification
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
@ -32,6 +33,20 @@ def _set_prom_info():
) )
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError),
)
def clear_update_notifications():
"""Clear update notifications on startup if the notification was for the version
we're running now."""
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
if "new_version" not in notification.event.context:
continue
notification_version = notification.event.context["new_version"]
if LOCAL_VERSION >= parse(notification_version):
notification.delete()
@CELERY_APP.task(bind=True, base=SystemTask) @CELERY_APP.task(bind=True, base=SystemTask)
@prefill_task @prefill_task
def update_latest_version(self: SystemTask): def update_latest_version(self: SystemTask):

View File

@ -1,12 +1,12 @@
"""test admin tasks""" """test admin tasks"""
from django.apps import apps
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase from django.test import TestCase
from requests_mock import Mocker from requests_mock import Mocker
from authentik.admin.tasks import ( from authentik.admin.tasks import (
VERSION_CACHE_KEY, VERSION_CACHE_KEY,
clear_update_notifications,
update_latest_version, update_latest_version,
) )
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -72,13 +72,12 @@ class TestAdminTasks(TestCase):
def test_clear_update_notifications(self): def test_clear_update_notifications(self):
"""Test clear of previous notification""" """Test clear of previous notification"""
admin_config = apps.get_app_config("authentik_admin")
Event.objects.create( Event.objects.create(
action=EventAction.UPDATE_AVAILABLE, context={"new_version": "99999999.9999999.9999999"} action=EventAction.UPDATE_AVAILABLE, context={"new_version": "99999999.9999999.9999999"}
) )
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"}) Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"})
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={}) Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={})
admin_config.clear_update_notifications() clear_update_notifications()
self.assertFalse( self.assertFalse(
Event.objects.filter( Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1" action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1"

View File

@ -1,13 +1,12 @@
"""authentik API AppConfig""" """authentik API AppConfig"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikAPIConfig(ManagedAppConfig): class AuthentikAPIConfig(AppConfig):
"""authentik API Config""" """authentik API Config"""
name = "authentik.api" name = "authentik.api"
label = "authentik_api" label = "authentik_api"
mountpoint = "api/" mountpoint = "api/"
verbose_name = "authentik API" verbose_name = "authentik API"
default = True

View File

@ -1,14 +0,0 @@
from django.test import TestCase
from authentik.blueprints.apps import ManagedAppConfig
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import get_apps
class TestManagedAppConfig(TestCase):
def test_apps_use_managed_app_config(self):
for app in get_apps():
if app.name.startswith("authentik.enterprise"):
self.assertIn(EnterpriseConfig, app.__class__.__bases__)
else:
self.assertIn(ManagedAppConfig, app.__class__.__bases__)

View File

@ -1,9 +1,9 @@
"""authentik brands app""" """authentik brands app"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikBrandsConfig(ManagedAppConfig): class AuthentikBrandsConfig(AppConfig):
"""authentik Brand app""" """authentik Brand app"""
name = "authentik.brands" name = "authentik.brands"
@ -12,4 +12,3 @@ class AuthentikBrandsConfig(ManagedAppConfig):
mountpoints = { mountpoints = {
"authentik.brands.urls_root": "", "authentik.brands.urls_root": "",
} }
default = True

View File

@ -153,10 +153,10 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
return applications return applications
def _filter_applications_with_launch_url( def _filter_applications_with_launch_url(
self, paginated_apps: Iterator[Application] self, pagined_apps: Iterator[Application]
) -> list[Application]: ) -> list[Application]:
applications = [] applications = []
for app in paginated_apps: for app in pagined_apps:
if app.get_launch_url(): if app.get_launch_url():
applications.append(app) applications.append(app)
return applications return applications

View File

@ -84,7 +84,6 @@ from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar from authentik.lib.avatars import get_avatar
from authentik.rbac.decorators import permission_required from authentik.rbac.decorators import permission_required
from authentik.rbac.models import get_permission_choices from authentik.rbac.models import get_permission_choices
from authentik.stages.email.flow import pickle_flow_token_for_email
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
@ -452,7 +451,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
return super().list(request, *args, **kwargs) return super().list(request, *args, **kwargs)
def _create_recovery_link(self, for_email=False) -> tuple[str, Token]: def _create_recovery_link(self) -> tuple[str, Token]:
"""Create a recovery link (when the current brand has a recovery flow set), """Create a recovery link (when the current brand has a recovery flow set),
that can either be shown to an admin or sent to the user directly""" that can either be shown to an admin or sent to the user directly"""
brand: Brand = self.request._request.brand brand: Brand = self.request._request.brand
@ -474,16 +473,12 @@ class UserViewSet(UsedByMixin, ModelViewSet):
raise ValidationError( raise ValidationError(
{"non_field_errors": "Recovery flow not applicable to user"} {"non_field_errors": "Recovery flow not applicable to user"}
) from None ) from None
_plan = FlowToken.pickle(plan)
if for_email:
_plan = pickle_flow_token_for_email(plan)
token, __ = FlowToken.objects.update_or_create( token, __ = FlowToken.objects.update_or_create(
identifier=f"{user.uid}-password-reset", identifier=f"{user.uid}-password-reset",
defaults={ defaults={
"user": user, "user": user,
"flow": flow, "flow": flow,
"_plan": _plan, "_plan": FlowToken.pickle(plan),
"revoke_on_execution": not for_email,
}, },
) )
querystring = urlencode({QS_KEY_TOKEN: token.key}) querystring = urlencode({QS_KEY_TOKEN: token.key})
@ -653,7 +648,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
if for_user.email == "": if for_user.email == "":
LOGGER.debug("User doesn't have an email address") LOGGER.debug("User doesn't have an email address")
raise ValidationError({"non_field_errors": "User does not have an email address set."}) raise ValidationError({"non_field_errors": "User does not have an email address set."})
link, token = self._create_recovery_link(for_email=True) link, token = self._create_recovery_link()
# Lookup the email stage to assure the current user can access it # Lookup the email stage to assure the current user can access it
stages = get_objects_for_user( stages = get_objects_for_user(
request.user, "authentik_stages_email.view_emailstage" request.user, "authentik_stages_email.view_emailstage"

View File

@ -79,7 +79,6 @@ def _migrate_session(
AuthenticatedSession.objects.using(db_alias).create( AuthenticatedSession.objects.using(db_alias).create(
session=session, session=session,
user=old_auth_session.user, user=old_auth_session.user,
uuid=old_auth_session.uuid,
) )

View File

@ -1,81 +1,10 @@
# Generated by Django 5.1.9 on 2025-05-14 11:15 # Generated by Django 5.1.9 on 2025-05-14 11:15
from django.apps.registry import Apps, apps as global_apps from django.apps.registry import Apps
from django.db import migrations from django.db import migrations
from django.contrib.contenttypes.management import create_contenttypes
from django.contrib.auth.management import create_permissions
from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_authenticated_session_permissions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
"""Migrate permissions from OldAuthenticatedSession to AuthenticatedSession"""
db_alias = schema_editor.connection.alias
# `apps` here is just an instance of `django.db.migrations.state.AppConfigStub`, we need the
# real config for creating permissions and content types
authentik_core_config = global_apps.get_app_config("authentik_core")
# These are only ran by django after all migrations, but we need them right now.
# `global_apps` is needed,
create_permissions(authentik_core_config, using=db_alias, verbosity=1)
create_contenttypes(authentik_core_config, using=db_alias, verbosity=1)
# But from now on, this is just a regular migration, so use `apps`
Permission = apps.get_model("auth", "Permission")
ContentType = apps.get_model("contenttypes", "ContentType")
try:
old_ct = ContentType.objects.using(db_alias).get(
app_label="authentik_core", model="oldauthenticatedsession"
)
new_ct = ContentType.objects.using(db_alias).get(
app_label="authentik_core", model="authenticatedsession"
)
except ContentType.DoesNotExist:
# This should exist at this point, but if not, let's cut our losses
return
# Get all permissions for the old content type
old_perms = Permission.objects.using(db_alias).filter(content_type=old_ct)
# Create equivalent permissions for the new content type
for old_perm in old_perms:
new_perm = (
Permission.objects.using(db_alias)
.filter(
content_type=new_ct,
codename=old_perm.codename,
)
.first()
)
if not new_perm:
# This should exist at this point, but if not, let's cut our losses
continue
# Global user permissions
User = apps.get_model("authentik_core", "User")
User.user_permissions.through.objects.using(db_alias).filter(
permission=old_perm
).all().update(permission=new_perm)
# Global role permissions
DjangoGroup = apps.get_model("auth", "Group")
DjangoGroup.permissions.through.objects.using(db_alias).filter(
permission=old_perm
).all().update(permission=new_perm)
# Object user permissions
UserObjectPermission = apps.get_model("guardian", "UserObjectPermission")
UserObjectPermission.objects.using(db_alias).filter(permission=old_perm).all().update(
permission=new_perm, content_type=new_ct
)
# Object role permissions
GroupObjectPermission = apps.get_model("guardian", "GroupObjectPermission")
GroupObjectPermission.objects.using(db_alias).filter(permission=old_perm).all().update(
permission=new_perm, content_type=new_ct
)
def remove_old_authenticated_session_content_type( def remove_old_authenticated_session_content_type(
apps: Apps, schema_editor: BaseDatabaseSchemaEditor apps: Apps, schema_editor: BaseDatabaseSchemaEditor
): ):
@ -92,12 +21,7 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.RunPython(
code=migrate_authenticated_session_permissions,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython( migrations.RunPython(
code=remove_old_authenticated_session_content_type, code=remove_old_authenticated_session_content_type,
reverse_code=migrations.RunPython.noop,
), ),
] ]

View File

@ -10,7 +10,7 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<ak-message-container alignment="bottom"></ak-message-container> <ak-message-container></ak-message-container>
<ak-interface-admin> <ak-interface-admin>
<ak-loading></ak-loading> <ak-loading></ak-loading>
</ak-interface-admin> </ak-interface-admin>

View File

@ -3,14 +3,7 @@ from urllib.parse import unquote_plus
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
from cryptography.x509 import ( from cryptography.x509 import Certificate, NameOID, ObjectIdentifier, load_pem_x509_certificate
Certificate,
NameOID,
ObjectIdentifier,
UnsupportedGeneralNameType,
load_pem_x509_certificate,
)
from cryptography.x509.verification import PolicyBuilder, Store, VerificationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from authentik.brands.models import Brand from authentik.brands.models import Brand
@ -109,22 +102,16 @@ class MTLSStageView(ChallengeStageView):
return None return None
def validate_cert(self, authorities: list[CertificateKeyPair], certs: list[Certificate]): def validate_cert(self, authorities: list[CertificateKeyPair], certs: list[Certificate]):
authorities_cert = [x.certificate for x in authorities]
for _cert in certs: for _cert in certs:
try: for ca in authorities:
PolicyBuilder().store(Store(authorities_cert)).build_client_verifier().verify( try:
_cert, [] _cert.verify_directly_issued_by(ca.certificate)
) return _cert
return _cert except (InvalidSignature, TypeError, ValueError) as exc:
except ( self.logger.warning(
InvalidSignature, "Discarding cert not issued by authority", cert=_cert, authority=ca, exc=exc
TypeError, )
ValueError, continue
VerificationError,
UnsupportedGeneralNameType,
) as exc:
self.logger.warning("Discarding invalid certificate", cert=_cert, exc=exc)
continue
return None return None
def check_if_user(self, cert: Certificate): def check_if_user(self, cert: Certificate):
@ -154,9 +141,7 @@ class MTLSStageView(ChallengeStageView):
"subject": cert.subject.rfc4514_string(), "subject": cert.subject.rfc4514_string(),
"issuer": cert.issuer.rfc4514_string(), "issuer": cert.issuer.rfc4514_string(),
"fingerprint_sha256": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"), "fingerprint_sha256": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"),
"fingerprint_sha1": hexlify(cert.fingerprint(hashes.SHA1()), ":").decode( # nosec "fingerprint_sha1": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"),
"utf-8"
),
} }
def auth_user(self, user: User, cert: Certificate): def auth_user(self, user: User, cert: Certificate):

View File

@ -1,31 +1,30 @@
-----BEGIN CERTIFICATE----- -----BEGIN CERTIFICATE-----
MIIFWTCCA0GgAwIBAgIUDEnKCSmIXG/akySGes7bhOGrN/8wDQYJKoZIhvcNAQEL MIIFOzCCAyOgAwIBAgIUbnIMy+Ewi5RvK7OBDxWMCk7wi08wDQYJKoZIhvcNAQEL
BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl
bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNTE5MTIzODQ2WhcNMjYw bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNDI3MTgzMTE3WhcNMjYw
NTE1MTIzODQ2WjARMQ8wDQYDVQQDDAZjbGllbnQwggIiMA0GCSqGSIb3DQEBAQUA NDIzMTgzMTE3WjARMQ8wDQYDVQQDDAZjbGllbnQwggIiMA0GCSqGSIb3DQEBAQUA
A4ICDwAwggIKAoICAQCkPkS1V6l0gj0ulxMznkxkgrw4p9Tjd8teSsGZt02A2Eo6 A4ICDwAwggIKAoICAQCdV+GEa7+7ito1i/z637OZW+0azv1kuF2aDwSzv+FJd+4L
7D8FbJ7pp3d5fYW/TWuEKVBLWTID6rijW5EGcdgTM5Jxf/QR+aZTEK6umQxUd4yO 6hCroRbVYTUFS3I3YwanOOZfau64xH0+pFM5Js8aREG68eqKBayx8vT27hyAOFhd
mOtp+xVS3KlcsSej2dFpeE5h5VkZizHpvh5xkoAP8W5VtQLOVF0hIeumHnJmaeLj giEVmSQJfla4ogvPie1rJ0HVOL7CiR72HDPQvz+9k1iDX3xQ/4sdAb3XurN13e+M
+mhK9PBFpO7k9SFrYYhd/uLrYbIdANihbIO2Q74rNEJHewhFNM7oNSjjEWzRd/7S Gtavhjiyqxmoo/H4WRd8BhD/BZQFWtaxWODDY8aKk5R7omw6Xf7aRv1BlHdE4Ucy
qNdQij9JGrVG7u8YJJscEQHqyHMYFVCEMjxmsge5BO6Vx5OWmUE3wXPzb5TbyTS4 Wozvpsj2Kz0l61rRUhiMlE0D9dpijgaRYFB+M7R2casH3CdhGQbBHTRiqBkZa6iq
+yg88g9rYTUXrzz+poCyKpaur45qBsdw35lJ8nq69VJj2xJLGQDwoTgGSXRuPciC SDkTiTwNJQQJov8yPTsR+9P8OOuV6QN+DGm/FXJJFaPnsHw/HDy7EAbA1PcdbSyK
3OilQI+Ma+j8qQGJxJ8WJxISlf1cuhp+V4ZUd1lawlM5hAXyXmHRlH4pun4y+g7O XvJ8nVjdNhCEGbLGVSwAQLO+78hChVIN5YH+QSrP84YBSxKZYArnf4z2e9drqAN3
O34+fE3pK25JjVCicMT/rC2A/sb95j/fHTzzJpbB70U0I50maTcIsOkyw6aiF//E KmC26TkaUzkXnndnxOXBEIOSmyCdD4Dutg1XPE/bs8rA6rVGIR3pKXbCr29Z8hZn
0ShTDz14x22SCMolUc6hxTDZvBB6yrcJHd7d9CCnFH2Sgo13QrtNJ/atXgm13HGh Cn9jbxwDwTX865ljR1Oc3dnIeCWa9AS/uHaSMdGlbGbDrt4Bj/nyyfu8xc034K/0
wBzRwK38XUGl/J4pJaxAupTVCPriStUM3m0EYHNelRRUE91pbyeGT0rvOuv00uLw uPh3hF3FLWNAomRVZCvtuh/v7IEIQEgUbvQMWBhZJ8hu3HdtV8V9TIAryVKzEzGy
Rj7K7hJZR8avTKWmKrVBVpq+gSojGW1DwBS0NiDNkZs0d/IjB1wkzczEgdZjXwID Q72UHuQyK0njRDTmA/T+jn7P8GWOuf9eNdzd0gH0gcEuhCZFxPPRvUAeDuC7DQID
AQABo3QwcjAfBgNVHSMEGDAWgBTa+Ns6QzqlNvnTGszkouQQtZnVJDAdBgNVHSUE AQABo1YwVDAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwFAYDVR0RAQH/
FjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwEQYDVR0RBAowCIIGY2xpZW50MB0GA1Ud BAowCIIGY2xpZW50MB0GA1UdDgQWBBQ5KZwTD8+4CqLnbM/cBSXg8XeLXTANBgkq
DgQWBBT1xg5sXkypRBwvCxBuyfoanaiZ5jANBgkqhkiG9w0BAQsFAAOCAgEAvUAz hkiG9w0BAQsFAAOCAgEABDkb3iyEOl1xKq1wxyRzf2L8qfPXAQw71FxMbgHArM+a
YwIjxY/0KHZDU8owdILVqKChzfLcy9OHNPyEI3TSOI8X6gNtBO+HE6r8aWGcC9vw e44wJGO3mZgPH0trOaJ+tuN6erB5YbZfsoX+xFacwskj9pKyb4QJLr/ENmJZRgyL
zzeIsNQ3UEjvRWi2r+vUVbiPTbFdZboNDSZv6ZmGHxwd85VsjXRGoXV6koCT/9zi wp5P6PB6IUJhvryvy/GxrG938YGmFtYQ+ikeJw5PWhB6218C1aZ9hsi94wZ1Zzrc
9/lCM1DwqwYSwBphMJdRVFRUMluSYk1oHflGeA18xgGuts4eFivJwhabGm1AdVVQ Ry0q0D4QvIEZ0X2HL1Harc7gerE3VqhgQ7EWyImM+lCRtNDduwDQnZauwhr2r6cW
/CYvqCuTxd/DCzWZBdyxYpDru64i/kyeJCt1pThKEFDWmpumFdBI4CxJ0OhxVSGp XG4VTe1RCNsDA0xinXQE2Xf9voCd0Zf6wOOXJseQtrXpf+tG4N13cy5heF5ihed1
dOXzK+Y6ULepxCvi6/OpSog52jQ6PnNd1ghiYtq7yO1T4GQz65M1vtHHVvQ3gfBE hDxSeki0KjTM+18kVVfVm4fzxf1Zg0gm54UlzWceIWh9EtnWMUV08H0D1M9YNmW8
AuKYQp6io7ypitRx+LpjsBQenyP4FFGfrq7pm90nLluOBOArfSdF0N+CP2wo/YFV hWTupk7M+jAw8Y+suHOe6/RLi0+fb9NSJpIpq4GqJ5UF2kerXHX0SvuAavoXyB0j
9BGf89OtvRi3BXCm2NXkE/Sc4We26tY8x7xNLOmNs8YOT0O3r/EQ690W9GIwRMx0 CQrUXkRScEKOO2KAbVExSG56Ff7Ee8cRUAQ6rLC5pQRACq/R0sa6RcUsFPXul3Yv
m0r/RXWn5V3o4Jib9r8eH9NzaDstD8g9dECcGfM4fHoM/DAGFaRrNcjMsS1APP3L vbO2rTuArAUPkNVFknwkndheN4lOslRd1If02HunZETmsnal6p+nmuMWt2pQ2fDA
jp7+BfBSXtrz9V6rVJ3CBLXlLK0AuSm7bqd1MJsGA9uMLpsVZIUA+KawcmPGdPU+ vIguG54FyQ1T1IbF/QhfTEY62CQAebcgutnqqJHt9qe7Jr6ev57hMrJDEjotSzkY
NxdpBCtzyurQSUyaTLtVqSeP35gMAwaNzUDph8Uh+vHz+kRwgXS19OQvTaud5LJu OhOVrcYqgLldr1nBqNVlIK/4VrDaWH8H5dNJ72gA9aMNVH4/bSTJhuO7cJkLnHw=
nQe4JNS+u5e2VDEBWUxt8NTpu6eShDN0iIEHtxA=
-----END CERTIFICATE----- -----END CERTIFICATE-----

View File

@ -5,12 +5,7 @@ from django.urls import reverse
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import ( from authentik.core.tests.utils import create_test_brand, create_test_flow, create_test_user
create_test_brand,
create_test_cert,
create_test_flow,
create_test_user,
)
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.enterprise.stages.mtls.models import ( from authentik.enterprise.stages.mtls.models import (
CertAttributes, CertAttributes,
@ -132,18 +127,6 @@ class MTLSStageTests(FlowTestCase):
self.assertEqual(res.status_code, 200) self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied") self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
def test_invalid_cert(self):
"""Test invalid certificate"""
cert = create_test_cert()
with self.assertFlowFinishes() as plan:
res = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
headers={"X-Forwarded-TLS-Client-Cert": quote_plus(cert.certificate_data)},
)
self.assertEqual(res.status_code, 200)
self.assertStageResponse(res, self.flow, component="ak-stage-access-denied")
self.assertNotIn(PLAN_CONTEXT_PENDING_USER, plan().context)
def test_auth_no_user(self): def test_auth_no_user(self):
"""Test auth with no user""" """Test auth with no user"""
User.objects.filter(username="client").delete() User.objects.filter(username="client").delete()
@ -217,12 +200,14 @@ class MTLSStageTests(FlowTestCase):
self.assertEqual( self.assertEqual(
plan().context[PLAN_CONTEXT_CERTIFICATE], plan().context[PLAN_CONTEXT_CERTIFICATE],
{ {
"fingerprint_sha1": "52:39:ca:1e:3a:1f:78:3a:9f:26:3b:c2:84:99:48:68:99:99:81:8a", "fingerprint_sha1": (
"08:d4:a4:79:25:ca:c3:51:28:88:bb:30:c2:96:c3:44:5a:eb:18:07:84:ca:b4:75:27:74:61:19:8a:6a:af:fc"
),
"fingerprint_sha256": ( "fingerprint_sha256": (
"c1:07:8b:7c:e9:02:57:87:1e:92:e5:81:83:21:bc:92:c7:47:65:e3:97:fb:05:97:6f:36:9e:b5:31:77:98:b7" "08:d4:a4:79:25:ca:c3:51:28:88:bb:30:c2:96:c3:44:5a:eb:18:07:84:ca:b4:75:27:74:61:19:8a:6a:af:fc"
), ),
"issuer": "OU=Self-signed,O=authentik,CN=authentik Test CA", "issuer": "OU=Self-signed,O=authentik,CN=authentik Test CA",
"serial_number": "70153443448884702681996102271549704759327537151", "serial_number": "630532384467334865093173111400266136879266564943",
"subject": "CN=client", "subject": "CN=client",
}, },
) )

View File

@ -1,18 +0,0 @@
# Generated by Django 5.1.9 on 2025-05-27 12:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_flows", "0027_auto_20231028_1424"),
]
operations = [
migrations.AddField(
model_name="flowtoken",
name="revoke_on_execution",
field=models.BooleanField(default=True),
),
]

View File

@ -303,10 +303,9 @@ class FlowToken(Token):
flow = models.ForeignKey(Flow, on_delete=models.CASCADE) flow = models.ForeignKey(Flow, on_delete=models.CASCADE)
_plan = models.TextField() _plan = models.TextField()
revoke_on_execution = models.BooleanField(default=True)
@staticmethod @staticmethod
def pickle(plan: "FlowPlan") -> str: def pickle(plan) -> str:
"""Pickle into string""" """Pickle into string"""
data = dumps(plan) data = dumps(plan)
return b64encode(data).decode() return b64encode(data).decode()

View File

@ -99,10 +99,9 @@ class ChallengeStageView(StageView):
self.logger.debug("Got StageInvalidException", exc=exc) self.logger.debug("Got StageInvalidException", exc=exc)
return self.executor.stage_invalid() return self.executor.stage_invalid()
if not challenge.is_valid(): if not challenge.is_valid():
self.logger.error( self.logger.warning(
"f(ch): Invalid challenge", "f(ch): Invalid challenge",
errors=challenge.errors, errors=challenge.errors,
challenge=challenge.data,
) )
return HttpChallengeResponse(challenge) return HttpChallengeResponse(challenge)

View File

@ -146,8 +146,7 @@ class FlowExecutorView(APIView):
except (AttributeError, EOFError, ImportError, IndexError) as exc: except (AttributeError, EOFError, ImportError, IndexError) as exc:
LOGGER.warning("f(exec): Failed to restore token plan", exc=exc) LOGGER.warning("f(exec): Failed to restore token plan", exc=exc)
finally: finally:
if token.revoke_on_execution: token.delete()
token.delete()
if not isinstance(plan, FlowPlan): if not isinstance(plan, FlowPlan):
return None return None
plan.context[PLAN_CONTEXT_IS_RESTORED] = token plan.context[PLAN_CONTEXT_IS_RESTORED] = token

View File

@ -81,6 +81,7 @@ debugger: false
log_level: info log_level: info
session_storage: cache
sessions: sessions:
unauthenticated_age: days=1 unauthenticated_age: days=1

View File

@ -1,7 +1,6 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import asdict from dataclasses import asdict
from celery import group
from celery.exceptions import Retry from celery.exceptions import Retry
from celery.result import allow_join_result from celery.result import allow_join_result
from django.core.paginator import Paginator from django.core.paginator import Paginator
@ -83,41 +82,21 @@ class SyncTasks:
self.logger.debug("Failed to acquire sync lock, skipping", provider=provider.name) self.logger.debug("Failed to acquire sync lock, skipping", provider=provider.name)
return return
try: try:
messages.append(_("Syncing users")) for page in users_paginator.page_range:
user_results = ( messages.append(_("Syncing page {page} of users".format(page=page)))
group( for msg in sync_objects.apply_async(
[ args=(class_to_path(User), page, provider_pk),
sync_objects.signature( time_limit=PAGE_TIMEOUT,
args=(class_to_path(User), page, provider_pk), soft_time_limit=PAGE_TIMEOUT,
time_limit=PAGE_TIMEOUT, ).get():
soft_time_limit=PAGE_TIMEOUT,
)
for page in users_paginator.page_range
]
)
.apply_async()
.get()
)
for result in user_results:
for msg in result:
messages.append(LogEvent(**msg)) messages.append(LogEvent(**msg))
messages.append(_("Syncing groups")) for page in groups_paginator.page_range:
group_results = ( messages.append(_("Syncing page {page} of groups".format(page=page)))
group( for msg in sync_objects.apply_async(
[ args=(class_to_path(Group), page, provider_pk),
sync_objects.signature( time_limit=PAGE_TIMEOUT,
args=(class_to_path(Group), page, provider_pk), soft_time_limit=PAGE_TIMEOUT,
time_limit=PAGE_TIMEOUT, ).get():
soft_time_limit=PAGE_TIMEOUT,
)
for page in groups_paginator.page_range
]
)
.apply_async()
.get()
)
for result in group_results:
for msg in result:
messages.append(LogEvent(**msg)) messages.append(LogEvent(**msg))
except TransientSyncException as exc: except TransientSyncException as exc:
self.logger.warning("transient sync exception", exc=exc) self.logger.warning("transient sync exception", exc=exc)
@ -130,7 +109,7 @@ class SyncTasks:
def sync_objects( def sync_objects(
self, object_type: str, page: int, provider_pk: int, override_dry_run=False, **filter self, object_type: str, page: int, provider_pk: int, override_dry_run=False, **filter
): ):
_object_type: type[Model] = path_to_class(object_type) _object_type = path_to_class(object_type)
self.logger = get_logger().bind( self.logger = get_logger().bind(
provider_type=class_to_path(self._provider_model), provider_type=class_to_path(self._provider_model),
provider_pk=provider_pk, provider_pk=provider_pk,
@ -153,19 +132,6 @@ class SyncTasks:
self.logger.debug("starting discover") self.logger.debug("starting discover")
client.discover() client.discover()
self.logger.debug("starting sync for page", page=page) self.logger.debug("starting sync for page", page=page)
messages.append(
asdict(
LogEvent(
_(
"Syncing page {page} of {object_type}".format(
page=page, object_type=_object_type._meta.verbose_name_plural
)
),
log_level="info",
logger=f"{provider._meta.verbose_name}@{object_type}",
)
)
)
for obj in paginator.page(page).object_list: for obj in paginator.page(page).object_list:
obj: Model obj: Model
try: try:

View File

@ -1,11 +1,9 @@
"""Websocket tests""" """Websocket tests"""
from dataclasses import asdict from dataclasses import asdict
from unittest.mock import patch
from channels.routing import URLRouter from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator from channels.testing import WebsocketCommunicator
from django.contrib.contenttypes.models import ContentType
from django.test import TransactionTestCase from django.test import TransactionTestCase
from authentik import __version__ from authentik import __version__
@ -16,12 +14,6 @@ from authentik.providers.proxy.models import ProxyProvider
from authentik.root import websocket from authentik.root import websocket
def patched__get_ct_cached(app_label, codename):
"""Caches `ContentType` instances like its `QuerySet` does."""
return ContentType.objects.get(app_label=app_label, permission__codename=codename)
@patch("guardian.shortcuts._get_ct_cached", patched__get_ct_cached)
class TestOutpostWS(TransactionTestCase): class TestOutpostWS(TransactionTestCase):
"""Websocket tests""" """Websocket tests"""
@ -46,7 +38,6 @@ class TestOutpostWS(TransactionTestCase):
) )
connected, _ = await communicator.connect() connected, _ = await communicator.connect()
self.assertFalse(connected) self.assertFalse(connected)
await communicator.disconnect()
async def test_auth_valid(self): async def test_auth_valid(self):
"""Test auth with token""" """Test auth with token"""
@ -57,7 +48,6 @@ class TestOutpostWS(TransactionTestCase):
) )
connected, _ = await communicator.connect() connected, _ = await communicator.connect()
self.assertTrue(connected) self.assertTrue(connected)
await communicator.disconnect()
async def test_send(self): async def test_send(self):
"""Test sending of Hello""" """Test sending of Hello"""

View File

@ -1,12 +1,11 @@
"""Authentik policy dummy app config""" """Authentik policy dummy app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPolicyDummyConfig(ManagedAppConfig): class AuthentikPolicyDummyConfig(AppConfig):
"""Authentik policy_dummy app config""" """Authentik policy_dummy app config"""
name = "authentik.policies.dummy" name = "authentik.policies.dummy"
label = "authentik_policies_dummy" label = "authentik_policies_dummy"
verbose_name = "authentik Policies.Dummy" verbose_name = "authentik Policies.Dummy"
default = True

View File

@ -1,11 +1,11 @@
"""authentik policy engine""" """authentik policy engine"""
from collections.abc import Iterable from collections.abc import Iterator
from multiprocessing import Pipe, current_process from multiprocessing import Pipe, current_process
from multiprocessing.connection import Connection from multiprocessing.connection import Connection
from time import perf_counter
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Count, Q, QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from sentry_sdk import start_span from sentry_sdk import start_span
from sentry_sdk.tracing import Span from sentry_sdk.tracing import Span
@ -67,11 +67,14 @@ class PolicyEngine:
self.__processes: list[PolicyProcessInfo] = [] self.__processes: list[PolicyProcessInfo] = []
self.use_cache = True self.use_cache = True
self.__expected_result_count = 0 self.__expected_result_count = 0
self.__static_result: PolicyResult | None = None
def bindings(self) -> QuerySet[PolicyBinding] | Iterable[PolicyBinding]: def iterate_bindings(self) -> Iterator[PolicyBinding]:
"""Make sure all Policies are their respective classes""" """Make sure all Policies are their respective classes"""
return PolicyBinding.objects.filter(target=self.__pbm, enabled=True).order_by("order") return (
PolicyBinding.objects.filter(target=self.__pbm, enabled=True)
.order_by("order")
.iterator()
)
def _check_policy_type(self, binding: PolicyBinding): def _check_policy_type(self, binding: PolicyBinding):
"""Check policy type, make sure it's not the root class as that has no logic implemented""" """Check policy type, make sure it's not the root class as that has no logic implemented"""
@ -81,66 +84,30 @@ class PolicyEngine:
def _check_cache(self, binding: PolicyBinding): def _check_cache(self, binding: PolicyBinding):
if not self.use_cache: if not self.use_cache:
return False return False
# It's a bit silly to time this, but before = perf_counter()
with HIST_POLICIES_EXECUTION_TIME.labels( key = cache_key(binding, self.request)
binding_order=binding.order, cached_policy = cache.get(key, None)
binding_target_type=binding.target_type, duration = max(perf_counter() - before, 0)
binding_target_name=binding.target_name, if not cached_policy:
object_pk=str(self.request.obj.pk), return False
object_type=class_to_path(self.request.obj.__class__),
mode="cache_retrieve",
).time():
key = cache_key(binding, self.request)
cached_policy = cache.get(key, None)
if not cached_policy:
return False
self.logger.debug( self.logger.debug(
"P_ENG: Taking result from cache", "P_ENG: Taking result from cache",
binding=binding, binding=binding,
cache_key=key, cache_key=key,
request=self.request, request=self.request,
) )
HIST_POLICIES_EXECUTION_TIME.labels(
binding_order=binding.order,
binding_target_type=binding.target_type,
binding_target_name=binding.target_name,
object_pk=str(self.request.obj.pk),
object_type=class_to_path(self.request.obj.__class__),
mode="cache_retrieve",
).observe(duration)
# It's a bit silly to time this, but
self.__cached_policies.append(cached_policy) self.__cached_policies.append(cached_policy)
return True return True
def compute_static_bindings(self, bindings: QuerySet[PolicyBinding]):
"""Check static bindings if possible"""
aggrs = {
"total": Count(
"pk", filter=Q(Q(group__isnull=False) | Q(user__isnull=False), policy=None)
),
}
if self.request.user.pk:
all_groups = self.request.user.all_groups()
aggrs["passing"] = Count(
"pk",
filter=Q(
Q(
Q(user=self.request.user) | Q(group__in=all_groups),
negate=False,
)
| Q(
Q(~Q(user=self.request.user), user__isnull=False)
| Q(~Q(group__in=all_groups), group__isnull=False),
negate=True,
),
enabled=True,
),
)
matched_bindings = bindings.aggregate(**aggrs)
passing = False
if matched_bindings["total"] == 0 and matched_bindings.get("passing", 0) == 0:
# If we didn't find any static bindings, do nothing
return
self.logger.debug("P_ENG: Found static bindings", **matched_bindings)
if matched_bindings.get("passing", 0) > 0:
# Any passing static binding -> passing
passing = True
elif matched_bindings["total"] > 0 and matched_bindings.get("passing", 0) < 1:
# No matching static bindings but at least one is configured -> not passing
passing = False
self.__static_result = PolicyResult(passing)
def build(self) -> "PolicyEngine": def build(self) -> "PolicyEngine":
"""Build wrapper which monitors performance""" """Build wrapper which monitors performance"""
with ( with (
@ -156,12 +123,7 @@ class PolicyEngine:
span: Span span: Span
span.set_data("pbm", self.__pbm) span.set_data("pbm", self.__pbm)
span.set_data("request", self.request) span.set_data("request", self.request)
bindings = self.bindings() for binding in self.iterate_bindings():
policy_bindings = bindings
if isinstance(bindings, QuerySet):
self.compute_static_bindings(bindings)
policy_bindings = [x for x in bindings if x.policy]
for binding in policy_bindings:
self.__expected_result_count += 1 self.__expected_result_count += 1
self._check_policy_type(binding) self._check_policy_type(binding)
@ -191,13 +153,10 @@ class PolicyEngine:
@property @property
def result(self) -> PolicyResult: def result(self) -> PolicyResult:
"""Get policy-checking result""" """Get policy-checking result"""
self.__processes.sort(key=lambda x: x.binding.order)
process_results: list[PolicyResult] = [x.result for x in self.__processes if x.result] process_results: list[PolicyResult] = [x.result for x in self.__processes if x.result]
all_results = list(process_results + self.__cached_policies) all_results = list(process_results + self.__cached_policies)
if len(all_results) < self.__expected_result_count: # pragma: no cover if len(all_results) < self.__expected_result_count: # pragma: no cover
raise AssertionError("Got less results than polices") raise AssertionError("Got less results than polices")
if self.__static_result:
all_results.append(self.__static_result)
# No results, no policies attached -> passing # No results, no policies attached -> passing
if len(all_results) == 0: if len(all_results) == 0:
return PolicyResult(self.empty_result) return PolicyResult(self.empty_result)

View File

@ -1,12 +1,11 @@
"""authentik Event Matcher policy app config""" """authentik Event Matcher policy app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPoliciesEventMatcherConfig(ManagedAppConfig): class AuthentikPoliciesEventMatcherConfig(AppConfig):
"""authentik Event Matcher policy app config""" """authentik Event Matcher policy app config"""
name = "authentik.policies.event_matcher" name = "authentik.policies.event_matcher"
label = "authentik_policies_event_matcher" label = "authentik_policies_event_matcher"
verbose_name = "authentik Policies.Event Matcher" verbose_name = "authentik Policies.Event Matcher"
default = True

View File

@ -1,12 +1,11 @@
"""Authentik policy_expiry app config""" """Authentik policy_expiry app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPolicyExpiryConfig(ManagedAppConfig): class AuthentikPolicyExpiryConfig(AppConfig):
"""Authentik policy_expiry app config""" """Authentik policy_expiry app config"""
name = "authentik.policies.expiry" name = "authentik.policies.expiry"
label = "authentik_policies_expiry" label = "authentik_policies_expiry"
verbose_name = "authentik Policies.Expiry" verbose_name = "authentik Policies.Expiry"
default = True

View File

@ -1,12 +1,11 @@
"""Authentik policy_expression app config""" """Authentik policy_expression app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPolicyExpressionConfig(ManagedAppConfig): class AuthentikPolicyExpressionConfig(AppConfig):
"""Authentik policy_expression app config""" """Authentik policy_expression app config"""
name = "authentik.policies.expression" name = "authentik.policies.expression"
label = "authentik_policies_expression" label = "authentik_policies_expression"
verbose_name = "authentik Policies.Expression" verbose_name = "authentik Policies.Expression"
default = True

View File

@ -1,12 +1,11 @@
"""Authentik policy geoip app config""" """Authentik policy geoip app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPolicyGeoIPConfig(ManagedAppConfig): class AuthentikPolicyGeoIPConfig(AppConfig):
"""Authentik policy_geoip app config""" """Authentik policy_geoip app config"""
name = "authentik.policies.geoip" name = "authentik.policies.geoip"
label = "authentik_policies_geoip" label = "authentik_policies_geoip"
verbose_name = "authentik Policies.GeoIP" verbose_name = "authentik Policies.GeoIP"
default = True

View File

@ -1,12 +1,11 @@
"""authentik Password policy app config""" """authentik Password policy app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPoliciesPasswordConfig(ManagedAppConfig): class AuthentikPoliciesPasswordConfig(AppConfig):
"""authentik Password policy app config""" """authentik Password policy app config"""
name = "authentik.policies.password" name = "authentik.policies.password"
label = "authentik_policies_password" label = "authentik_policies_password"
verbose_name = "authentik Policies.Password" verbose_name = "authentik Policies.Password"
default = True

View File

@ -1,12 +1,9 @@
"""policy engine tests""" """policy engine tests"""
from django.core.cache import cache from django.core.cache import cache
from django.db import connections
from django.test import TestCase from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from authentik.core.models import Group from authentik.core.tests.utils import create_test_admin_user
from authentik.core.tests.utils import create_test_user
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
@ -22,7 +19,7 @@ class TestPolicyEngine(TestCase):
def setUp(self): def setUp(self):
clear_policy_cache() clear_policy_cache()
self.user = create_test_user() self.user = create_test_admin_user()
self.policy_false = DummyPolicy.objects.create( self.policy_false = DummyPolicy.objects.create(
name=generate_id(), result=False, wait_min=0, wait_max=1 name=generate_id(), result=False, wait_min=0, wait_max=1
) )
@ -130,43 +127,3 @@ class TestPolicyEngine(TestCase):
self.assertEqual(len(cache.keys(f"{CACHE_PREFIX}{binding.policy_binding_uuid.hex}*")), 1) self.assertEqual(len(cache.keys(f"{CACHE_PREFIX}{binding.policy_binding_uuid.hex}*")), 1)
self.assertEqual(engine.build().passing, False) self.assertEqual(engine.build().passing, False)
self.assertEqual(len(cache.keys(f"{CACHE_PREFIX}{binding.policy_binding_uuid.hex}*")), 1) self.assertEqual(len(cache.keys(f"{CACHE_PREFIX}{binding.policy_binding_uuid.hex}*")), 1)
def test_engine_static_bindings(self):
"""Test static bindings"""
group_a = Group.objects.create(name=generate_id())
group_b = Group.objects.create(name=generate_id())
group_b.users.add(self.user)
user = create_test_user()
for case in [
{
"message": "Group, not member",
"binding_args": {"group": group_a},
"passing": False,
},
{
"message": "Group, member",
"binding_args": {"group": group_b},
"passing": True,
},
{
"message": "User, other",
"binding_args": {"user": user},
"passing": False,
},
{
"message": "User, same",
"binding_args": {"user": self.user},
"passing": True,
},
]:
with self.subTest():
pbm = PolicyBindingModel.objects.create()
for x in range(1000):
PolicyBinding.objects.create(target=pbm, order=x, **case["binding_args"])
engine = PolicyEngine(pbm, self.user)
engine.use_cache = False
with CaptureQueriesContext(connections["default"]) as ctx:
engine.build()
self.assertLess(ctx.final_queries, 1000)
self.assertEqual(engine.result.passing, case["passing"])

View File

@ -29,12 +29,13 @@ class TestPolicyProcess(TestCase):
def setUp(self): def setUp(self):
clear_policy_cache() clear_policy_cache()
self.factory = RequestFactory() self.factory = RequestFactory()
self.user = User.objects.create_user(username=generate_id()) self.user = User.objects.create_user(username="policyuser")
def test_group_passing(self): def test_group_passing(self):
"""Test binding to group""" """Test binding to group"""
group = Group.objects.create(name=generate_id()) group = Group.objects.create(name="test-group")
group.users.add(self.user) group.users.add(self.user)
group.save()
binding = PolicyBinding(group=group) binding = PolicyBinding(group=group)
request = PolicyRequest(self.user) request = PolicyRequest(self.user)
@ -43,7 +44,8 @@ class TestPolicyProcess(TestCase):
def test_group_negative(self): def test_group_negative(self):
"""Test binding to group""" """Test binding to group"""
group = Group.objects.create(name=generate_id()) group = Group.objects.create(name="test-group")
group.save()
binding = PolicyBinding(group=group) binding = PolicyBinding(group=group)
request = PolicyRequest(self.user) request = PolicyRequest(self.user)
@ -113,10 +115,8 @@ class TestPolicyProcess(TestCase):
def test_exception(self): def test_exception(self):
"""Test policy execution""" """Test policy execution"""
policy = Policy.objects.create(name=generate_id()) policy = Policy.objects.create(name="test-execution")
binding = PolicyBinding( binding = PolicyBinding(policy=policy, target=Application.objects.create(name="test"))
policy=policy, target=Application.objects.create(name=generate_id())
)
request = PolicyRequest(self.user) request = PolicyRequest(self.user)
response = PolicyProcess(binding, request, None).execute() response = PolicyProcess(binding, request, None).execute()
@ -125,15 +125,13 @@ class TestPolicyProcess(TestCase):
def test_execution_logging(self): def test_execution_logging(self):
"""Test policy execution creates event""" """Test policy execution creates event"""
policy = DummyPolicy.objects.create( policy = DummyPolicy.objects.create(
name=generate_id(), name="test-execution-logging",
result=False, result=False,
wait_min=0, wait_min=0,
wait_max=1, wait_max=1,
execution_logging=True, execution_logging=True,
) )
binding = PolicyBinding( binding = PolicyBinding(policy=policy, target=Application.objects.create(name="test"))
policy=policy, target=Application.objects.create(name=generate_id())
)
http_request = self.factory.get(reverse("authentik_api:user-impersonate-end")) http_request = self.factory.get(reverse("authentik_api:user-impersonate-end"))
http_request.user = self.user http_request.user = self.user
@ -188,15 +186,13 @@ class TestPolicyProcess(TestCase):
def test_execution_logging_anonymous(self): def test_execution_logging_anonymous(self):
"""Test policy execution creates event with anonymous user""" """Test policy execution creates event with anonymous user"""
policy = DummyPolicy.objects.create( policy = DummyPolicy.objects.create(
name=generate_id(), name="test-execution-logging-anon",
result=False, result=False,
wait_min=0, wait_min=0,
wait_max=1, wait_max=1,
execution_logging=True, execution_logging=True,
) )
binding = PolicyBinding( binding = PolicyBinding(policy=policy, target=Application.objects.create(name="test"))
policy=policy, target=Application.objects.create(name=generate_id())
)
user = AnonymousUser() user = AnonymousUser()
@ -223,9 +219,9 @@ class TestPolicyProcess(TestCase):
def test_raises(self): def test_raises(self):
"""Test policy that raises error""" """Test policy that raises error"""
policy_raises = ExpressionPolicy.objects.create(name=generate_id(), expression="{{ 0/0 }}") policy_raises = ExpressionPolicy.objects.create(name="raises", expression="{{ 0/0 }}")
binding = PolicyBinding( binding = PolicyBinding(
policy=policy_raises, target=Application.objects.create(name=generate_id()) policy=policy_raises, target=Application.objects.create(name="test")
) )
request = PolicyRequest(self.user) request = PolicyRequest(self.user)

View File

@ -1,12 +1,11 @@
"""authentik ldap provider app config""" """authentik ldap provider app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikProviderLDAPConfig(ManagedAppConfig): class AuthentikProviderLDAPConfig(AppConfig):
"""authentik ldap provider app config""" """authentik ldap provider app config"""
name = "authentik.providers.ldap" name = "authentik.providers.ldap"
label = "authentik_providers_ldap" label = "authentik_providers_ldap"
verbose_name = "authentik Providers.LDAP" verbose_name = "authentik Providers.LDAP"
default = True

View File

@ -7,8 +7,10 @@ from django.db import migrations
def migrate_search_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): def migrate_search_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.core.models import User
from django.apps import apps as real_apps from django.apps import apps as real_apps
from django.contrib.auth.management import create_permissions from django.contrib.auth.management import create_permissions
from guardian.shortcuts import UserObjectPermission
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias

View File

@ -50,4 +50,3 @@ AMR_PASSWORD = "pwd" # nosec
AMR_MFA = "mfa" AMR_MFA = "mfa"
AMR_OTP = "otp" AMR_OTP = "otp"
AMR_WEBAUTHN = "user" AMR_WEBAUTHN = "user"
AMR_SMART_CARD = "sc"

View File

@ -16,7 +16,6 @@ from authentik.providers.oauth2.constants import (
ACR_AUTHENTIK_DEFAULT, ACR_AUTHENTIK_DEFAULT,
AMR_MFA, AMR_MFA,
AMR_PASSWORD, AMR_PASSWORD,
AMR_SMART_CARD,
AMR_WEBAUTHN, AMR_WEBAUTHN,
) )
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
@ -140,10 +139,9 @@ class IDToken:
amr.append(AMR_PASSWORD) amr.append(AMR_PASSWORD)
if method == "auth_webauthn_pwl": if method == "auth_webauthn_pwl":
amr.append(AMR_WEBAUTHN) amr.append(AMR_WEBAUTHN)
if "certificate" in method_args:
amr.append(AMR_SMART_CARD)
if "mfa_devices" in method_args: if "mfa_devices" in method_args:
amr.append(AMR_MFA) if len(amr) > 0:
amr.append(AMR_MFA)
if amr: if amr:
id_token.amr = amr id_token.amr = amr

View File

@ -10,11 +10,3 @@ class AuthentikProviderProxyConfig(ManagedAppConfig):
label = "authentik_providers_proxy" label = "authentik_providers_proxy"
verbose_name = "authentik Providers.Proxy" verbose_name = "authentik Providers.Proxy"
default = True default = True
@ManagedAppConfig.reconcile_tenant
def proxy_set_defaults(self):
from authentik.providers.proxy.models import ProxyProvider
for provider in ProxyProvider.objects.all():
provider.set_oauth_defaults()
provider.save()

View File

@ -47,8 +47,6 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
def reconcile(self, current: V1Ingress, reference: V1Ingress): def reconcile(self, current: V1Ingress, reference: V1Ingress):
super().reconcile(current, reference) super().reconcile(current, reference)
self._check_annotations(current, reference) self._check_annotations(current, reference)
if current.spec.ingress_class_name != reference.spec.ingress_class_name:
raise NeedsUpdate()
# Create a list of all expected host and tls hosts # Create a list of all expected host and tls hosts
expected_hosts = [] expected_hosts = []
expected_hosts_tls = [] expected_hosts_tls = []

View File

@ -2,13 +2,25 @@
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer from channels.layers import get_channel_layer
from django.db import DatabaseError, InternalError, ProgrammingError
from authentik.outposts.consumer import OUTPOST_GROUP from authentik.outposts.consumer import OUTPOST_GROUP
from authentik.outposts.models import Outpost, OutpostType from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.oauth2.id_token import hash_session_key from authentik.providers.oauth2.id_token import hash_session_key
from authentik.providers.proxy.models import ProxyProvider
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError),
)
def proxy_set_defaults():
"""Ensure correct defaults are set for all providers"""
for provider in ProxyProvider.objects.all():
provider.set_oauth_defaults()
provider.save()
@CELERY_APP.task() @CELERY_APP.task()
def proxy_on_logout(session_id: str): def proxy_on_logout(session_id: str):
"""Update outpost instances connected to a single outpost""" """Update outpost instances connected to a single outpost"""

View File

@ -166,6 +166,7 @@ class ConnectionToken(ExpiringModel):
always_merger.merge(settings, default_settings) always_merger.merge(settings, default_settings)
always_merger.merge(settings, self.endpoint.provider.settings) always_merger.merge(settings, self.endpoint.provider.settings)
always_merger.merge(settings, self.endpoint.settings) always_merger.merge(settings, self.endpoint.settings)
always_merger.merge(settings, self.settings)
def mapping_evaluator(mappings: QuerySet): def mapping_evaluator(mappings: QuerySet):
for mapping in mappings: for mapping in mappings:
@ -190,7 +191,6 @@ class ConnectionToken(ExpiringModel):
mapping_evaluator( mapping_evaluator(
RACPropertyMapping.objects.filter(endpoint__in=[self.endpoint]).order_by("name") RACPropertyMapping.objects.filter(endpoint__in=[self.endpoint]).order_by("name")
) )
always_merger.merge(settings, self.settings)
settings["drive-path"] = f"/tmp/connection/{self.token}" # nosec settings["drive-path"] = f"/tmp/connection/{self.token}" # nosec
settings["create-drive-path"] = "true" settings["create-drive-path"] = "true"

View File

@ -90,6 +90,23 @@ class TestModels(TransactionTestCase):
"resize-method": "display-update", "resize-method": "display-update",
}, },
) )
# Set settings in token
token.settings = {
"level": "token",
}
token.save()
self.assertEqual(
token.get_settings(),
{
"hostname": self.endpoint.host.split(":")[0],
"port": "1324",
"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) # Set settings in property mapping (provider)
mapping = RACPropertyMapping.objects.create( mapping = RACPropertyMapping.objects.create(
name=generate_id(), name=generate_id(),
@ -134,22 +151,3 @@ class TestModels(TransactionTestCase):
"resize-method": "display-update", "resize-method": "display-update",
}, },
) )
# Set settings in token
token.settings = {
"level": "token",
}
token.save()
self.assertEqual(
token.get_settings(),
{
"hostname": self.endpoint.host.split(":")[0],
"port": "1324",
"client-name": f"authentik - {self.user}",
"drive-path": path,
"create-drive-path": "true",
"foo": "true",
"bar": "6",
"resize-method": "display-update",
"level": "token",
},
)

View File

@ -1,12 +1,11 @@
"""authentik radius provider app config""" """authentik radius provider app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikProviderRadiusConfig(ManagedAppConfig): class AuthentikProviderRadiusConfig(AppConfig):
"""authentik radius provider app config""" """authentik radius provider app config"""
name = "authentik.providers.radius" name = "authentik.providers.radius"
label = "authentik_providers_radius" label = "authentik_providers_radius"
verbose_name = "authentik Providers.Radius" verbose_name = "authentik Providers.Radius"
default = True

View File

@ -1,13 +1,12 @@
"""authentik SAML IdP app config""" """authentik SAML IdP app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikProviderSAMLConfig(ManagedAppConfig): class AuthentikProviderSAMLConfig(AppConfig):
"""authentik SAML IdP app config""" """authentik SAML IdP app config"""
name = "authentik.providers.saml" name = "authentik.providers.saml"
label = "authentik_providers_saml" label = "authentik_providers_saml"
verbose_name = "authentik Providers.SAML" verbose_name = "authentik Providers.SAML"
mountpoint = "application/saml/" mountpoint = "application/saml/"
default = True

View File

@ -47,16 +47,15 @@ class SCIMGroupClient(SCIMClient[Group, SCIMProviderGroup, SCIMGroupSchema]):
def to_schema(self, obj: Group, connection: SCIMProviderGroup) -> SCIMGroupSchema: def to_schema(self, obj: Group, connection: SCIMProviderGroup) -> SCIMGroupSchema:
"""Convert authentik user into SCIM""" """Convert authentik user into SCIM"""
raw_scim_group = super().to_schema(obj, connection) raw_scim_group = super().to_schema(
obj,
connection,
schemas=(SCIM_GROUP_SCHEMA,),
)
try: try:
scim_group = SCIMGroupSchema.model_validate(delete_none_values(raw_scim_group)) scim_group = SCIMGroupSchema.model_validate(delete_none_values(raw_scim_group))
except ValidationError as exc: except ValidationError as exc:
raise StopSync(exc, obj) from exc raise StopSync(exc, obj) from exc
if SCIM_GROUP_SCHEMA not in scim_group.schemas:
scim_group.schemas.insert(0, SCIM_GROUP_SCHEMA)
# As this might be unset, we need to tell pydantic it's set so ensure the schemas
# are included, even if its just the defaults
scim_group.schemas = list(scim_group.schemas)
if not scim_group.externalId: if not scim_group.externalId:
scim_group.externalId = str(obj.pk) scim_group.externalId = str(obj.pk)

View File

@ -31,16 +31,15 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
def to_schema(self, obj: User, connection: SCIMProviderUser) -> SCIMUserSchema: def to_schema(self, obj: User, connection: SCIMProviderUser) -> SCIMUserSchema:
"""Convert authentik user into SCIM""" """Convert authentik user into SCIM"""
raw_scim_user = super().to_schema(obj, connection) raw_scim_user = super().to_schema(
obj,
connection,
schemas=(SCIM_USER_SCHEMA,),
)
try: try:
scim_user = SCIMUserSchema.model_validate(delete_none_values(raw_scim_user)) scim_user = SCIMUserSchema.model_validate(delete_none_values(raw_scim_user))
except ValidationError as exc: except ValidationError as exc:
raise StopSync(exc, obj) from exc raise StopSync(exc, obj) from exc
if SCIM_USER_SCHEMA not in scim_user.schemas:
scim_user.schemas.insert(0, SCIM_USER_SCHEMA)
# As this might be unset, we need to tell pydantic it's set so ensure the schemas
# are included, even if its just the defaults
scim_user.schemas = list(scim_user.schemas)
if not scim_user.externalId: if not scim_user.externalId:
scim_user.externalId = str(obj.uid) scim_user.externalId = str(obj.uid)
return scim_user return scim_user

View File

@ -91,57 +91,6 @@ class SCIMUserTests(TestCase):
}, },
) )
@Mocker()
def test_user_create_custom_schema(self, mock: Mocker):
"""Test user creation with custom schema"""
schema = SCIMMapping.objects.create(
name="custom_schema",
expression="""return {"schemas": ["foo"]}""",
)
self.provider.property_mappings.add(schema)
scim_id = generate_id()
mock.get(
"https://localhost/ServiceProviderConfig",
json={},
)
mock.post(
"https://localhost/Users",
json={
"id": scim_id,
},
)
uid = generate_id()
user = User.objects.create(
username=uid,
name=f"{uid} {uid}",
email=f"{uid}@goauthentik.io",
)
self.assertEqual(mock.call_count, 2)
self.assertEqual(mock.request_history[0].method, "GET")
self.assertEqual(mock.request_history[1].method, "POST")
self.assertJSONEqual(
mock.request_history[1].body,
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "foo"],
"active": True,
"emails": [
{
"primary": True,
"type": "other",
"value": f"{uid}@goauthentik.io",
}
],
"externalId": user.uid,
"name": {
"familyName": uid,
"formatted": f"{uid} {uid}",
"givenName": uid,
},
"displayName": f"{uid} {uid}",
"userName": uid,
},
)
@Mocker() @Mocker()
def test_user_create_different_provider_same_id(self, mock: Mocker): def test_user_create_different_provider_same_id(self, mock: Mocker):
"""Test user creation with multiple providers that happen """Test user creation with multiple providers that happen
@ -435,7 +384,7 @@ class SCIMUserTests(TestCase):
self.assertIn(request.method, SAFE_METHODS) self.assertIn(request.method, SAFE_METHODS)
task = SystemTask.objects.filter(uid=slugify(self.provider.name)).first() task = SystemTask.objects.filter(uid=slugify(self.provider.name)).first()
self.assertIsNotNone(task) self.assertIsNotNone(task)
drop_msg = task.messages[3] drop_msg = task.messages[2]
self.assertEqual(drop_msg["event"], "Dropping mutating request due to dry run") self.assertEqual(drop_msg["event"], "Dropping mutating request due to dry run")
self.assertIsNotNone(drop_msg["attributes"]["url"]) self.assertIsNotNone(drop_msg["attributes"]["url"])
self.assertIsNotNone(drop_msg["attributes"]["body"]) self.assertIsNotNone(drop_msg["attributes"]["body"])

View File

@ -1,13 +1,12 @@
"""authentik Recovery app config""" """authentik Recovery app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikRecoveryConfig(ManagedAppConfig): class AuthentikRecoveryConfig(AppConfig):
"""authentik Recovery app config""" """authentik Recovery app config"""
name = "authentik.recovery" name = "authentik.recovery"
label = "authentik_recovery" label = "authentik_recovery"
verbose_name = "authentik Recovery" verbose_name = "authentik Recovery"
mountpoint = "recovery/" mountpoint = "recovery/"
default = True

View File

@ -98,7 +98,13 @@ def _get_startup_tasks_default_tenant() -> list[Callable]:
def _get_startup_tasks_all_tenants() -> list[Callable]: def _get_startup_tasks_all_tenants() -> list[Callable]:
"""Get all tasks to be run on startup for all tenants""" """Get all tasks to be run on startup for all tenants"""
return [] from authentik.admin.tasks import clear_update_notifications
from authentik.providers.proxy.tasks import proxy_set_defaults
return [
clear_update_notifications,
proxy_set_defaults,
]
@worker_ready.connect @worker_ready.connect

View File

@ -132,7 +132,7 @@ TENANT_CREATION_FAKES_MIGRATIONS = True
TENANT_BASE_SCHEMA = "template" TENANT_BASE_SCHEMA = "template"
PUBLIC_SCHEMA_NAME = CONFIG.get("postgresql.default_schema") PUBLIC_SCHEMA_NAME = CONFIG.get("postgresql.default_schema")
GUARDIAN_MONKEY_PATCH_USER = False GUARDIAN_MONKEY_PATCH = False
SPECTACULAR_SETTINGS = { SPECTACULAR_SETTINGS = {
"TITLE": "authentik", "TITLE": "authentik",
@ -424,7 +424,7 @@ else:
"BACKEND": "authentik.root.storages.FileStorage", "BACKEND": "authentik.root.storages.FileStorage",
"OPTIONS": { "OPTIONS": {
"location": Path(CONFIG.get("storage.media.file.path")), "location": Path(CONFIG.get("storage.media.file.path")),
"base_url": CONFIG.get("web.path", "/") + "media/", "base_url": "/media/",
}, },
} }
# Compatibility for apps not supporting top-level STORAGES # Compatibility for apps not supporting top-level STORAGES

View File

@ -31,8 +31,6 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
if kwargs.get("randomly_seed", None): if kwargs.get("randomly_seed", None):
self.args.append(f"--randomly-seed={kwargs['randomly_seed']}") self.args.append(f"--randomly-seed={kwargs['randomly_seed']}")
if kwargs.get("no_capture", False):
self.args.append("--capture=no")
settings.TEST = True settings.TEST = True
settings.CELERY["task_always_eager"] = True settings.CELERY["task_always_eager"] = True
@ -66,11 +64,6 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
"Default behaviour: use random.Random().getrandbits(32), so the seed is" "Default behaviour: use random.Random().getrandbits(32), so the seed is"
"different on each run.", "different on each run.",
) )
parser.add_argument(
"--no-capture",
action="store_true",
help="Disable any capturing of stdout/stderr during tests.",
)
def run_tests(self, test_labels, extra_tests=None, **kwargs): def run_tests(self, test_labels, extra_tests=None, **kwargs):
"""Run pytest and return the exitcode. """Run pytest and return the exitcode.

View File

@ -103,7 +103,6 @@ class LDAPSourceSerializer(SourceSerializer):
"user_object_filter", "user_object_filter",
"group_object_filter", "group_object_filter",
"group_membership_field", "group_membership_field",
"user_membership_attribute",
"object_uniqueness_field", "object_uniqueness_field",
"password_login_update_internal_password", "password_login_update_internal_password",
"sync_users", "sync_users",
@ -112,7 +111,6 @@ class LDAPSourceSerializer(SourceSerializer):
"sync_parent_group", "sync_parent_group",
"connectivity", "connectivity",
"lookup_groups_from_user", "lookup_groups_from_user",
"delete_not_found_objects",
] ]
extra_kwargs = {"bind_password": {"write_only": True}} extra_kwargs = {"bind_password": {"write_only": True}}
@ -140,7 +138,6 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
"user_object_filter", "user_object_filter",
"group_object_filter", "group_object_filter",
"group_membership_field", "group_membership_field",
"user_membership_attribute",
"object_uniqueness_field", "object_uniqueness_field",
"password_login_update_internal_password", "password_login_update_internal_password",
"sync_users", "sync_users",
@ -150,7 +147,6 @@ class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
"user_property_mappings", "user_property_mappings",
"group_property_mappings", "group_property_mappings",
"lookup_groups_from_user", "lookup_groups_from_user",
"delete_not_found_objects",
] ]
search_fields = ["name", "slug"] search_fields = ["name", "slug"]
ordering = ["name"] ordering = ["name"]

View File

@ -1,48 +0,0 @@
# Generated by Django 5.1.9 on 2025-05-28 08:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0048_delete_oldauthenticatedsession_content_type"),
("authentik_sources_ldap", "0008_groupldapsourceconnection_userldapsourceconnection"),
]
operations = [
migrations.AddField(
model_name="groupldapsourceconnection",
name="validated_by",
field=models.UUIDField(
blank=True,
help_text="Unique ID used while checking if this object still exists in the directory.",
null=True,
),
),
migrations.AddField(
model_name="ldapsource",
name="delete_not_found_objects",
field=models.BooleanField(
default=False,
help_text="Delete authentik users and groups which were previously supplied by this source, but are now missing from it.",
),
),
migrations.AddField(
model_name="userldapsourceconnection",
name="validated_by",
field=models.UUIDField(
blank=True,
help_text="Unique ID used while checking if this object still exists in the directory.",
null=True,
),
),
migrations.AddIndex(
model_name="groupldapsourceconnection",
index=models.Index(fields=["validated_by"], name="authentik_s_validat_b70447_idx"),
),
migrations.AddIndex(
model_name="userldapsourceconnection",
index=models.Index(fields=["validated_by"], name="authentik_s_validat_ff2ebc_idx"),
),
]

View File

@ -1,32 +0,0 @@
# Generated by Django 5.1.9 on 2025-05-29 11:22
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def set_user_membership_attribute(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
LDAPSource = apps.get_model("authentik_sources_ldap", "LDAPSource")
db_alias = schema_editor.connection.alias
LDAPSource.objects.using(db_alias).filter(group_membership_field="memberUid").all().update(
user_membership_attribute="ldap_uniq"
)
class Migration(migrations.Migration):
dependencies = [
("authentik_sources_ldap", "0009_groupldapsourceconnection_validated_by_and_more"),
]
operations = [
migrations.AddField(
model_name="ldapsource",
name="user_membership_attribute",
field=models.TextField(
default="distinguishedName",
help_text="Attribute which matches the value of `group_membership_field`.",
),
),
migrations.RunPython(set_user_membership_attribute, migrations.RunPython.noop),
]

View File

@ -100,10 +100,6 @@ class LDAPSource(Source):
default="(objectClass=person)", default="(objectClass=person)",
help_text=_("Consider Objects matching this filter to be Users."), help_text=_("Consider Objects matching this filter to be Users."),
) )
user_membership_attribute = models.TextField(
default=LDAP_DISTINGUISHED_NAME,
help_text=_("Attribute which matches the value of `group_membership_field`."),
)
group_membership_field = models.TextField( group_membership_field = models.TextField(
default="member", help_text=_("Field which contains members of a group.") default="member", help_text=_("Field which contains members of a group.")
) )
@ -141,14 +137,6 @@ class LDAPSource(Source):
), ),
) )
delete_not_found_objects = models.BooleanField(
default=False,
help_text=_(
"Delete authentik users and groups which were previously supplied by this source, "
"but are now missing from it."
),
)
@property @property
def component(self) -> str: def component(self) -> str:
return "ak-source-ldap-form" return "ak-source-ldap-form"
@ -333,12 +321,6 @@ class LDAPSourcePropertyMapping(PropertyMapping):
class UserLDAPSourceConnection(UserSourceConnection): class UserLDAPSourceConnection(UserSourceConnection):
validated_by = models.UUIDField(
null=True,
blank=True,
help_text=_("Unique ID used while checking if this object still exists in the directory."),
)
@property @property
def serializer(self) -> type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.sources.ldap.api import ( from authentik.sources.ldap.api import (
@ -350,18 +332,9 @@ class UserLDAPSourceConnection(UserSourceConnection):
class Meta: class Meta:
verbose_name = _("User LDAP Source Connection") verbose_name = _("User LDAP Source Connection")
verbose_name_plural = _("User LDAP Source Connections") verbose_name_plural = _("User LDAP Source Connections")
indexes = [
models.Index(fields=["validated_by"]),
]
class GroupLDAPSourceConnection(GroupSourceConnection): class GroupLDAPSourceConnection(GroupSourceConnection):
validated_by = models.UUIDField(
null=True,
blank=True,
help_text=_("Unique ID used while checking if this object still exists in the directory."),
)
@property @property
def serializer(self) -> type[Serializer]: def serializer(self) -> type[Serializer]:
from authentik.sources.ldap.api import ( from authentik.sources.ldap.api import (
@ -373,6 +346,3 @@ class GroupLDAPSourceConnection(GroupSourceConnection):
class Meta: class Meta:
verbose_name = _("Group LDAP Source Connection") verbose_name = _("Group LDAP Source Connection")
verbose_name_plural = _("Group LDAP Source Connections") verbose_name_plural = _("Group LDAP Source Connections")
indexes = [
models.Index(fields=["validated_by"]),
]

View File

@ -9,7 +9,7 @@ from structlog.stdlib import BoundLogger, get_logger
from authentik.core.sources.mapper import SourceMapper from authentik.core.sources.mapper import SourceMapper
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.sync.mapper import PropertyMappingManager from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.sources.ldap.models import LDAPSource, flatten from authentik.sources.ldap.models import LDAPSource
class BaseLDAPSynchronizer: class BaseLDAPSynchronizer:
@ -77,16 +77,6 @@ class BaseLDAPSynchronizer:
"""Get objects from LDAP, implemented in subclass""" """Get objects from LDAP, implemented in subclass"""
raise NotImplementedError() raise NotImplementedError()
def get_attributes(self, object):
if "attributes" not in object:
return
return object.get("attributes", {})
def get_identifier(self, attributes: dict):
if not attributes.get(self._source.object_uniqueness_field):
return
return flatten(attributes[self._source.object_uniqueness_field])
def search_paginator( # noqa: PLR0913 def search_paginator( # noqa: PLR0913
self, self,
search_base, search_base,

View File

@ -1,61 +0,0 @@
from collections.abc import Generator
from itertools import batched
from uuid import uuid4
from ldap3 import SUBTREE
from authentik.core.models import Group
from authentik.sources.ldap.models import GroupLDAPSourceConnection
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.forward_delete_users import DELETE_CHUNK_SIZE, UPDATE_CHUNK_SIZE
class GroupLDAPForwardDeletion(BaseLDAPSynchronizer):
"""Delete LDAP Groups from authentik"""
@staticmethod
def name() -> str:
return "group_deletions"
def get_objects(self, **kwargs) -> Generator:
if not self._source.sync_groups or not self._source.delete_not_found_objects:
self.message("Group syncing is disabled for this Source")
return iter(())
uuid = uuid4()
groups = self._source.connection().extend.standard.paged_search(
search_base=self.base_dn_groups,
search_filter=self._source.group_object_filter,
search_scope=SUBTREE,
attributes=[self._source.object_uniqueness_field],
generator=True,
**kwargs,
)
for batch in batched(groups, UPDATE_CHUNK_SIZE, strict=False):
identifiers = []
for group in batch:
if not (attributes := self.get_attributes(group)):
continue
if identifier := self.get_identifier(attributes):
identifiers.append(identifier)
GroupLDAPSourceConnection.objects.filter(identifier__in=identifiers).update(
validated_by=uuid
)
return batched(
GroupLDAPSourceConnection.objects.filter(source=self._source)
.exclude(validated_by=uuid)
.values_list("group", flat=True)
.iterator(chunk_size=DELETE_CHUNK_SIZE),
DELETE_CHUNK_SIZE,
strict=False,
)
def sync(self, group_pks: tuple) -> int:
"""Delete authentik groups"""
if not self._source.sync_groups or not self._source.delete_not_found_objects:
self.message("Group syncing is disabled for this Source")
return -1
self._logger.debug("Deleting groups", group_pks=group_pks)
_, deleted_per_type = Group.objects.filter(pk__in=group_pks).delete()
return deleted_per_type.get(Group._meta.label, 0)

View File

@ -1,63 +0,0 @@
from collections.abc import Generator
from itertools import batched
from uuid import uuid4
from ldap3 import SUBTREE
from authentik.core.models import User
from authentik.sources.ldap.models import UserLDAPSourceConnection
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
UPDATE_CHUNK_SIZE = 10_000
DELETE_CHUNK_SIZE = 50
class UserLDAPForwardDeletion(BaseLDAPSynchronizer):
"""Delete LDAP Users from authentik"""
@staticmethod
def name() -> str:
return "user_deletions"
def get_objects(self, **kwargs) -> Generator:
if not self._source.sync_users or not self._source.delete_not_found_objects:
self.message("User syncing is disabled for this Source")
return iter(())
uuid = uuid4()
users = self._source.connection().extend.standard.paged_search(
search_base=self.base_dn_users,
search_filter=self._source.user_object_filter,
search_scope=SUBTREE,
attributes=[self._source.object_uniqueness_field],
generator=True,
**kwargs,
)
for batch in batched(users, UPDATE_CHUNK_SIZE, strict=False):
identifiers = []
for user in batch:
if not (attributes := self.get_attributes(user)):
continue
if identifier := self.get_identifier(attributes):
identifiers.append(identifier)
UserLDAPSourceConnection.objects.filter(identifier__in=identifiers).update(
validated_by=uuid
)
return batched(
UserLDAPSourceConnection.objects.filter(source=self._source)
.exclude(validated_by=uuid)
.values_list("user", flat=True)
.iterator(chunk_size=DELETE_CHUNK_SIZE),
DELETE_CHUNK_SIZE,
strict=False,
)
def sync(self, user_pks: tuple) -> int:
"""Delete authentik users"""
if not self._source.sync_users or not self._source.delete_not_found_objects:
self.message("User syncing is disabled for this Source")
return -1
self._logger.debug("Deleting users", user_pks=user_pks)
_, deleted_per_type = User.objects.filter(pk__in=user_pks).delete()
return deleted_per_type.get(User._meta.label, 0)

View File

@ -58,16 +58,18 @@ class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
return -1 return -1
group_count = 0 group_count = 0
for group in page_data: for group in page_data:
if (attributes := self.get_attributes(group)) is None: if "attributes" not in group:
continue continue
attributes = group.get("attributes", {})
group_dn = flatten(flatten(group.get("entryDN", group.get("dn")))) group_dn = flatten(flatten(group.get("entryDN", group.get("dn"))))
if not (uniq := self.get_identifier(attributes)): if not attributes.get(self._source.object_uniqueness_field):
self.message( self.message(
f"Uniqueness field not found/not set in attributes: '{group_dn}'", f"Uniqueness field not found/not set in attributes: '{group_dn}'",
attributes=attributes.keys(), attributes=attributes.keys(),
dn=group_dn, dn=group_dn,
) )
continue continue
uniq = flatten(attributes[self._source.object_uniqueness_field])
try: try:
defaults = { defaults = {
k: flatten(v) k: flatten(v)

View File

@ -63,19 +63,25 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
group_member_dn = group_member.get("dn", {}) group_member_dn = group_member.get("dn", {})
members.append(group_member_dn) members.append(group_member_dn)
else: else:
if (attributes := self.get_attributes(group)) is None: if "attributes" not in group:
continue continue
members = attributes.get(self._source.group_membership_field, []) members = group.get("attributes", {}).get(self._source.group_membership_field, [])
ak_group = self.get_group(group) ak_group = self.get_group(group)
if not ak_group: if not ak_group:
continue continue
membership_mapping_attribute = LDAP_DISTINGUISHED_NAME
if self._source.group_membership_field == "memberUid":
# If memberships are based on the posixGroup's 'memberUid'
# attribute we use the RDN instead of the FDN to lookup members.
membership_mapping_attribute = LDAP_UNIQUENESS
users = User.objects.filter( users = User.objects.filter(
Q(**{f"attributes__{self._source.user_membership_attribute}__in": members}) Q(**{f"attributes__{membership_mapping_attribute}__in": members})
| Q( | Q(
**{ **{
f"attributes__{self._source.user_membership_attribute}__isnull": True, f"attributes__{membership_mapping_attribute}__isnull": True,
"ak_groups__in": [ak_group], "ak_groups__in": [ak_group],
} }
) )

View File

@ -60,16 +60,18 @@ class UserLDAPSynchronizer(BaseLDAPSynchronizer):
return -1 return -1
user_count = 0 user_count = 0
for user in page_data: for user in page_data:
if (attributes := self.get_attributes(user)) is None: if "attributes" not in user:
continue continue
attributes = user.get("attributes", {})
user_dn = flatten(user.get("entryDN", user.get("dn"))) user_dn = flatten(user.get("entryDN", user.get("dn")))
if not (uniq := self.get_identifier(attributes)): if not attributes.get(self._source.object_uniqueness_field):
self.message( self.message(
f"Uniqueness field not found/not set in attributes: '{user_dn}'", f"Uniqueness field not found/not set in attributes: '{user_dn}'",
attributes=attributes.keys(), attributes=attributes.keys(),
dn=user_dn, dn=user_dn,
) )
continue continue
uniq = flatten(attributes[self._source.object_uniqueness_field])
try: try:
defaults = { defaults = {
k: flatten(v) k: flatten(v)

View File

@ -17,8 +17,6 @@ from authentik.lib.utils.reflection import class_to_path, path_to_class
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
from authentik.sources.ldap.models import LDAPSource from authentik.sources.ldap.models import LDAPSource
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.forward_delete_groups import GroupLDAPForwardDeletion
from authentik.sources.ldap.sync.forward_delete_users import UserLDAPForwardDeletion
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
@ -54,11 +52,11 @@ def ldap_connectivity_check(pk: str | None = None):
@CELERY_APP.task( @CELERY_APP.task(
# We take the configured hours timeout time by 3.5 as we run user and # We take the configured hours timeout time by 2.5 as we run user and
# group in parallel and then membership, then deletions, so 3x is to cover the serial tasks, # group in parallel and then membership, so 2x is to cover the serial tasks,
# and 0.5x on top of that to give some more leeway # and 0.5x on top of that to give some more leeway
soft_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 3.5, soft_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 2.5,
task_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 3.5, task_time_limit=(60 * 60 * CONFIG.get_int("ldap.task_timeout_hours")) * 2.5,
) )
def ldap_sync_single(source_pk: str): def ldap_sync_single(source_pk: str):
"""Sync a single source""" """Sync a single source"""
@ -81,25 +79,6 @@ def ldap_sync_single(source_pk: str):
group( group(
ldap_sync_paginator(source, MembershipLDAPSynchronizer), ldap_sync_paginator(source, MembershipLDAPSynchronizer),
), ),
# Finally, deletions. What we'd really like to do here is something like
# ```
# user_identifiers = <ldap query>
# User.objects.exclude(
# usersourceconnection__identifier__in=user_uniqueness_identifiers,
# ).delete()
# ```
# This runs into performance issues in large installations. So instead we spread the
# work out into three steps:
# 1. Get every object from the LDAP source.
# 2. Mark every object as "safe" in the database. This is quick, but any error could
# mean deleting users which should not be deleted, so we do it immediately, in
# large chunks, and only queue the deletion step afterwards.
# 3. Delete every unmarked item. This is slow, so we spread it over many tasks in
# small chunks.
group(
ldap_sync_paginator(source, UserLDAPForwardDeletion)
+ ldap_sync_paginator(source, GroupLDAPForwardDeletion),
),
) )
task() task()

View File

@ -2,33 +2,6 @@
from ldap3 import MOCK_SYNC, OFFLINE_SLAPD_2_4, Connection, Server from ldap3 import MOCK_SYNC, OFFLINE_SLAPD_2_4, Connection, Server
# The mock modifies these in place, so we have to define them per string
user_in_slapd_dn = "cn=user_in_slapd_cn,ou=users,dc=goauthentik,dc=io"
user_in_slapd_cn = "user_in_slapd_cn"
user_in_slapd_uid = "user_in_slapd_uid"
user_in_slapd_object_class = "person"
user_in_slapd = {
"dn": user_in_slapd_dn,
"attributes": {
"cn": user_in_slapd_cn,
"uid": user_in_slapd_uid,
"objectClass": user_in_slapd_object_class,
},
}
group_in_slapd_dn = "cn=user_in_slapd_cn,ou=groups,dc=goauthentik,dc=io"
group_in_slapd_cn = "group_in_slapd_cn"
group_in_slapd_uid = "group_in_slapd_uid"
group_in_slapd_object_class = "groupOfNames"
group_in_slapd = {
"dn": group_in_slapd_dn,
"attributes": {
"cn": group_in_slapd_cn,
"uid": group_in_slapd_uid,
"objectClass": group_in_slapd_object_class,
"member": [user_in_slapd["dn"]],
},
}
def mock_slapd_connection(password: str) -> Connection: def mock_slapd_connection(password: str) -> Connection:
"""Create mock SLAPD connection""" """Create mock SLAPD connection"""
@ -123,14 +96,5 @@ def mock_slapd_connection(password: str) -> Connection:
"objectClass": "posixAccount", "objectClass": "posixAccount",
}, },
) )
# Known user and group
connection.strategy.add_entry(
user_in_slapd["dn"],
user_in_slapd["attributes"],
)
connection.strategy.add_entry(
group_in_slapd["dn"],
group_in_slapd["attributes"],
)
connection.bind() connection.bind()
return connection return connection

View File

@ -13,26 +13,14 @@ from authentik.events.system_tasks import TaskStatus
from authentik.lib.generators import generate_id, generate_key from authentik.lib.generators import generate_id, generate_key
from authentik.lib.sync.outgoing.exceptions import StopSync from authentik.lib.sync.outgoing.exceptions import StopSync
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.sources.ldap.models import ( from authentik.sources.ldap.models import LDAPSource, LDAPSourcePropertyMapping
GroupLDAPSourceConnection,
LDAPSource,
LDAPSourcePropertyMapping,
UserLDAPSourceConnection,
)
from authentik.sources.ldap.sync.forward_delete_users import DELETE_CHUNK_SIZE
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer from authentik.sources.ldap.sync.users import UserLDAPSynchronizer
from authentik.sources.ldap.tasks import ldap_sync, ldap_sync_all from authentik.sources.ldap.tasks import ldap_sync, ldap_sync_all
from authentik.sources.ldap.tests.mock_ad import mock_ad_connection from authentik.sources.ldap.tests.mock_ad import mock_ad_connection
from authentik.sources.ldap.tests.mock_freeipa import mock_freeipa_connection from authentik.sources.ldap.tests.mock_freeipa import mock_freeipa_connection
from authentik.sources.ldap.tests.mock_slapd import ( from authentik.sources.ldap.tests.mock_slapd import mock_slapd_connection
group_in_slapd_cn,
group_in_slapd_uid,
mock_slapd_connection,
user_in_slapd_cn,
user_in_slapd_uid,
)
LDAP_PASSWORD = generate_key() LDAP_PASSWORD = generate_key()
@ -269,56 +257,12 @@ class LDAPSyncTests(TestCase):
self.source.group_membership_field = "memberUid" self.source.group_membership_field = "memberUid"
self.source.user_object_filter = "(objectClass=posixAccount)" self.source.user_object_filter = "(objectClass=posixAccount)"
self.source.group_object_filter = "(objectClass=posixGroup)" self.source.group_object_filter = "(objectClass=posixGroup)"
self.source.user_membership_attribute = "uid"
self.source.user_property_mappings.set( self.source.user_property_mappings.set(
[
*LDAPSourcePropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
).all(),
LDAPSourcePropertyMapping.objects.create(
name="name",
expression='return {"attributes": {"uid": list_flatten(ldap.get("uid"))}}',
),
]
)
self.source.group_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter( LDAPSourcePropertyMapping.objects.filter(
managed="goauthentik.io/sources/ldap/openldap-cn" Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
) )
) )
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
self.source.save()
user_sync = UserLDAPSynchronizer(self.source)
user_sync.sync_full()
group_sync = GroupLDAPSynchronizer(self.source)
group_sync.sync_full()
membership_sync = MembershipLDAPSynchronizer(self.source)
membership_sync.sync_full()
# Test if membership mapping based on memberUid works.
posix_group = Group.objects.filter(name="group-posix").first()
self.assertTrue(posix_group.users.filter(name="user-posix").exists())
def test_sync_groups_openldap_posix_group_nonstandard_membership_attribute(self):
"""Test posix group sync"""
self.source.object_uniqueness_field = "cn"
self.source.group_membership_field = "memberUid"
self.source.user_object_filter = "(objectClass=posixAccount)"
self.source.group_object_filter = "(objectClass=posixGroup)"
self.source.user_membership_attribute = "cn"
self.source.user_property_mappings.set(
[
*LDAPSourcePropertyMapping.objects.filter(
Q(managed__startswith="goauthentik.io/sources/ldap/default")
| Q(managed__startswith="goauthentik.io/sources/ldap/openldap")
).all(),
LDAPSourcePropertyMapping.objects.create(
name="name",
expression='return {"attributes": {"cn": list_flatten(ldap.get("cn"))}}',
),
]
)
self.source.group_property_mappings.set( self.source.group_property_mappings.set(
LDAPSourcePropertyMapping.objects.filter( LDAPSourcePropertyMapping.objects.filter(
managed="goauthentik.io/sources/ldap/openldap-cn" managed="goauthentik.io/sources/ldap/openldap-cn"
@ -364,160 +308,3 @@ class LDAPSyncTests(TestCase):
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD)) connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection): with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get() ldap_sync_all.delay().get()
def test_user_deletion(self):
"""Test user deletion"""
user = User.objects.create_user(username="not-in-the-source")
UserLDAPSourceConnection.objects.create(
user=user, source=self.source, identifier="not-in-the-source"
)
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.delete_not_found_objects = True
self.source.save()
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
self.assertFalse(User.objects.filter(username="not-in-the-source").exists())
def test_user_deletion_still_in_source(self):
"""Test that user is not deleted if it's still in the source"""
username = user_in_slapd_cn
identifier = user_in_slapd_uid
user = User.objects.create_user(username=username)
UserLDAPSourceConnection.objects.create(
user=user, source=self.source, identifier=identifier
)
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.delete_not_found_objects = True
self.source.save()
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
self.assertTrue(User.objects.filter(username=username).exists())
def test_user_deletion_no_sync(self):
"""Test that user is not deleted if sync_users is False"""
user = User.objects.create_user(username="not-in-the-source")
UserLDAPSourceConnection.objects.create(
user=user, source=self.source, identifier="not-in-the-source"
)
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.delete_not_found_objects = True
self.source.sync_users = False
self.source.save()
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
self.assertTrue(User.objects.filter(username="not-in-the-source").exists())
def test_user_deletion_no_delete(self):
"""Test that user is not deleted if delete_not_found_objects is False"""
user = User.objects.create_user(username="not-in-the-source")
UserLDAPSourceConnection.objects.create(
user=user, source=self.source, identifier="not-in-the-source"
)
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.save()
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
self.assertTrue(User.objects.filter(username="not-in-the-source").exists())
def test_group_deletion(self):
"""Test group deletion"""
group = Group.objects.create(name="not-in-the-source")
GroupLDAPSourceConnection.objects.create(
group=group, source=self.source, identifier="not-in-the-source"
)
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.delete_not_found_objects = True
self.source.save()
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
self.assertFalse(Group.objects.filter(name="not-in-the-source").exists())
def test_group_deletion_still_in_source(self):
"""Test that group is not deleted if it's still in the source"""
groupname = group_in_slapd_cn
identifier = group_in_slapd_uid
group = Group.objects.create(name=groupname)
GroupLDAPSourceConnection.objects.create(
group=group, source=self.source, identifier=identifier
)
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.delete_not_found_objects = True
self.source.save()
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
self.assertTrue(Group.objects.filter(name=groupname).exists())
def test_group_deletion_no_sync(self):
"""Test that group is not deleted if sync_groups is False"""
group = Group.objects.create(name="not-in-the-source")
GroupLDAPSourceConnection.objects.create(
group=group, source=self.source, identifier="not-in-the-source"
)
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.delete_not_found_objects = True
self.source.sync_groups = False
self.source.save()
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
self.assertTrue(Group.objects.filter(name="not-in-the-source").exists())
def test_group_deletion_no_delete(self):
"""Test that group is not deleted if delete_not_found_objects is False"""
group = Group.objects.create(name="not-in-the-source")
GroupLDAPSourceConnection.objects.create(
group=group, source=self.source, identifier="not-in-the-source"
)
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.save()
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
self.assertTrue(Group.objects.filter(name="not-in-the-source").exists())
def test_batch_deletion(self):
"""Test batch deletion"""
BATCH_SIZE = DELETE_CHUNK_SIZE + 1
for i in range(BATCH_SIZE):
user = User.objects.create_user(username=f"not-in-the-source-{i}")
group = Group.objects.create(name=f"not-in-the-source-{i}")
group.users.add(user)
UserLDAPSourceConnection.objects.create(
user=user, source=self.source, identifier=f"not-in-the-source-{i}-user"
)
GroupLDAPSourceConnection.objects.create(
group=group, source=self.source, identifier=f"not-in-the-source-{i}-group"
)
self.source.object_uniqueness_field = "uid"
self.source.group_object_filter = "(objectClass=groupOfNames)"
self.source.delete_not_found_objects = True
self.source.save()
connection = MagicMock(return_value=mock_slapd_connection(LDAP_PASSWORD))
with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
ldap_sync_all.delay().get()
self.assertFalse(User.objects.filter(username__startswith="not-in-the-source").exists())
self.assertFalse(Group.objects.filter(name__startswith="not-in-the-source").exists())

View File

@ -1,12 +1,11 @@
"""authentik plex config""" """authentik plex config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikSourcePlexConfig(ManagedAppConfig): class AuthentikSourcePlexConfig(AppConfig):
"""authentik source plex config""" """authentik source plex config"""
name = "authentik.sources.plex" name = "authentik.sources.plex"
label = "authentik_sources_plex" label = "authentik_sources_plex"
verbose_name = "authentik Sources.Plex" verbose_name = "authentik Sources.Plex"
default = True

View File

@ -9,7 +9,6 @@ from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.http import urlencode from django.utils.http import urlencode
from django.utils.translation import gettext as _
from django.views import View from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -129,9 +128,7 @@ class InitiateView(View):
# otherwise we default to POST_AUTO, with direct redirect # otherwise we default to POST_AUTO, with direct redirect
if source.binding_type == SAMLBindingTypes.POST: if source.binding_type == SAMLBindingTypes.POST:
injected_stages.append(in_memory_stage(ConsentStageView)) injected_stages.append(in_memory_stage(ConsentStageView))
plan_kwargs[PLAN_CONTEXT_CONSENT_HEADER] = _( plan_kwargs[PLAN_CONTEXT_CONSENT_HEADER] = f"Continue to {source.name}"
"Continue to {source_name}".format(source_name=source.name)
)
injected_stages.append(in_memory_stage(AutosubmitStageView)) injected_stages.append(in_memory_stage(AutosubmitStageView))
return self.handle_login_flow( return self.handle_login_flow(
source, source,

View File

@ -97,8 +97,7 @@ class GroupsView(SCIMObjectView):
self.logger.warning("Invalid group member", exc=exc) self.logger.warning("Invalid group member", exc=exc)
continue continue
query |= Q(uuid=member.value) query |= Q(uuid=member.value)
if query: group.users.set(User.objects.filter(query))
group.users.set(User.objects.filter(query))
if not connection: if not connection:
connection, _ = SCIMSourceGroup.objects.get_or_create( connection, _ = SCIMSourceGroup.objects.get_or_create(
source=self.source, source=self.source,

View File

@ -1,12 +1,11 @@
"""Authenticator""" """Authenticator"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageAuthenticatorConfig(ManagedAppConfig): class AuthentikStageAuthenticatorConfig(AppConfig):
"""Authenticator App config""" """Authenticator App config"""
name = "authentik.stages.authenticator" name = "authentik.stages.authenticator"
label = "authentik_stages_authenticator" label = "authentik_stages_authenticator"
verbose_name = "authentik Stages.Authenticator" verbose_name = "authentik Stages.Authenticator"
default = True

View File

@ -1,12 +1,11 @@
"""SMS""" """SMS"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageAuthenticatorSMSConfig(ManagedAppConfig): class AuthentikStageAuthenticatorSMSConfig(AppConfig):
"""SMS App config""" """SMS App config"""
name = "authentik.stages.authenticator_sms" name = "authentik.stages.authenticator_sms"
label = "authentik_stages_authenticator_sms" label = "authentik_stages_authenticator_sms"
verbose_name = "authentik Stages.Authenticator.SMS" verbose_name = "authentik Stages.Authenticator.SMS"
default = True

View File

@ -1,12 +1,11 @@
"""TOTP""" """TOTP"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageAuthenticatorTOTPConfig(ManagedAppConfig): class AuthentikStageAuthenticatorTOTPConfig(AppConfig):
"""TOTP App config""" """TOTP App config"""
name = "authentik.stages.authenticator_totp" name = "authentik.stages.authenticator_totp"
label = "authentik_stages_authenticator_totp" label = "authentik_stages_authenticator_totp"
verbose_name = "authentik Stages.Authenticator.TOTP" verbose_name = "authentik Stages.Authenticator.TOTP"
default = True

View File

@ -1,12 +1,11 @@
"""Authenticator Validation Stage""" """Authenticator Validation Stage"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageAuthenticatorValidateConfig(ManagedAppConfig): class AuthentikStageAuthenticatorValidateConfig(AppConfig):
"""Authenticator Validation Stage""" """Authenticator Validation Stage"""
name = "authentik.stages.authenticator_validate" name = "authentik.stages.authenticator_validate"
label = "authentik_stages_authenticator_validate" label = "authentik_stages_authenticator_validate"
verbose_name = "authentik Stages.Authenticator.Validate" verbose_name = "authentik Stages.Authenticator.Validate"
default = True

View File

@ -151,7 +151,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
webauthn_user_verification=UserVerification.PREFERRED, webauthn_user_verification=UserVerification.PREFERRED,
) )
stage.webauthn_allowed_device_types.set( stage.webauthn_allowed_device_types.set(
WebAuthnDeviceType.objects.filter(description="YubiKey 5 Series") WebAuthnDeviceType.objects.filter(
description="Android Authenticator with SafetyNet Attestation"
)
) )
session = self.client.session session = self.client.session
plan = FlowPlan(flow_pk=flow.pk.hex) plan = FlowPlan(flow_pk=flow.pk.hex)
@ -337,7 +339,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
device_classes=[DeviceClasses.WEBAUTHN], device_classes=[DeviceClasses.WEBAUTHN],
) )
stage.webauthn_allowed_device_types.set( stage.webauthn_allowed_device_types.set(
WebAuthnDeviceType.objects.filter(description="YubiKey 5 Series") WebAuthnDeviceType.objects.filter(
description="Android Authenticator with SafetyNet Attestation"
)
) )
session = self.client.session session = self.client.session
plan = FlowPlan(flow_pk=flow.pk.hex) plan = FlowPlan(flow_pk=flow.pk.hex)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -141,7 +141,9 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
"""Test registration with restricted devices (fail)""" """Test registration with restricted devices (fail)"""
webauthn_mds_import.delay(force=True).get() webauthn_mds_import.delay(force=True).get()
self.stage.device_type_restrictions.set( self.stage.device_type_restrictions.set(
WebAuthnDeviceType.objects.filter(description="YubiKey 5 Series") WebAuthnDeviceType.objects.filter(
description="Android Authenticator with SafetyNet Attestation"
)
) )
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]) plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])

View File

@ -1,12 +1,11 @@
"""authentik captcha app""" """authentik captcha app"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageCaptchaConfig(ManagedAppConfig): class AuthentikStageCaptchaConfig(AppConfig):
"""authentik captcha app""" """authentik captcha app"""
name = "authentik.stages.captcha" name = "authentik.stages.captcha"
label = "authentik_stages_captcha" label = "authentik_stages_captcha"
verbose_name = "authentik Stages.Captcha" verbose_name = "authentik Stages.Captcha"
default = True

View File

@ -1,12 +1,11 @@
"""authentik consent app""" """authentik consent app"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageConsentConfig(ManagedAppConfig): class AuthentikStageConsentConfig(AppConfig):
"""authentik consent app""" """authentik consent app"""
name = "authentik.stages.consent" name = "authentik.stages.consent"
label = "authentik_stages_consent" label = "authentik_stages_consent"
verbose_name = "authentik Stages.Consent" verbose_name = "authentik Stages.Consent"
default = True

View File

@ -4,8 +4,6 @@ from uuid import uuid4
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField from rest_framework.fields import CharField
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
@ -49,11 +47,6 @@ class ConsentChallengeResponse(ChallengeResponse):
component = CharField(default="ak-stage-consent") component = CharField(default="ak-stage-consent")
token = CharField(required=True) token = CharField(required=True)
def validate_token(self, token: str):
if token != self.stage.executor.request.session[SESSION_KEY_CONSENT_TOKEN]:
raise ValidationError(_("Invalid consent token, re-showing prompt"))
return token
class ConsentStageView(ChallengeStageView): class ConsentStageView(ChallengeStageView):
"""Simple consent checker.""" """Simple consent checker."""
@ -127,6 +120,9 @@ class ConsentStageView(ChallengeStageView):
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
if response.data["token"] != self.request.session[SESSION_KEY_CONSENT_TOKEN]:
self.logger.info("Invalid consent token, re-showing prompt")
return self.get(self.request)
if self.should_always_prompt(): if self.should_always_prompt():
return self.executor.stage_ok() return self.executor.stage_ok()
current_stage: ConsentStage = self.executor.current_stage current_stage: ConsentStage = self.executor.current_stage

View File

@ -17,7 +17,6 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent
from authentik.stages.consent.stage import ( from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS, PLAN_CONTEXT_CONSENT_PERMISSIONS,
SESSION_KEY_CONSENT_TOKEN, SESSION_KEY_CONSENT_TOKEN,
) )
@ -34,40 +33,6 @@ class TestConsentStage(FlowTestCase):
slug=generate_id(), slug=generate_id(),
) )
def test_mismatched_token(self):
"""Test incorrect token"""
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
stage = ConsentStage.objects.create(name=generate_id(), mode=ConsentMode.ALWAYS_REQUIRE)
binding = FlowStageBinding.objects.create(target=flow, stage=stage, order=2)
plan = FlowPlan(flow_pk=flow.pk.hex, bindings=[binding], markers=[StageMarker()])
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
)
self.assertEqual(response.status_code, 200)
session = self.client.session
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
{
"token": generate_id(),
},
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
flow,
component="ak-stage-consent",
response_errors={
"token": [{"string": "Invalid consent token, re-showing prompt", "code": "invalid"}]
},
)
self.assertFalse(UserConsent.objects.filter(user=self.user).exists())
def test_always_required(self): def test_always_required(self):
"""Test always required consent""" """Test always required consent"""
flow = create_test_flow(FlowDesignation.AUTHENTICATION) flow = create_test_flow(FlowDesignation.AUTHENTICATION)
@ -193,7 +158,6 @@ class TestConsentStage(FlowTestCase):
context={ context={
PLAN_CONTEXT_APPLICATION: self.application, PLAN_CONTEXT_APPLICATION: self.application,
PLAN_CONTEXT_CONSENT_PERMISSIONS: [PermissionDict(id="foo", name="foo-desc")], PLAN_CONTEXT_CONSENT_PERMISSIONS: [PermissionDict(id="foo", name="foo-desc")],
PLAN_CONTEXT_CONSENT_HEADER: "test header",
}, },
) )
session = self.client.session session = self.client.session

View File

@ -1,12 +1,11 @@
"""authentik deny stage app config""" """authentik deny stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageDenyConfig(ManagedAppConfig): class AuthentikStageDenyConfig(AppConfig):
"""authentik deny stage config""" """authentik deny stage config"""
name = "authentik.stages.deny" name = "authentik.stages.deny"
label = "authentik_stages_deny" label = "authentik_stages_deny"
verbose_name = "authentik Stages.Deny" verbose_name = "authentik Stages.Deny"
default = True

View File

@ -1,12 +1,11 @@
"""authentik dummy stage config""" """authentik dummy stage config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageDummyConfig(ManagedAppConfig): class AuthentikStageDummyConfig(AppConfig):
"""authentik dummy stage config""" """authentik dummy stage config"""
name = "authentik.stages.dummy" name = "authentik.stages.dummy"
label = "authentik_stages_dummy" label = "authentik_stages_dummy"
verbose_name = "authentik Stages.Dummy" verbose_name = "authentik Stages.Dummy"
default = True

View File

@ -1,38 +0,0 @@
from base64 import b64encode
from copy import deepcopy
from pickle import dumps # nosec
from django.utils.translation import gettext as _
from authentik.flows.models import FlowToken, in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, FlowPlan
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_HEADER, ConsentStageView
def pickle_flow_token_for_email(plan: FlowPlan):
"""Insert a consent stage into the flow plan and pickle it for a FlowToken,
to be sent via Email. This is to prevent automated email scanners, which sometimes
open links in emails in a full browser from breaking the link."""
plan_copy = deepcopy(plan)
plan_copy.insert_stage(in_memory_stage(EmailTokenRevocationConsentStageView), index=0)
plan_copy.context[PLAN_CONTEXT_CONSENT_HEADER] = _("Continue to confirm this email address.")
data = dumps(plan_copy)
return b64encode(data).decode()
class EmailTokenRevocationConsentStageView(ConsentStageView):
def get(self, request, *args, **kwargs):
token: FlowToken = self.executor.plan.context[PLAN_CONTEXT_IS_RESTORED]
try:
token.refresh_from_db()
except FlowToken.DoesNotExist:
return self.executor.stage_invalid(
_("Link was already used, please request a new link.")
)
return super().get(request, *args, **kwargs)
def challenge_valid(self, response):
token: FlowToken = self.executor.plan.context[PLAN_CONTEXT_IS_RESTORED]
token.delete()
return super().challenge_valid(response)

View File

@ -23,7 +23,6 @@ from authentik.flows.stage import ChallengeStageView
from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.stages.email.flow import pickle_flow_token_for_email
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
@ -87,8 +86,7 @@ class EmailStageView(ChallengeStageView):
user=pending_user, user=pending_user,
identifier=identifier, identifier=identifier,
flow=self.executor.flow, flow=self.executor.flow,
_plan=pickle_flow_token_for_email(self.executor.plan), _plan=FlowToken.pickle(self.executor.plan),
revoke_on_execution=False,
) )
token = tokens.first() token = tokens.first()
# Check if token is expired and rotate key if so # Check if token is expired and rotate key if so

View File

@ -174,5 +174,5 @@ class TestEmailStageSending(FlowTestCase):
response = self.client.post(url) response = self.client.post(url)
response = self.client.post(url) response = self.client.post(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertGreaterEqual(len(mail.outbox), 1) self.assertTrue(len(mail.outbox) >= 1)
self.assertEqual(mail.outbox[0].subject, "authentik") self.assertEqual(mail.outbox[0].subject, "authentik")

View File

@ -17,7 +17,6 @@ from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN, FlowExecutorView from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN, FlowExecutorView
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.stages.consent.stage import SESSION_KEY_CONSENT_TOKEN
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, EmailStageView from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, EmailStageView
@ -161,17 +160,6 @@ class TestEmailStage(FlowTestCase):
kwargs={"flow_slug": self.flow.slug}, kwargs={"flow_slug": self.flow.slug},
) )
) )
self.assertStageResponse(response, self.flow, component="ak-stage-consent")
response = self.client.post(
reverse(
"authentik_api:flow-executor",
kwargs={"flow_slug": self.flow.slug},
),
data={
"token": self.client.session[SESSION_KEY_CONSENT_TOKEN],
},
follow=True,
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect")) self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
@ -194,7 +182,6 @@ class TestEmailStage(FlowTestCase):
# Set flow token user to a different user # Set flow token user to a different user
token: FlowToken = FlowToken.objects.get(user=self.user) token: FlowToken = FlowToken.objects.get(user=self.user)
token.user = create_test_admin_user() token.user = create_test_admin_user()
token.revoke_on_execution = True
token.save() token.save()
with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()): with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()):

View File

@ -1,12 +1,11 @@
"""authentik identification stage app config""" """authentik identification stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageIdentificationConfig(ManagedAppConfig): class AuthentikStageIdentificationConfig(AppConfig):
"""authentik identification stage config""" """authentik identification stage config"""
name = "authentik.stages.identification" name = "authentik.stages.identification"
label = "authentik_stages_identification" label = "authentik_stages_identification"
verbose_name = "authentik Stages.Identification" verbose_name = "authentik Stages.Identification"
default = True

View File

@ -1,12 +1,11 @@
"""authentik invitation stage app config""" """authentik invitation stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageInvitationConfig(ManagedAppConfig): class AuthentikStageInvitationConfig(AppConfig):
"""authentik invitation stage config""" """authentik invitation stage config"""
name = "authentik.stages.invitation" name = "authentik.stages.invitation"
label = "authentik_stages_invitation" label = "authentik_stages_invitation"
verbose_name = "authentik Stages.Invitation" verbose_name = "authentik Stages.Invitation"
default = True

View File

@ -1,12 +1,11 @@
"""authentik core app config""" """authentik core app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStagePasswordConfig(ManagedAppConfig): class AuthentikStagePasswordConfig(AppConfig):
"""authentik password stage config""" """authentik password stage config"""
name = "authentik.stages.password" name = "authentik.stages.password"
label = "authentik_stages_password" label = "authentik_stages_password"
verbose_name = "authentik Stages.Password" verbose_name = "authentik Stages.Password"
default = True

Some files were not shown because too many files have changed in this diff Show More