Compare commits

..

1 Commits

Author SHA1 Message Date
Jens Langhammer
1db6104bef flows: return correct status code on error
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-12-18 13:23:24 +01:00
355 changed files with 3363 additions and 7349 deletions

View File

@@ -12,14 +12,13 @@ inputs:
runs:
using: "composite"
steps:
- name: Install apt deps & cleanup
- name: Install apt deps
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
shell: bash
run: |
sudo apt-get remove --purge man-db
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
sudo rm -rf /usr/local/lib/android
- name: Install uv
if: ${{ contains(inputs.dependencies, 'python') }}
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v5
@@ -51,13 +50,13 @@ runs:
if: ${{ contains(inputs.dependencies, 'runtime') }}
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
with:
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/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
if: ${{ contains(inputs.dependencies, 'runtime') }}
shell: bash
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/compose.yml up -d
docker compose -f .github/actions/setup/docker-compose.yml up -d
cd web && npm i
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}

View File

@@ -11,6 +11,11 @@ services:
ports:
- 5432:5432
restart: always
redis:
image: docker.io/library/redis:7
ports:
- 6379:6379
restart: always
s3:
container_name: s3
image: docker.io/zenko/cloudserver

View File

@@ -20,7 +20,7 @@ runs:
- name: PostgreSQL Logs
shell: bash
run: |
if [[ $RUNNER_DEBUG == '1' ]]; then
if [[ $ACTIONS_RUNNER_DEBUG == 'true' || $ACTIONS_STEP_DEBUG == 'true' ]]; then
docker stop setup-postgresql-1
echo "::group::PostgreSQL Logs"
docker logs setup-postgresql-1

View File

@@ -44,7 +44,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -85,7 +85,6 @@ jobs:
id: push
with:
context: .
file: lifecycle/container/Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
@@ -96,7 +95,7 @@ jobs:
platforms: linux/${{ inputs.image_arch }}
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
cache-to: ${{ steps.ev.outputs.cacheTo }}
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -90,14 +90,14 @@ jobs:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: int128/docker-manifest-create-action@6cdd53a8337cd50bc3ef8c7016579d8d460edd94 # v2
- uses: int128/docker-manifest-create-action@b60433fd4312d7a64a56d769b76ebe3f45cf36b4 # v2
id: build
with:
tags: ${{ matrix.tag }}
sources: |
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}

View File

@@ -75,7 +75,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -101,7 +101,7 @@ jobs:
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@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -24,5 +24,5 @@ jobs:
dir="/tmp/authentik/${{ matrix.version }}"
mkdir -p $dir
cd $dir
wget https://${{ matrix.version }}.goauthentik.io/compose.yml
wget https://${{ matrix.version }}.goauthentik.io/docker-compose.yml
${current}/scripts/test_docker.sh

View File

@@ -193,15 +193,13 @@ jobs:
glob: tests/e2e/test_source_scim*
- name: flows
glob: tests/e2e/test_flows*
- name: endpoints
glob: tests/e2e/test_endpoints_*
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
with:
@@ -223,54 +221,6 @@ jobs:
if: ${{ always() }}
with:
flags: e2e
test-openid-conformance:
name: test-openid-conformance (${{ matrix.job.name }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
job:
- name: basic
glob: tests/openid_conformance/test_basic.py
- name: implicit
glob: tests/openid_conformance/test_implicit.py
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
- name: Setup conformance suite
run: |
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
npm run build
npm run build:sfe
- name: run conformance
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
uv run coverage xml
- uses: ./.github/actions/test-results
if: ${{ always() }}
with:
flags: conformance
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: conformance-certification-${{ matrix.job.name }}
path: tests/openid_conformance/exports/
ci-core-mark:
if: always()
needs:

View File

@@ -92,7 +92,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -114,7 +114,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: lifecycle/container/${{ matrix.type }}.Dockerfile
file: ${{ matrix.type }}.Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
@@ -122,7 +122,7 @@ jobs:
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:

View File

@@ -35,7 +35,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -58,7 +58,7 @@ jobs:
push: true
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
if: true
with:
@@ -90,7 +90,7 @@ jobs:
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
@@ -121,10 +121,10 @@ jobs:
build-args: |
VERSION=${{ github.ref }}
tags: ${{ steps.ev.outputs.imageTags }}
file: lifecycle/container/${{ matrix.type }}.Dockerfile
file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
@@ -232,7 +232,7 @@ jobs:
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
docker cp ${container}:web/ .
- name: Create a Sentry.io release
uses: getsentry/action-release@dab6548b3c03c4717878099e43782cf5be654289 # v3
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

3
.gitignore vendored
View File

@@ -211,5 +211,4 @@ source_docs/
/vendor/
### Docker ###
tests/openid_conformance/exports/*.zip
compose.override.yml
docker-compose.override.yml

View File

@@ -16,8 +16,10 @@ go.sum @goauthentik/backend
# Infrastructure
.github/ @goauthentik/infrastructure
lifecycle/aws/ @goauthentik/infrastructure
lifecycle/container/ @goauthentik/infrastructure
Dockerfile @goauthentik/infrastructure
*Dockerfile @goauthentik/infrastructure
.dockerignore @goauthentik/infrastructure
docker-compose.yml @goauthentik/infrastructure
Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure
@@ -38,7 +40,7 @@ packages/tsconfig @goauthentik/frontend
# Web
web/ @goauthentik/frontend
# Locale
/locale/ @goauthentik/backend @goauthentik/frontend
locale/ @goauthentik/backend @goauthentik/frontend
web/xliff/ @goauthentik/backend @goauthentik/frontend
# Docs
website/ @goauthentik/docs

View File

@@ -141,10 +141,14 @@ gen-build: ## Extract the schema from the database
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
uv run ak build_schema
uv run ak make_blueprint_schema --file blueprints/schema.json
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
uv run ak spectacular --file schema.yml
gen-compose:
uv run scripts/generate_compose.py
uv run scripts/generate_docker_compose.py
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
@@ -152,7 +156,7 @@ gen-changelog: ## (Release) generate the changelog based from the commits since
gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > schema-old.yml
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" diff \
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" diff \
--markdown \
/local/diff.md \
/local/schema-old.yml \
@@ -175,7 +179,7 @@ gen-clean-go: ## Remove generated API client for Go
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" gen \
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" gen \
generate \
-i /local/schema.yml \
-g typescript-fetch \
@@ -296,7 +300,7 @@ docs-api-clean: ## Clean generated API documentation
docker: ## Build a docker image of the current source tree
mkdir -p ${GEN_API_TS}
DOCKER_BUILDKIT=1 docker build . -f lifecycle/container/Dockerfile --progress plain --tag ${DOCKER_IMAGE}
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
test-docker:
BUILD=true ${PWD}/scripts/test_docker.sh
@@ -330,6 +334,6 @@ ci-pending-migrations: ci--meta-debug
uv run ak makemigrations --check
ci-test: ci--meta-debug
uv run coverage run manage.py test --keepdb authentik
uv run coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
uv run coverage report
uv run coverage xml

View File

@@ -1,45 +0,0 @@
from json import dumps
from django.core.management.base import BaseCommand, no_translations
from drf_spectacular.drainage import GENERATOR_STATS
from drf_spectacular.generators import SchemaGenerator
from drf_spectacular.renderers import OpenApiYamlRenderer
from drf_spectacular.validation import validate_schema
from structlog.stdlib import get_logger
from authentik.blueprints.v1.schema import SchemaBuilder
class Command(BaseCommand):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.logger = get_logger()
def add_arguments(self, parser):
parser.add_argument("--blueprint-file", type=str, default="blueprints/schema.json")
parser.add_argument("--api-file", type=str, default="schema.yml")
@no_translations
def handle(self, *args, blueprint_file: str, api_file: str, **options):
self.build_blueprint(blueprint_file)
self.build_api(api_file)
def build_blueprint(self, file: str):
self.logger.debug("Building blueprint schema...", file=file)
blueprint_builder = SchemaBuilder()
blueprint_builder.build()
with open(file, "w") as _schema:
_schema.write(
dumps(blueprint_builder.schema, indent=4, default=SchemaBuilder.json_default)
)
def build_api(self, file: str):
self.logger.debug("Building API schema...", file=file)
generator = SchemaGenerator()
schema = generator.get_schema(request=None, public=True)
GENERATOR_STATS.emit_summary()
validate_schema(schema)
output = OpenApiYamlRenderer().render(schema, renderer_context={})
with open(file, "wb") as f:
f.write(output)

View File

@@ -1,14 +1,9 @@
"""Schema generation tests"""
from pathlib import Path
from django.core.management import call_command
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import safe_load
from authentik.lib.config import CONFIG
class TestSchemaGeneration(APITestCase):
"""Generic admin tests"""
@@ -26,18 +21,3 @@ class TestSchemaGeneration(APITestCase):
reverse("authentik_api:schema-browser"),
)
self.assertEqual(response.status_code, 200)
def test_build_schema(self):
"""Test schema build command"""
blueprint_file = Path("blueprints/schema.json")
api_file = Path("schema.yml")
blueprint_file.unlink()
api_file.unlink()
with (
CONFIG.patch("debug", True),
CONFIG.patch("tenants.enabled", True),
CONFIG.patch("outposts.disable_embedded_outpost", True),
):
call_command("build_schema")
self.assertTrue(blueprint_file.exists())
self.assertTrue(api_file.exists())

View File

@@ -31,7 +31,6 @@ class Capabilities(models.TextChoices):
"""Define capabilities which influence which APIs can/should be used"""
CAN_SAVE_MEDIA = "can_save_media"
CAN_SAVE_REPORTS = "can_save_reports"
CAN_GEO_IP = "can_geo_ip"
CAN_ASN = "can_asn"
CAN_IMPERSONATE = "can_impersonate"
@@ -71,8 +70,6 @@ class ConfigView(APIView):
caps = []
if get_file_manager(FileUsage.MEDIA).manageable:
caps.append(Capabilities.CAN_SAVE_MEDIA)
if get_file_manager(FileUsage.REPORTS).manageable:
caps.append(Capabilities.CAN_SAVE_REPORTS)
for processor in get_context_processors():
if cap := processor.capability():
caps.append(cap)

View File

@@ -1,7 +1,9 @@
"""Generate JSON Schema for blueprints"""
from json import dumps
from typing import Any
from django.core.management.base import BaseCommand, no_translations
from django.db.models import Model, fields
from django.db.models.fields.related import OneToOneField
from drf_jsonschema_serializer.convert import converter, field_to_converter
@@ -38,12 +40,13 @@ class PrimaryKeyRelatedFieldConverter:
return {"type": "integer"}
class SchemaBuilder:
class Command(BaseCommand):
"""Generate JSON Schema for blueprints"""
schema: dict
def __init__(self):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.schema = {
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json",
@@ -90,6 +93,16 @@ class SchemaBuilder:
"$defs": {"blueprint_entry": {"oneOf": []}},
}
def add_arguments(self, parser):
parser.add_argument("--file", type=str)
@no_translations
def handle(self, *args, file: str, **options):
"""Generate JSON Schema for blueprints"""
self.build()
with open(file, "w") as _schema:
_schema.write(dumps(self.schema, indent=4, default=Command.json_default))
@staticmethod
def json_default(value: Any) -> Any:
"""Helper that handles gettext_lazy strings that JSON doesn't handle"""
@@ -111,7 +124,7 @@ class SchemaBuilder:
try:
serializer_class = model_instance.serializer
except NotImplementedError as exc:
raise ValueError(f"SerializerModel not implemented by {model}") from exc
raise NotImplementedError(model_instance) from exc
serializer = serializer_class(
context={
SERIALIZER_CONTEXT_BLUEPRINT: False,

View File

@@ -8,62 +8,45 @@ metadata:
- Application (icon)
- Source (icon)
- Flow (background)
- Endpoint Enrollment token (key)
entries:
token:
- model: authentik_core.token
identifiers:
identifier: "%(uid)s-token"
attrs:
key: "%(uid)s"
user: "%(user)s"
intent: api
app:
- model: authentik_core.application
identifiers:
slug: "%(uid)s-app"
attrs:
name: "%(uid)s-app"
icon: https://goauthentik.io/img/icon.png
source:
- model: authentik_sources_oauth.oauthsource
identifiers:
slug: "%(uid)s-source"
attrs:
name: "%(uid)s-source"
provider_type: azuread
consumer_key: "%(uid)s"
consumer_secret: "%(uid)s"
icon: https://goauthentik.io/img/icon.png
flow:
- model: authentik_flows.flow
identifiers:
slug: "%(uid)s-flow"
attrs:
name: "%(uid)s-flow"
title: "%(uid)s-flow"
designation: authentication
background: https://goauthentik.io/img/icon.png
user:
- model: authentik_core.user
identifiers:
username: "%(uid)s"
attrs:
name: "%(uid)s"
password: "%(uid)s"
- model: authentik_core.user
identifiers:
username: "%(uid)s-no-password"
attrs:
name: "%(uid)s"
endpoint:
- model: authentik_endpoints_connectors_agent.agentconnector
id: connector
identifiers:
name: "%(uid)s"
- model: authentik_endpoints_connectors_agent.enrollmenttoken
identifiers:
name: "%(uid)s"
attrs:
key: "%(uid)s"
connector: !KeyOf connector
- model: authentik_core.token
identifiers:
identifier: "%(uid)s-token"
attrs:
key: "%(uid)s"
user: "%(user)s"
intent: api
- model: authentik_core.application
identifiers:
slug: "%(uid)s-app"
attrs:
name: "%(uid)s-app"
icon: https://goauthentik.io/img/icon.png
- model: authentik_sources_oauth.oauthsource
identifiers:
slug: "%(uid)s-source"
attrs:
name: "%(uid)s-source"
provider_type: azuread
consumer_key: "%(uid)s"
consumer_secret: "%(uid)s"
icon: https://goauthentik.io/img/icon.png
- model: authentik_flows.flow
identifiers:
slug: "%(uid)s-flow"
attrs:
name: "%(uid)s-flow"
title: "%(uid)s-flow"
designation: authentication
background: https://goauthentik.io/img/icon.png
- model: authentik_core.user
identifiers:
username: "%(uid)s"
attrs:
name: "%(uid)s"
password: "%(uid)s"
- model: authentik_core.user
identifiers:
username: "%(uid)s-no-password"
attrs:
name: "%(uid)s"

View File

@@ -5,7 +5,6 @@ from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.core.models import Token, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.endpoints.connectors.agent.models import EnrollmentToken
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
@@ -30,18 +29,12 @@ class TestBlueprintsV1ConditionalFields(TransactionTestCase):
def test_user(self):
"""Test user"""
user = User.objects.filter(username=self.uid).first()
user: User = User.objects.filter(username=self.uid).first()
self.assertIsNotNone(user)
self.assertTrue(user.check_password(self.uid))
def test_user_null(self):
"""Test user"""
user = User.objects.filter(username=f"{self.uid}-no-password").first()
user: User = User.objects.filter(username=f"{self.uid}-no-password").first()
self.assertIsNotNone(user)
self.assertFalse(user.has_usable_password())
def test_enrollment_token(self):
"""Test endpoint enrollment token"""
token = EnrollmentToken.objects.filter(name=self.uid).first()
self.assertIsNotNone(token)
self.assertEqual(token.key, self.uid)

View File

@@ -149,7 +149,7 @@ class TestBlueprintsV1Tasks(TransactionTestCase):
instance.status,
BlueprintInstanceStatus.UNKNOWN,
)
apply_blueprint.send(instance.pk).get_result(block=True)
apply_blueprint(instance.pk)
instance.refresh_from_db()
self.assertEqual(instance.last_applied_hash, "")
self.assertEqual(

View File

@@ -15,6 +15,7 @@ from django.db.models import Model
from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from django_channels_postgres.models import GroupChannel, Message
from guardian.models import RoleObjectPermission, UserObjectPermission
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
@@ -40,17 +41,55 @@ from authentik.core.models import (
User,
UserSourceConnection,
)
from authentik.endpoints.models import Connector
from authentik.endpoints.connectors.agent.models import (
AgentDeviceConnection,
AppleNonce,
DeviceAuthenticationToken,
)
from authentik.endpoints.connectors.agent.models import (
DeviceToken as EndpointDeviceToken,
)
from authentik.endpoints.models import Connector, Device, DeviceConnection, DeviceFactSnapshot
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderUser,
)
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.ssf.models import StreamEvent
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
EndpointDevice,
EndpointDeviceConnection,
)
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.utils import cleanse_dict
from authentik.flows.models import Stage
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.flows.models import FlowToken, Stage
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.reflection import get_apps
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
DeviceToken,
RefreshToken,
)
from authentik.providers.proxy.models import ProxySession
from authentik.providers.rac.models import ConnectionToken
from authentik.providers.saml.models import SAMLSession
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
from authentik.rbac.models import Role
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.stages.consent.models import UserConsent
from authentik.tasks.models import Task, TaskLog
from authentik.tenants.models import Tenant
# Context set when the serializer is created in a blueprint context
# Update website/docs/customize/blueprints/v1/models.md when used
@@ -86,16 +125,49 @@ def excluded_models() -> list[type[Model]]:
# Classes that have other dependencies
Session,
AuthenticatedSession,
# Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
FlowToken,
LicenseUsage,
SCIMProviderGroup,
SCIMProviderUser,
Tenant,
Task,
TaskLog,
ConnectionToken,
AuthorizationCode,
AccessToken,
RefreshToken,
ProxySession,
Reputation,
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
GoogleWorkspaceProviderUser,
GoogleWorkspaceProviderGroup,
MicrosoftEntraProviderUser,
MicrosoftEntraProviderGroup,
EndpointDevice,
EndpointDeviceConnection,
EndpointDeviceToken,
Device,
DeviceConnection,
DeviceAuthenticationToken,
AppleNonce,
AgentDeviceConnection,
DeviceFactSnapshot,
DeviceToken,
StreamEvent,
UserConsent,
SAMLSession,
Message,
GroupChannel,
)
def is_model_allowed(model: type[Model]) -> bool:
"""Check if model is allowed"""
return (
model not in excluded_models()
and issubclass(model, SerializerModel | BaseMetaModel)
and not issubclass(model, InternallyManagedMixin)
)
return model not in excluded_models() and issubclass(model, SerializerModel | BaseMetaModel)
class DoRollback(SentryIgnoredException):

View File

@@ -37,21 +37,14 @@ class ApplyBlueprintMetaSerializer(PassiveSerializer):
return super().validate(attrs)
def create(self, validated_data: dict) -> MetaResult:
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.tasks import apply_blueprint
if not self.blueprint_instance:
LOGGER.info("Blueprint does not exist, but not required")
return MetaResult()
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
# Apply blueprint directly using Importer to avoid task context requirements
# and prevent deadlocks when called from within another blueprint task
blueprint_content = self.blueprint_instance.retrieve()
importer = Importer.from_string(blueprint_content, self.blueprint_instance.context)
valid, logs = importer.validate()
[log.log() for log in logs]
if valid:
importer.apply()
apply_blueprint(self.blueprint_instance.pk)
return MetaResult()

View File

@@ -12,6 +12,7 @@ from django.db import DatabaseError, InternalError, ProgrammingError
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_dramatiq_postgres.middleware import CurrentTaskNotFound
from dramatiq.actor import actor
from dramatiq.middleware import Middleware
from structlog.stdlib import get_logger
@@ -39,6 +40,7 @@ from authentik.events.utils import sanitize_dict
from authentik.lib.config import CONFIG
from authentik.tasks.apps import PRIORITY_HIGH
from authentik.tasks.middleware import CurrentTask
from authentik.tasks.models import Task
from authentik.tasks.schedules.models import Schedule
from authentik.tenants.models import Tenant
@@ -189,7 +191,10 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
@actor(description=_("Apply single blueprint."))
def apply_blueprint(instance_pk: UUID):
self = CurrentTask.get_task()
try:
self = CurrentTask.get_task()
except CurrentTaskNotFound:
self = Task()
self.set_uid(str(instance_pk))
instance: BlueprintInstance | None = None
try:

View File

@@ -256,7 +256,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
return [
StrField(Group, "name"),
BoolField(Group, "is_superuser", nullable=True),
JSONSearchField(Group, "attributes"),
JSONSearchField(Group, "attributes", suggest_nested=False),
]
def get_queryset(self):

View File

@@ -518,7 +518,7 @@ class UserViewSet(
StrField(User, "path"),
BoolField(User, "is_active", nullable=True),
ChoiceSearchField(User, "type"),
JSONSearchField(User, "attributes"),
JSONSearchField(User, "attributes", suggest_nested=False),
]
def get_queryset(self):

View File

@@ -66,12 +66,9 @@ class SessionStore(SessionBase):
def decode(self, session_data):
try:
return pickle.loads(session_data) # nosec
except (pickle.PickleError, AttributeError, TypeError):
# PickleError, ValueError - unpickling exceptions
# AttributeError - can happen when Django model fields (e.g., FileField) are unpickled
# and their descriptors fail to initialize (e.g., missing storage)
# TypeError - can happen with incompatible pickled objects
# If any of these happen, just return an empty dictionary (an empty session)
except pickle.PickleError:
# ValueError, unpickling exceptions. If any of these happen, just return an empty
# dictionary (an empty session)
pass
return {}

View File

@@ -35,13 +35,8 @@ def clean_expired_models():
LOGGER.debug("Expired models", model=cls, amount=amount)
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
clear_expired_cache()
for cls in [Message, GroupChannel]:
objects = cls.objects.all().filter(expires__lt=now())
amount = objects.count()
for obj in chunked_queryset(objects):
obj.delete()
LOGGER.debug("Expired models", model=cls, amount=amount)
self.info(f"Expired {amount} {cls._meta.verbose_name_plural}")
Message.delete_expired()
GroupChannel.delete_expired()
@actor(description=_("Remove temporary users created by SAML Sources."))

View File

@@ -5,10 +5,9 @@ from django.test import TestCase
from authentik.core.models import Group, PropertyMapping, Source, User
from authentik.core.sources.mapper import SourceMapper
from authentik.lib.generators import generate_id
from authentik.lib.models import InternallyManagedMixin
class ProxySource(InternallyManagedMixin, Source):
class ProxySource(Source):
@property
def property_mapping_type(self):
return PropertyMapping

View File

@@ -7,17 +7,14 @@ from cryptography.x509 import load_pem_x509_certificate
from django.db import migrations, models
from authentik.crypto.signals import extract_certificate_metadata
from authentik.lib.migrations import progress_bar
def backfill_certificate_metadata(apps, schema_editor): # noqa: ARG001
"""Backfill certificate metadata and kid for existing records."""
db_alias = schema_editor.connection.alias
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
print("\nStoring extra data about certificates, this might take a couple of minutes...")
for cert in progress_bar(CertificateKeyPair.objects.using(db_alias).all()):
for cert in CertificateKeyPair.objects.all():
updated_fields = []
if cert.certificate_data:
@@ -50,7 +47,7 @@ def backfill_certificate_metadata(apps, schema_editor): # noqa: ARG001
updated_fields.append("kid")
if updated_fields:
cert.save(update_fields=updated_fields, using=db_alias)
cert.save(update_fields=updated_fields)
class Migration(migrations.Migration):

View File

@@ -1,13 +1,11 @@
from typing import cast
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.fields import ChoiceField
from rest_framework.permissions import IsAuthenticated
from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.request import Request
from rest_framework.response import Response
@@ -24,9 +22,6 @@ from authentik.endpoints.connectors.agent.api.agent import (
from authentik.endpoints.connectors.agent.auth import (
AgentAuth,
AgentEnrollmentAuth,
DeviceAuthFedAuthentication,
agent_auth_issue_token,
check_device_policies,
)
from authentik.endpoints.connectors.agent.controller import MDMConfigResponseSerializer
from authentik.endpoints.connectors.agent.models import (
@@ -37,10 +32,7 @@ from authentik.endpoints.connectors.agent.models import (
)
from authentik.endpoints.facts import DeviceFacts, OSFamily
from authentik.endpoints.models import Device
from authentik.events.models import Event, EventAction
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
from authentik.lib.utils.reflection import ConditionalInheritance
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
class AgentConnectorSerializer(ConnectorSerializer):
@@ -171,43 +163,3 @@ class AgentConnectorViewSet(
connection: AgentDeviceConnection = token.device
connection.create_snapshot(data.validated_data)
return Response(status=204)
@extend_schema(
request=OpenApiTypes.NONE,
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
responses={
200: AgentTokenResponseSerializer(),
404: OpenApiResponse(description="Device not found"),
},
)
@action(
methods=["POST"],
detail=False,
pagination_class=None,
filter_backends=[],
permission_classes=[IsAuthenticated],
authentication_classes=[DeviceAuthFedAuthentication],
)
def auth_fed(self, request: Request) -> Response:
federated_token, device, connector = request.auth
policy_result = check_device_policies(device, federated_token.user, request._request)
if not policy_result.passing:
raise ValidationError(
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
)
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
rel_exp = int((exp - now()).total_seconds())
Event.new(
EventAction.LOGIN,
**{
PLAN_CONTEXT_METHOD: "jwt",
PLAN_CONTEXT_METHOD_ARGS: {
"jwt": federated_token,
"provider": federated_token.provider,
},
PLAN_CONTEXT_DEVICE: device,
},
).from_http(request, user=federated_token.user)
return Response({"token": token, "expires_in": rel_exp})

View File

@@ -1,11 +1,9 @@
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.tokens import TokenViewSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
@@ -21,11 +19,6 @@ class EnrollmentTokenSerializer(ModelSerializer):
source="device_group", read_only=True, required=False
)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["key"] = CharField(required=False)
class Meta:
model = EnrollmentToken
fields = [

View File

@@ -1,28 +1,13 @@
from typing import Any
from django.http import HttpRequest
from django.utils.timezone import now
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from jwt import PyJWTError, decode, encode
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import PermissionDenied
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.api.authentication import IPCUser, validate_auth
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import User
from authentik.crypto.apps import MANAGED_KEY
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceToken, EnrollmentToken
from authentik.endpoints.models import Device
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine
from authentik.policies.models import PolicyBindingModel
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
LOGGER = get_logger()
PLATFORM_ISSUER = "goauthentik.io/platform"
from authentik.endpoints.connectors.agent.models import DeviceToken, EnrollmentToken
class DeviceUser(IPCUser):
@@ -55,96 +40,3 @@ class AgentAuth(BaseAuthentication):
raise PermissionDenied()
CTX_AUTH_VIA.set("endpoint_token")
return (DeviceUser(), device_token)
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
if not kp:
return None, None
exp = now() + timedelta_from_string(connector.auth_session_duration)
token = encode(
{
"iss": PLATFORM_ISSUER,
"aud": str(device.pk),
"iat": int(now().timestamp()),
"exp": int(exp.timestamp()),
"preferred_username": user.username,
**kwargs,
},
kp.private_key,
headers={
"kid": kp.kid,
},
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
)
return token, exp
class DeviceAuthFedAuthentication(BaseAuthentication):
def authenticate(self, request):
raw_token = validate_auth(get_authorization_header(request))
if not raw_token:
LOGGER.warning("Missing token")
return None
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
if not device:
LOGGER.warning("Couldn't find device")
return None
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
connector = connectors_for_device.first()
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
federated_token = AccessToken.objects.filter(
token=raw_token, provider__in=providers
).first()
if not federated_token:
LOGGER.warning("Couldn't lookup provider")
return None
_key, _alg = federated_token.provider.jwt_key
try:
decode(
raw_token,
_key.public_key(),
algorithms=[_alg],
options={
"verify_aud": False,
},
)
LOGGER.info(
"successfully verified JWT with provider", provider=federated_token.provider.name
)
return (federated_token.user, (federated_token, device, connector))
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
return None
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
"""Auth schema"""
target_class = DeviceAuthFedAuthentication
name = "device_federation"
def get_security_definition(self, auto_schema):
"""Auth schema"""
return {"type": "http", "scheme": "bearer"}
def check_device_policies(device: Device, user: User, request: HttpRequest):
"""Check policies bound to device group and device"""
if device.access_group:
result = check_pbm_policies(device.access_group, user, request)
if result.passing:
return result
return check_pbm_policies(device, user, request)
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
policy_engine = PolicyEngine(pbm, user, request)
policy_engine.use_cache = False
policy_engine.empty_result = False
policy_engine.mode = pbm.policy_engine_mode
policy_engine.build()
result = policy_engine.result
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
return result

View File

@@ -16,7 +16,7 @@ from authentik.endpoints.models import (
)
from authentik.flows.stage import StageView
from authentik.lib.generators import generate_key
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
if TYPE_CHECKING:
@@ -97,7 +97,7 @@ class AgentDeviceUserBinding(DeviceUserBinding):
apple_enclave_key_id = models.TextField()
class DeviceToken(InternallyManagedMixin, ExpiringModel):
class DeviceToken(ExpiringModel):
"""Per-device token used for authentication."""
token_uuid = models.UUIDField(primary_key=True, default=uuid4)
@@ -143,7 +143,7 @@ class EnrollmentToken(ExpiringModel, SerializerModel):
]
class DeviceAuthenticationToken(InternallyManagedMixin, ExpiringModel):
class DeviceAuthenticationToken(ExpiringModel):
identifier = models.UUIDField(default=uuid4, primary_key=True)
device = models.ForeignKey(Device, on_delete=models.CASCADE)
@@ -160,7 +160,7 @@ class DeviceAuthenticationToken(InternallyManagedMixin, ExpiringModel):
verbose_name_plural = _("Device authentication tokens")
class AppleNonce(InternallyManagedMixin, ExpiringModel):
class AppleNonce(ExpiringModel):
nonce = models.TextField()
device_token = models.ForeignKey(DeviceToken, on_delete=models.CASCADE)

View File

@@ -15,7 +15,7 @@ from authentik.core.models import AttributesMixin, ExpiringModel
from authentik.flows.models import Stage
from authentik.flows.stage import StageView
from authentik.lib.merge import MERGE_LIST_UNIQUE
from authentik.lib.models import InheritanceForeignKey, InternallyManagedMixin, SerializerModel
from authentik.lib.models import InheritanceForeignKey, SerializerModel
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.policies.models import PolicyBinding, PolicyBindingModel
from authentik.tasks.schedules.common import ScheduleSpec
@@ -28,7 +28,7 @@ LOGGER = get_logger()
DEVICE_FACTS_CACHE_TIMEOUT = 3600
class Device(InternallyManagedMixin, ExpiringModel, AttributesMixin, PolicyBindingModel):
class Device(ExpiringModel, AttributesMixin, PolicyBindingModel):
device_uuid = models.UUIDField(default=uuid4, primary_key=True)
name = models.TextField(unique=True)
@@ -86,7 +86,7 @@ class DeviceUserBinding(PolicyBinding):
verbose_name_plural = _("Device User bindings")
class DeviceConnection(InternallyManagedMixin, SerializerModel):
class DeviceConnection(SerializerModel):
device_connection_uuid = models.UUIDField(default=uuid4, primary_key=True)
device = models.ForeignKey("Device", on_delete=models.CASCADE)
connector = models.ForeignKey("Connector", on_delete=models.CASCADE)
@@ -115,7 +115,7 @@ class DeviceConnection(InternallyManagedMixin, SerializerModel):
verbose_name_plural = _("Device connections")
class DeviceFactSnapshot(InternallyManagedMixin, ExpiringModel, SerializerModel):
class DeviceFactSnapshot(ExpiringModel, SerializerModel):
snapshot_id = models.UUIDField(primary_key=True, default=uuid4)
connection = models.ForeignKey(DeviceConnection, on_delete=models.CASCADE)
data = models.JSONField(default=dict)

View File

@@ -1,8 +1,6 @@
"""Enterprise API Views"""
from collections.abc import Callable
from datetime import timedelta
from functools import wraps
from django.utils.timezone import now
from django.utils.translation import gettext as _
@@ -37,18 +35,6 @@ class EnterpriseRequiredMixin:
return super().validate(attrs)
def enterprise_action(func: Callable):
"""Check permissions for a single custom action"""
@wraps(func)
def wrapper(*args, **kwargs) -> Response:
if not LicenseKey.cached_summary().status.is_valid:
raise ValidationError(_("Enterprise is required to use this endpoint."))
return func(*args, **kwargs)
return wrapper
class LicenseSerializer(ModelSerializer):
"""License Serializer"""

View File

@@ -1,20 +1,31 @@
from django.urls import reverse
from django.utils.timezone import now
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from structlog.stdlib import get_logger
from authentik.endpoints.connectors.agent.api.agent import (
AgentAuthenticationResponse,
AgentTokenResponseSerializer,
)
from authentik.endpoints.connectors.agent.auth import AgentAuth
from authentik.endpoints.connectors.agent.models import (
DeviceAuthenticationToken,
DeviceToken,
)
from authentik.enterprise.api import enterprise_action
from authentik.enterprise.endpoints.connectors.agent.auth import (
DeviceAuthFedAuthentication,
agent_auth_issue_token,
check_device_policies,
)
from authentik.events.models import Event, EventAction
from authentik.flows.planner import PLAN_CONTEXT_DEVICE
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
LOGGER = get_logger()
@@ -26,7 +37,6 @@ class AgentConnectorViewSetMixin:
responses=AgentAuthenticationResponse(),
)
@action(methods=["POST"], detail=False, authentication_classes=[AgentAuth])
@enterprise_action
def auth_ia(self, request: Request) -> Response:
token: DeviceToken = request.auth
auth_token = DeviceAuthenticationToken.objects.create(
@@ -44,3 +54,43 @@ class AgentConnectorViewSetMixin:
),
}
)
@extend_schema(
request=OpenApiTypes.NONE,
parameters=[OpenApiParameter("device", OpenApiTypes.STR, location="query", required=True)],
responses={
200: AgentTokenResponseSerializer(),
404: OpenApiResponse(description="Device not found"),
},
)
@action(
methods=["POST"],
detail=False,
pagination_class=None,
filter_backends=[],
permission_classes=[IsAuthenticated],
authentication_classes=[DeviceAuthFedAuthentication],
)
def auth_fed(self, request: Request) -> Response:
federated_token, device, connector = request.auth
policy_result = check_device_policies(device, federated_token.user, request._request)
if not policy_result.passing:
raise ValidationError(
{"policy_result": "Policy denied access", "policy_messages": policy_result.messages}
)
token, exp = agent_auth_issue_token(device, connector, federated_token.user)
rel_exp = int((exp - now()).total_seconds())
Event.new(
EventAction.LOGIN,
**{
PLAN_CONTEXT_METHOD: "jwt",
PLAN_CONTEXT_METHOD_ARGS: {
"jwt": federated_token,
"provider": federated_token.provider,
},
PLAN_CONTEXT_DEVICE: device,
},
).from_http(request, user=federated_token.user)
return Response({"token": token, "expires_in": rel_exp})

View File

@@ -0,0 +1,113 @@
from django.http import HttpRequest
from django.utils.timezone import now
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from jwt import PyJWTError, decode, encode
from rest_framework.authentication import BaseAuthentication
from structlog.stdlib import get_logger
from authentik.api.authentication import get_authorization_header, validate_auth
from authentik.core.models import User
from authentik.crypto.apps import MANAGED_KEY
from authentik.crypto.models import CertificateKeyPair
from authentik.endpoints.connectors.agent.models import AgentConnector
from authentik.endpoints.models import Device
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine
from authentik.policies.models import PolicyBindingModel
from authentik.providers.oauth2.models import AccessToken, JWTAlgorithms, OAuth2Provider
LOGGER = get_logger()
PLATFORM_ISSUER = "goauthentik.io/platform"
def agent_auth_issue_token(device: Device, connector: AgentConnector, user: User, **kwargs):
kp = CertificateKeyPair.objects.filter(managed=MANAGED_KEY).first()
if not kp:
return None, None
exp = now() + timedelta_from_string(connector.auth_session_duration)
token = encode(
{
"iss": PLATFORM_ISSUER,
"aud": str(device.pk),
"iat": int(now().timestamp()),
"exp": int(exp.timestamp()),
"preferred_username": user.username,
**kwargs,
},
kp.private_key,
headers={
"kid": kp.kid,
},
algorithm=JWTAlgorithms.from_private_key(kp.private_key),
)
return token, exp
class DeviceAuthFedAuthentication(BaseAuthentication):
def authenticate(self, request):
raw_token = validate_auth(get_authorization_header(request))
if not raw_token:
LOGGER.warning("Missing token")
return None
device = Device.filter_not_expired(name=request.query_params.get("device")).first()
if not device:
LOGGER.warning("Couldn't find device")
return None
connectors_for_device = AgentConnector.objects.filter(device__in=[device])
connector = connectors_for_device.first()
providers = OAuth2Provider.objects.filter(agentconnector__in=connectors_for_device)
federated_token = AccessToken.objects.filter(
token=raw_token, provider__in=providers
).first()
if not federated_token:
LOGGER.warning("Couldn't lookup provider")
return None
_key, _alg = federated_token.provider.jwt_key
try:
decode(
raw_token,
_key.public_key(),
algorithms=[_alg],
options={
"verify_aud": False,
},
)
LOGGER.info(
"successfully verified JWT with provider", provider=federated_token.provider.name
)
return (federated_token.user, (federated_token, device, connector))
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
LOGGER.warning("failed to verify JWT", exc=exc, provider=federated_token.provider.name)
return None
class DeviceFederationAuthSchema(OpenApiAuthenticationExtension):
"""Auth schema"""
target_class = DeviceAuthFedAuthentication
name = "device_federation"
def get_security_definition(self, auto_schema):
"""Auth schema"""
return {"type": "http", "scheme": "bearer"}
def check_device_policies(device: Device, user: User, request: HttpRequest):
"""Check policies bound to device group and device"""
if device.access_group:
result = check_pbm_policies(device.access_group, user, request)
if result.passing:
return result
return check_pbm_policies(device, user, request)
def check_pbm_policies(pbm: PolicyBindingModel, user: User, request: HttpRequest):
policy_engine = PolicyEngine(pbm, user, request)
policy_engine.use_cache = False
policy_engine.empty_result = False
policy_engine.mode = pbm.policy_engine_mode
policy_engine.build()
result = policy_engine.result
LOGGER.debug("PolicyAccessView user_has_access", user=user.username, result=result, pbm=pbm.pk)
return result

View File

@@ -63,21 +63,8 @@ class TestConnectorAuthIA(FlowTestCase):
)
self.assertEqual(response.status_code, 200)
@patch(
"authentik.enterprise.license.LicenseKey.validate",
MagicMock(
return_value=LicenseKey(
aud="",
exp=expiry_valid,
name=generate_id(),
internal_users=100,
external_users=100,
)
),
)
@reconcile_app("authentik_crypto")
def test_auth_ia_fulfill(self):
License.objects.create(key=generate_id())
self.client.force_login(self.user)
response = self.client.post(
reverse("authentik_api:agentconnector-auth-ia"),

View File

@@ -3,12 +3,12 @@ from hmac import compare_digest
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseBadRequest, QueryDict
from authentik.endpoints.connectors.agent.auth import (
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken
from authentik.endpoints.models import Device
from authentik.enterprise.endpoints.connectors.agent.auth import (
agent_auth_issue_token,
check_device_policies,
)
from authentik.endpoints.connectors.agent.models import AgentConnector, DeviceAuthenticationToken
from authentik.endpoints.models import Device
from authentik.enterprise.policy import EnterprisePolicyAccessView
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import in_memory_stage

View File

@@ -11,7 +11,7 @@ from django.utils.translation import gettext as _
from rest_framework.serializers import BaseSerializer
from authentik.core.models import ExpiringModel
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
if TYPE_CHECKING:
from authentik.enterprise.license import LicenseKey
@@ -81,7 +81,7 @@ class LicenseUsageStatus(models.TextChoices):
return self in [LicenseUsageStatus.VALID, LicenseUsageStatus.EXPIRY_SOON]
class LicenseUsage(InternallyManagedMixin, ExpiringModel):
class LicenseUsage(ExpiringModel):
"""a single license usage record"""
expires = models.DateTimeField(default=usage_expiry)

View File

@@ -18,7 +18,7 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
@@ -32,7 +32,7 @@ def default_scopes() -> list[str]:
]
class GoogleWorkspaceProviderUser(InternallyManagedMixin, SerializerModel):
class GoogleWorkspaceProviderUser(SerializerModel):
"""Mapping of a user and provider to a Google user ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@@ -58,7 +58,7 @@ class GoogleWorkspaceProviderUser(InternallyManagedMixin, SerializerModel):
return f"Google Workspace Provider User {self.user_id} to {self.provider_id}"
class GoogleWorkspaceProviderGroup(InternallyManagedMixin, SerializerModel):
class GoogleWorkspaceProviderGroup(SerializerModel):
"""Mapping of a group and provider to a Google group ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)

View File

@@ -18,12 +18,12 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
class MicrosoftEntraProviderUser(InternallyManagedMixin, SerializerModel):
class MicrosoftEntraProviderUser(SerializerModel):
"""Mapping of a user and provider to a Microsoft user ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@@ -49,7 +49,7 @@ class MicrosoftEntraProviderUser(InternallyManagedMixin, SerializerModel):
return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}"
class MicrosoftEntraProviderGroup(InternallyManagedMixin, SerializerModel):
class MicrosoftEntraProviderGroup(SerializerModel):
"""Mapping of a group and provider to a Microsoft group ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)

View File

@@ -14,7 +14,7 @@ from jwt import encode
from authentik.core.models import BackchannelProvider, ExpiringModel, Token
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import CreatedUpdatedModel, InternallyManagedMixin
from authentik.lib.models import CreatedUpdatedModel
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
from authentik.tasks.models import TasksModel
@@ -153,7 +153,7 @@ class Stream(models.Model):
return encode(data, key, algorithm=alg, headers=headers)
class StreamEvent(InternallyManagedMixin, CreatedUpdatedModel, ExpiringModel):
class StreamEvent(CreatedUpdatedModel, ExpiringModel):
"""Single stream event to be sent"""
uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False)

View File

@@ -4,35 +4,37 @@ from django.urls import reverse
from drf_spectacular.utils import extend_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.fields import CharField, SerializerMethodField
from rest_framework.fields import CharField
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.groups import PartialUserSerializer
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import User
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.reports.models import DataExport
from authentik.enterprise.reports.tasks import generate_export
from authentik.rbac.permissions import HasPermission
class RequestedBySerializer(ModelSerializer):
class Meta:
model = User
fields = ("pk", "username")
class ContentTypeSerializer(ModelSerializer):
app_label = CharField(read_only=True)
model = CharField(read_only=True)
verbose_name_plural = SerializerMethodField()
def get_verbose_name_plural(self, ct: ContentType) -> str:
return ct.model_class()._meta.verbose_name_plural
class Meta:
model = ContentType
fields = ("id", "app_label", "model", "verbose_name_plural")
fields = ("id", "app_label", "model")
class DataExportSerializer(EnterpriseRequiredMixin, ModelSerializer):
requested_by = PartialUserSerializer(read_only=True)
requested_by = RequestedBySerializer(read_only=True)
content_type = ContentTypeSerializer(read_only=True)
class Meta:

View File

@@ -7,7 +7,6 @@ from django.db import connection
from django.db.models import Model, Q
from djangoql.compat import text_type
from djangoql.schema import StrField
from djangoql.serializers import DjangoQLSchemaSerializer
class JSONSearchField(StrField):
@@ -15,18 +14,10 @@ class JSONSearchField(StrField):
model: Model
def __init__(
self,
model=None,
name=None,
nullable=None,
suggest_nested=False,
fixed_structure: OrderedDict | None = None,
):
def __init__(self, model=None, name=None, nullable=None, suggest_nested=True):
# Set this in the constructor to not clobber the type variable
self.type = "relation"
self.suggest_nested = suggest_nested
self.fixed_structure = fixed_structure
super().__init__(model, name, nullable)
def get_lookup(self, path, operator, value):
@@ -66,23 +57,11 @@ class JSONSearchField(StrField):
)
return (x[0] for x in cursor.fetchall())
def get_fixed_structure(self, serializer: DjangoQLSchemaSerializer) -> OrderedDict:
new_dict = OrderedDict()
if not self.fixed_structure:
return new_dict
new_dict.setdefault(self.relation(), {})
for key, value in self.fixed_structure.items():
new_dict[self.relation()][key] = serializer.serialize_field(value)
if isinstance(value, JSONSearchField):
new_dict.update(value.get_nested_options(serializer))
return new_dict
def get_nested_options(self, serializer: DjangoQLSchemaSerializer) -> OrderedDict:
def get_nested_options(self) -> OrderedDict:
"""Get keys of all nested objects to show autocomplete"""
if not self.suggest_nested:
if self.fixed_structure:
return self.get_fixed_structure(serializer)
return OrderedDict()
base_model_name = f"{self.model._meta.app_label}.{self.model._meta.model_name}_{self.name}"
def recursive_function(parts: list[str], parent_parts: list[str] | None = None):
if not parent_parts:
@@ -108,7 +87,7 @@ class JSONSearchField(StrField):
relation_structure = defaultdict(dict)
for relations in self.json_field_keys():
result = recursive_function([self.relation()] + relations)
result = recursive_function([base_model_name] + relations)
for relation_key, value in result.items():
for sub_relation_key, sub_value in value.items():
if not relation_structure[relation_key].get(sub_relation_key, None):

View File

@@ -12,7 +12,7 @@ class AKQLSchemaSerializer(DjangoQLSchemaSerializer):
for _, field in fields.items():
if not isinstance(field, JSONSearchField):
continue
serialization["models"].update(field.get_nested_options(self))
serialization["models"].update(field.get_nested_options())
return serialization
def serialize_field(self, field):

View File

@@ -11,7 +11,7 @@ from rest_framework.serializers import BaseSerializer, Serializer
from authentik.core.types import UserSettingSerializer
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
from authentik.flows.stage import StageView
from authentik.lib.models import DeprecatedMixin, InternallyManagedMixin, SerializerModel
from authentik.lib.models import DeprecatedMixin, SerializerModel
from authentik.stages.authenticator.models import Device
@@ -63,7 +63,7 @@ class AuthenticatorEndpointGDTCStage(DeprecatedMixin, ConfigurableStage, Friendl
verbose_name_plural = _("Endpoint Authenticator Google Device Trust Connector Stages")
class EndpointDevice(InternallyManagedMixin, SerializerModel, Device):
class EndpointDevice(SerializerModel, Device):
"""Endpoint Device for a single user"""
uuid = models.UUIDField(primary_key=True, default=uuid4)
@@ -91,7 +91,7 @@ class EndpointDevice(InternallyManagedMixin, SerializerModel, Device):
verbose_name_plural = _("Endpoint Devices")
class EndpointDeviceConnection(InternallyManagedMixin, models.Model):
class EndpointDeviceConnection(models.Model):
device = models.ForeignKey(EndpointDevice, on_delete=models.CASCADE)
stage = models.ForeignKey(AuthenticatorEndpointGDTCStage, on_delete=models.CASCADE)

View File

@@ -1,6 +1,5 @@
"""Events API Views"""
from collections import OrderedDict
from datetime import timedelta
import django_filters
@@ -137,7 +136,7 @@ class EventViewSet(
filterset_class = EventsFilter
def get_ql_fields(self):
from djangoql.schema import DateTimeField, IntField, StrField
from djangoql.schema import DateTimeField, StrField
from authentik.enterprise.search.fields import ChoiceSearchField, JSONSearchField
@@ -146,42 +145,9 @@ class EventViewSet(
StrField(Event, "event_uuid"),
StrField(Event, "app", suggest_options=True),
StrField(Event, "client_ip"),
JSONSearchField(
Event,
"user",
fixed_structure=OrderedDict(
pk=IntField(),
username=StrField(),
email=StrField(),
),
),
JSONSearchField(
Event,
"brand",
fixed_structure=OrderedDict(
pk=StrField(),
app=StrField(),
name=StrField(),
model_name=StrField(),
),
),
JSONSearchField(
Event,
"context",
fixed_structure=OrderedDict(
http_request=JSONSearchField(
Event,
"context_http_request",
fixed_structure=OrderedDict(
args=JSONSearchField(Event, "context_http_request_args"),
path=StrField(),
method=StrField(),
request_id=StrField(),
user_agent=StrField(),
),
),
),
),
JSONSearchField(Event, "user", suggest_nested=False),
JSONSearchField(Event, "brand", suggest_nested=False),
JSONSearchField(Event, "context", suggest_nested=False),
DateTimeField(Event, "created", suggest_options=True),
]

View File

@@ -7,7 +7,7 @@ from typing import Any
from django.utils.timezone import now
from rest_framework.fields import CharField, ChoiceField, DateTimeField, DictField
from structlog import configure, get_config
from structlog.stdlib import NAME_TO_LEVEL, ProcessorFormatter, get_logger
from structlog.stdlib import NAME_TO_LEVEL, ProcessorFormatter
from structlog.testing import LogCapture
from structlog.types import EventDict
@@ -36,9 +36,6 @@ class LogEvent:
event, log_level, item.pop("logger"), timestamp, attributes=sanitize_dict(item)
)
def log(self):
get_logger(self.logger).log(NAME_TO_LEVEL[self.log_level], self.event, **self.attributes)
class LogEventSerializer(PassiveSerializer):
"""Single log message with all context logged."""

View File

@@ -19,7 +19,6 @@ from authentik.blueprints.v1.importer import excluded_models
from authentik.core.models import Group, User
from authentik.events.models import Event, EventAction, Notification
from authentik.events.utils import model_to_dict
from authentik.lib.models import InternallyManagedMixin
from authentik.lib.sentry import should_ignore_exception
from authentik.lib.utils.errors import exception_to_dict
from authentik.stages.authenticator_static.models import StaticToken
@@ -41,7 +40,7 @@ _CTX_REQUEST = ContextVar[HttpRequest | None]("authentik_events_log_request", de
def should_log_model(model: Model) -> bool:
"""Return true if operation on `model` should be logged"""
return model.__class__ not in IGNORED_MODELS and not isinstance(model, InternallyManagedMixin)
return model.__class__ not in IGNORED_MODELS
def should_log_m2m(model: Model) -> bool:

View File

@@ -8,8 +8,6 @@ from inspect import currentframe
from typing import Any
from uuid import uuid4
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.apps import apps
from django.db import models
from django.http import HttpRequest
@@ -43,7 +41,6 @@ from authentik.lib.utils.http import get_http_session
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.root.middleware import ClientIPMiddleware
from authentik.root.ws.consumer import build_user_group
from authentik.stages.email.models import EmailTemplates
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tasks.models import TasksModel
@@ -364,15 +361,6 @@ class NotificationTransport(TasksModel, SerializerModel):
notification=notification,
)
notification.save()
layer = get_channel_layer()
async_to_sync(layer.group_send)(
build_user_group(notification.user),
{
"type": "event.notification",
"id": str(notification.pk),
"data": notification.serializer(notification).data,
},
)
return []
def send_webhook(self, notification: "Notification") -> list[str]:

View File

@@ -20,7 +20,7 @@ from authentik.core.models import Token
from authentik.core.types import UserSettingSerializer
from authentik.flows.challenge import FlowLayout
from authentik.lib.config import CONFIG
from authentik.lib.models import InheritanceForeignKey, InternallyManagedMixin, SerializerModel
from authentik.lib.models import InheritanceForeignKey, SerializerModel
from authentik.lib.utils.reflection import class_to_path
from authentik.policies.models import PolicyBindingModel
@@ -301,7 +301,7 @@ class FriendlyNamedStage(models.Model):
abstract = True
class FlowToken(InternallyManagedMixin, Token):
class FlowToken(Token):
"""Subclass of a standard Token, stores the currently active flow plan upon creation.
Can be used to later resume a flow."""

View File

@@ -249,7 +249,7 @@ class ChallengeStageView(StageView):
"f(ch): invalid challenge response",
errors=challenge_response.errors,
)
return HttpChallengeResponse(challenge_response)
return HttpChallengeResponse(challenge_response, status=400)
class AccessDeniedStage(ChallengeStageView):

View File

@@ -147,7 +147,7 @@ class FlowExecutorView(APIView):
token.delete()
if not isinstance(plan, FlowPlan):
return None
if existing_plan := self.request.session.get(SESSION_KEY_PLAN):
if existing_plan := self.request.session[SESSION_KEY_PLAN]:
plan.context.update(existing_plan.context)
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
@@ -258,6 +258,11 @@ class FlowExecutorView(APIView):
serializers=challenge_types,
resource_type_field_name="component",
),
400: PolymorphicProxySerializer(
component_name="ChallengeTypes",
serializers=challenge_types,
resource_type_field_name="component",
),
},
request=OpenApiTypes.NONE,
parameters=[
@@ -305,6 +310,11 @@ class FlowExecutorView(APIView):
serializers=challenge_types,
resource_type_field_name="component",
),
400: PolymorphicProxySerializer(
component_name="ChallengeTypes",
serializers=challenge_types,
resource_type_field_name="component",
),
},
request=PolymorphicProxySerializer(
component_name="FlowChallengeResponse",

View File

@@ -111,7 +111,6 @@ def get_logger_config():
"hpack": "WARNING",
"httpx": "WARNING",
"azure": "WARNING",
"httpcore": "WARNING",
}
for handler_name, level in handler_level_map.items():
base_config["loggers"][handler_name] = {

View File

@@ -64,10 +64,6 @@ class DeprecatedMixin:
"""Mixin for classes that are deprecated"""
class InternallyManagedMixin:
"""Mixin for models that should _not_ be manageable via blueprint."""
class DomainlessURLValidator(URLValidator):
"""Subclass of URLValidator which doesn't check the domain
(to allow hostnames without domain)"""

View File

@@ -84,7 +84,7 @@ class OutgoingSyncProvider(ScheduledModel, Model):
raise NotImplementedError
def sync_dispatch(self) -> None:
for schedule in self.schedules.all():
for schedule in self.schedules:
schedule.send()
@property

View File

@@ -1,17 +1,16 @@
"""authentik database utilities"""
import gc
from collections.abc import Generator
from django.db import reset_queries
from django.db.models import Model, QuerySet
from django.db.models import QuerySet
def chunked_queryset[T: Model](queryset: QuerySet[T], chunk_size: int = 1_000) -> Generator[T]:
def chunked_queryset(queryset: QuerySet, chunk_size: int = 1_000):
if not queryset.exists():
return []
def get_chunks(qs: QuerySet) -> Generator[QuerySet[T]]:
def get_chunks(qs: QuerySet):
qs = qs.order_by("pk")
pks = qs.values_list("pk", flat=True)
start_pk = pks[0]

View File

@@ -9,7 +9,6 @@ from rest_framework.serializers import BaseSerializer
from structlog.stdlib import get_logger
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.blueprints.v1.meta.registry import BaseMetaModel
from authentik.events.models import Event, EventAction
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
@@ -32,7 +31,7 @@ def model_choices() -> list[tuple[str, str]]:
Returns a list of tuples containing (dotted.model.path, name)"""
choices = []
for model in apps.get_models():
if not is_model_allowed(model) or issubclass(model, BaseMetaModel):
if not is_model_allowed(model):
continue
name = f"{model._meta.app_label}.{model._meta.model_name}"
choices.append((name, model._meta.verbose_name))

View File

@@ -14,7 +14,7 @@ from structlog import get_logger
from authentik.core.models import ExpiringModel
from authentik.lib.config import CONFIG
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.root.middleware import ClientIPMiddleware
@@ -69,7 +69,7 @@ class ReputationPolicy(Policy):
verbose_name_plural = _("Reputation Policies")
class Reputation(InternallyManagedMixin, ExpiringModel, SerializerModel):
class Reputation(ExpiringModel, SerializerModel):
"""Reputation for user and or IP."""
objects = PostgresManager()

View File

@@ -42,7 +42,7 @@ from authentik.core.models import (
)
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin, SerializerModel
from authentik.lib.models import DomainlessURLValidator, SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
from authentik.providers.oauth2.constants import SubModes
from authentik.sources.oauth.models import OAuthSource
@@ -462,7 +462,7 @@ class BaseGrantModel(models.Model):
self._scope = " ".join(value)
class AuthorizationCode(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel):
"""OAuth2 Authorization Code"""
code = models.CharField(max_length=255, unique=True, verbose_name=_("Code"))
@@ -497,7 +497,7 @@ class AuthorizationCode(InternallyManagedMixin, SerializerModel, ExpiringModel,
)
class AccessToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
"""OAuth2 access token, non-opaque using a JWT as identifier"""
token = models.TextField()
@@ -545,7 +545,7 @@ class AccessToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGr
return TokenModelSerializer
class RefreshToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
"""OAuth2 Refresh Token, opaque"""
token = models.TextField(default=generate_client_secret)
@@ -585,7 +585,7 @@ class RefreshToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseG
return TokenModelSerializer
class DeviceToken(InternallyManagedMixin, ExpiringModel):
class DeviceToken(ExpiringModel):
"""Temporary device token for OAuth device flow"""
user = models.ForeignKey(

View File

@@ -13,7 +13,7 @@ from rest_framework.serializers import Serializer
from authentik.core.models import ExpiringModel
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin
from authentik.lib.models import DomainlessURLValidator
from authentik.outposts.models import OutpostModel
from authentik.providers.oauth2.models import (
ClientTypes,
@@ -27,7 +27,7 @@ SCOPE_AK_PROXY = "ak_proxy"
OUTPOST_CALLBACK_SIGNATURE = "X-authentik-auth-callback"
class ProxySession(InternallyManagedMixin, ExpiringModel):
class ProxySession(ExpiringModel):
"""Session storage for proxyv2 outposts using PostgreSQL"""
uuid = models.UUIDField(default=uuid4, primary_key=True)

View File

@@ -15,7 +15,7 @@ from structlog.stdlib import get_logger
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User, default_token_key
from authentik.events.models import Event, EventAction
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
from authentik.outposts.models import OutpostModel
from authentik.policies.models import PolicyBindingModel
@@ -145,7 +145,7 @@ class RACPropertyMapping(PropertyMapping):
verbose_name_plural = _("RAC Provider Property Mappings")
class ConnectionToken(InternallyManagedMixin, ExpiringModel):
class ConnectionToken(ExpiringModel):
"""Token for a single connection to a specified endpoint"""
connection_token_uuid = models.UUIDField(default=uuid4, primary_key=True)

View File

@@ -18,7 +18,7 @@ from authentik.core.models import (
User,
)
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin, SerializerModel
from authentik.lib.models import DomainlessURLValidator, SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
from authentik.sources.saml.models import SAMLNameIDPolicy
from authentik.sources.saml.processors.constants import (
@@ -303,7 +303,7 @@ class SAMLProviderImportModel(CreatableType, Provider):
verbose_name_plural = _("SAML Providers from Metadata")
class SAMLSession(InternallyManagedMixin, SerializerModel, ExpiringModel):
class SAMLSession(SerializerModel, ExpiringModel):
"""Track active SAML sessions for Single Logout support"""
saml_session_id = models.UUIDField(default=uuid4, primary_key=True)

View File

@@ -13,7 +13,7 @@ from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.core.models import BackchannelProvider, Group, PropertyMapping, User, UserTypes
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
@@ -22,7 +22,7 @@ from authentik.providers.scim.clients.auth import SCIMTokenAuth
LOGGER = get_logger()
class SCIMProviderUser(InternallyManagedMixin, SerializerModel):
class SCIMProviderUser(SerializerModel):
"""Mapping of a user and provider to a SCIM user ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
@@ -44,7 +44,7 @@ class SCIMProviderUser(InternallyManagedMixin, SerializerModel):
return f"SCIM Provider User {self.user_id} to {self.provider_id}"
class SCIMProviderGroup(InternallyManagedMixin, SerializerModel):
class SCIMProviderGroup(SerializerModel):
"""Mapping of a group and provider to a SCIM user ID"""
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)

View File

@@ -50,7 +50,7 @@ def get_user(scope):
"Cannot find session in scope. You should wrap your consumer in SessionMiddleware."
)
user = None
if (authenticated_session := scope["session"].get("authenticatedsession", None)) is not None:
if (authenticated_session := scope["session"].get("authenticated_session", None)) is not None:
user = authenticated_session.user
return user or AnonymousUser()

View File

@@ -96,9 +96,6 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
def add_arguments(cls, parser: ArgumentParser):
"""Add more pytest-specific arguments"""
DiscoverRunner.add_arguments(parser)
default_seed = None
if seed := os.getenv("CI_TEST_SEED"):
default_seed = int(seed)
parser.add_argument(
"--randomly-seed",
type=int,
@@ -106,7 +103,6 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
"to reuse the seed from the previous run."
"Default behaviour: use random.Random().getrandbits(32), so the seed is"
"different on each run.",
default=default_seed,
)
parser.add_argument(
"--no-capture",

View File

@@ -1,115 +0,0 @@
from unittest.mock import patch
from asgiref.sync import sync_to_async
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.http import HttpRequest
from django.test import TransactionTestCase
from authentik.core.tests.utils import create_test_user
from authentik.events.models import (
Event,
EventAction,
Notification,
NotificationTransport,
TransportMode,
)
from authentik.flows.apps import RefreshOtherFlowsAfterAuthentication
from authentik.lib.generators import generate_id
from authentik.root import websocket
from authentik.stages.password import BACKEND_INBUILT
from authentik.stages.user_login.stage import COOKIE_NAME_KNOWN_DEVICE
from authentik.tenants.utils import get_current_tenant
class TestClientWS(TransactionTestCase):
def setUp(self):
tenant = get_current_tenant()
tenant.flags[RefreshOtherFlowsAfterAuthentication().key] = True
tenant.save()
self.user = create_test_user()
async def _alogin_cookie(self, user, **kwargs):
"""Similar to `client.aforce_login` but allow setting of cookies"""
from django.contrib.auth import alogin
# Create a fake request to store login details.
request = HttpRequest()
session = await self.client.asession()
request.session = session
request.COOKIES.update(kwargs)
await alogin(request, user, BACKEND_INBUILT)
# Save the session values.
await request.session.asave()
self.client._set_login_cookies(request)
async def test_auth_blank(self):
dev_id = generate_id()
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns),
"/ws/client/",
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
await communicator.receive_nothing()
await communicator.receive_json_from()
await communicator.disconnect()
async def test_tab_refresh(self):
dev_id = generate_id()
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns),
"/ws/client/",
headers=[(b"cookie", f"{COOKIE_NAME_KNOWN_DEVICE}={dev_id}".encode())],
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
with patch("authentik.flows.apps.RefreshOtherFlowsAfterAuthentication.get") as flag:
flag.return_value = True
await self._alogin_cookie(self.user, **{COOKIE_NAME_KNOWN_DEVICE: dev_id})
evt = await communicator.receive_json_from()
self.assertEqual(
evt, {"message_type": "session.authenticated", "type": "event.session.authenticated"}
)
await communicator.disconnect()
async def test_notification(self):
communicator = WebsocketCommunicator(
URLRouter(websocket.websocket_urlpatterns), "/ws/client/"
)
communicator.scope["user"] = self.user
connected, _ = await communicator.connect()
self.assertTrue(connected)
transport = await NotificationTransport.objects.acreate(
name=generate_id(), mode=TransportMode.LOCAL
)
event = await sync_to_async(Event.new)(EventAction.LOGIN)
event.set_user(self.user)
await event.asave()
notification = Notification(
user=self.user,
body="foo",
event=event,
hyperlink="goauthentik.io",
hyperlink_label="a link",
)
await sync_to_async(transport.send_local)(notification)
evt = await communicator.receive_json_from(timeout=5)
self.assertEqual(evt["message_type"], "notification.new")
self.assertEqual(evt["id"], str(notification.pk))
self.assertEqual(evt["data"]["pk"], str(notification.pk))
self.assertEqual(evt["data"]["body"], "foo")
self.assertEqual(evt["data"]["event"]["pk"], str(event.pk))
await communicator.disconnect()

View File

@@ -7,7 +7,6 @@ from channels.generic.websocket import JsonWebsocketConsumer
from django.core.cache import cache
from django.db import connection
from authentik.core.models import User
from authentik.root.ws.storage import CACHE_PREFIX
@@ -17,34 +16,24 @@ def build_session_group(session_key: str):
).hexdigest()
def build_device_group(device_id: str):
def build_device_group(session_key: str):
return sha256(
f"{connection.schema_name}/group_client_device_{str(device_id)}".encode()
f"{connection.schema_name}/group_client_device_{str(session_key)}".encode()
).hexdigest()
def build_user_group(user: User):
return sha256(f"{connection.schema_name}/group_client_user_{user.uuid}".encode()).hexdigest()
class MessageConsumer(JsonWebsocketConsumer):
"""Consumer which sends django.contrib.messages Messages over WS.
channel_name is saved into cache with user_id, and when a add_message is called"""
session_key: str
device_cookie: str | None = None
user: User | None = None
def connect(self):
self.accept()
self.session_key = self.scope["session"].session_key
if self.session_key:
cache.set(f"{CACHE_PREFIX}{self.session_key}_messages_{self.channel_name}", True, None)
if user := self.scope.get("user"):
if user.is_authenticated:
async_to_sync(self.channel_layer.group_add)(
build_user_group(user), self.channel_name
)
if device_cookie := self.scope["cookies"].get("authentik_device", None):
self.device_cookie = device_cookie
async_to_sync(self.channel_layer.group_add)(
@@ -58,10 +47,6 @@ class MessageConsumer(JsonWebsocketConsumer):
async_to_sync(self.channel_layer.group_discard)(
build_device_group(self.device_cookie), self.channel_name
)
if self.user:
async_to_sync(self.channel_layer.group_discard)(
build_user_group(self.user), self.channel_name
)
def event_message(self, event: dict):
"""Event handler which is called by Messages Storage backend"""
@@ -69,8 +54,4 @@ class MessageConsumer(JsonWebsocketConsumer):
def event_session_authenticated(self, event: dict):
"""Event handler post user authentication"""
self.send_json({"message_type": "session.authenticated", **event})
def event_notification(self, event: dict):
"""Event handler for new notifications"""
self.send_json({"message_type": "notification.new", **event})
self.send_json({"message_type": "session.authenticated"})

View File

@@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import BaseSerializer, Serializer
from authentik.core.models import Group, PropertyMapping, Source, Token, User
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
class SCIMSource(Source):
@@ -101,7 +101,7 @@ class SCIMSourcePropertyMapping(PropertyMapping):
verbose_name_plural = _("SCIM Source Property Mappings")
class SCIMSourceUser(InternallyManagedMixin, SerializerModel):
class SCIMSourceUser(SerializerModel):
"""Mapping of a user and source to a SCIM user ID"""
id = models.TextField(primary_key=True, default=uuid4)
@@ -127,7 +127,7 @@ class SCIMSourceUser(InternallyManagedMixin, SerializerModel):
return f"SCIM User {self.user_id} to {self.source_id}"
class SCIMSourceGroup(InternallyManagedMixin, SerializerModel):
class SCIMSourceGroup(SerializerModel):
"""Mapping of a group and source to a SCIM user ID"""
id = models.TextField(primary_key=True, default=uuid4)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -11,7 +11,7 @@ from webauthn.helpers.structs import PublicKeyCredentialDescriptor
from authentik.core.types import UserSettingSerializer
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.stages.authenticator.models import Device
UNKNOWN_DEVICE_TYPE_AAGUID = "00000000-0000-0000-0000-000000000000"
@@ -164,7 +164,7 @@ class WebAuthnDevice(SerializerModel, Device):
verbose_name_plural = _("WebAuthn Devices")
class WebAuthnDeviceType(InternallyManagedMixin, SerializerModel):
class WebAuthnDeviceType(SerializerModel):
"""WebAuthn device type, used to restrict which device types are allowed"""
aaguid = models.UUIDField(primary_key=True, unique=True)

View File

@@ -7,7 +7,7 @@ from rest_framework.serializers import BaseSerializer, Serializer
from authentik.core.models import Application, ExpiringModel, User
from authentik.flows.models import Stage
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
@@ -51,7 +51,7 @@ class ConsentStage(Stage):
verbose_name_plural = _("Consent Stages")
class UserConsent(InternallyManagedMixin, SerializerModel, ExpiringModel):
class UserConsent(SerializerModel, ExpiringModel):
"""Consent given by a user for an application"""
user = models.ForeignKey(User, on_delete=models.CASCADE)

View File

@@ -1,9 +1,10 @@
"""Identification stage logic"""
from dataclasses import asdict
from random import SystemRandom
from time import sleep
from typing import Any
from django.contrib.auth.hashers import make_password
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.http import HttpResponse
@@ -160,8 +161,8 @@ class IdentificationChallengeResponse(ChallengeResponse):
op="authentik.stages.identification.validate_invalid_wait",
name="Sleep random time on invalid user identifier",
):
# hash a random password on invalid identifier, same as with a valid identifier
make_password(make_password(None))
# Sleep a random time (between 90 and 210ms) to "prevent" user enumeration attacks
sleep(0.030 * SystemRandom().randint(3, 7))
# Log in a similar format to Event.new(), but we don't want to create an event here
# as this stage is mostly used by unauthenticated users with very high rate limits
self.stage.logger.info(

View File

@@ -10,7 +10,7 @@ from django_dramatiq_postgres.models import TaskBase, TaskState
from authentik.events.logs import LogEvent
from authentik.events.utils import sanitize_item
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.utils.errors import exception_to_dict
from authentik.tenants.models import Tenant
@@ -30,7 +30,7 @@ class TaskStatus(models.TextChoices):
ERROR = "error"
class Task(InternallyManagedMixin, SerializerModel, TaskBase):
class Task(SerializerModel, TaskBase):
tenant = models.ForeignKey(
Tenant,
on_delete=models.CASCADE,
@@ -144,7 +144,7 @@ class Task(InternallyManagedMixin, SerializerModel, TaskBase):
self.log(self.uid, TaskStatus.ERROR, message, **attributes)
class TaskLog(InternallyManagedMixin, models.Model):
class TaskLog(models.Model):
id = models.UUIDField(default=uuid4, primary_key=True, editable=False)
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="tasklogs")

View File

@@ -17,7 +17,7 @@ from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.blueprints.apps import ManagedAppConfig
from authentik.lib.models import InternallyManagedMixin, SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.utils.time import timedelta_string_validator
LOGGER = get_logger()
@@ -41,7 +41,7 @@ def _validate_schema_name(name):
)
class Tenant(InternallyManagedMixin, TenantMixin, SerializerModel):
class Tenant(TenantMixin, SerializerModel):
"""Tenant"""
tenant_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)

View File

@@ -6276,11 +6276,6 @@
],
"format": "date-time",
"title": "Expires"
},
"key": {
"type": "string",
"minLength": 1,
"title": "Key"
}
},
"required": []

61
flake.lock generated
View File

@@ -1,61 +0,0 @@
{
"nodes": {
"futils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"futils": "futils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -1,58 +0,0 @@
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
futils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
futils,
} @ inputs: let
inherit (nixpkgs) lib;
inherit (futils.lib) eachDefaultSystem defaultSystems;
nixpkgsFor = lib.genAttrs defaultSystems (system:
import nixpkgs {
inherit system;
});
in
eachDefaultSystem (system: let
pkgs = nixpkgsFor.${system};
in {
devShell = pkgs.mkShell {
buildInputs = with pkgs; [
clang
cmake
docker-compose
gettext
git
glibc
gnumake
go
golangci-lint
krb5.dev
krb5.out
libclang
libev
libgcc
libtool
libunwind
libuv
libxml2
libxslt
lz4
nodejs_24
openssl
pkg-config
postgresql
postgresql.pg_config
python313
sccache
uv
xmlsec
zlib
];
};
});
}

8
go.mod
View File

@@ -1,6 +1,8 @@
module goauthentik.io
go 1.25.5
go 1.24.3
toolchain go1.24.6
require (
beryju.io/ldap v0.1.0
@@ -19,7 +21,7 @@ require (
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.3
github.com/grafana/pyroscope-go v1.2.7
github.com/jackc/pgx/v5 v5.8.0
github.com/jackc/pgx/v5 v5.7.6
github.com/jellydator/ttlcache/v3 v3.4.0
github.com/mitchellh/mapstructure v1.5.0
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
@@ -30,7 +32,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2026020.6
goauthentik.io/api/v3 v3.2026020.3
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.34.0
golang.org/x/sync v0.19.0

8
go.sum
View File

@@ -117,8 +117,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -214,8 +214,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
goauthentik.io/api/v3 v3.2026020.6 h1:ww545OfZAS0OayLkMQGheR3AsgQ2rc61vhRwSo9dBco=
goauthentik.io/api/v3 v3.2026020.6/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
goauthentik.io/api/v3 v3.2026020.3 h1:CKtPyAQToPT2yF5odTTc+IfPLhYeVX9FbLMeVnFgZps=
goauthentik.io/api/v3 v3.2026020.3/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=

View File

@@ -1,39 +1,25 @@
package utils
import (
"crypto/tls"
"slices"
)
import "crypto/tls"
func GetTLSConfig() *tls.Config {
// Based on
// https://ssl-config.mozilla.org/#server=go&version=1.25&config=intermediate&guideline=5.7
tlsConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{
tls.X25519,
tls.CurveP256,
tls.CurveP384,
},
PreferServerCipherSuites: true,
CipherSuites: []uint16{},
MaxVersion: tls.VersionTLS12,
}
excludedCiphers := []uint16{
// ChaCha20 is not FIPS validated
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
// Insecure SWEET32 attack ciphers, TLS config uses a fallback
// Insecure SWEET32 attack ciphers, TLS config uses a fallback
insecureCiphersIds := []uint16{
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,
tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
}
defaultSecureCiphers := []uint16{}
for _, cs := range tls.CipherSuites() {
if slices.Contains(excludedCiphers, cs.ID) {
continue
for _, icsId := range insecureCiphersIds {
if cs.ID != icsId {
defaultSecureCiphers = append(defaultSecureCiphers, cs.ID)
}
}
defaultSecureCiphers = append(defaultSecureCiphers, cs.ID)
}
tlsConfig.CipherSuites = defaultSecureCiphers
return tlsConfig

View File

@@ -73,7 +73,6 @@ def release_lock(conn: Connection, cursor: Cursor):
def run_migrations():
conn_opts = CONFIG.get_dict_from_b64_json("postgresql.conn_options", default={})
conn = connect(
dbname=CONFIG.get("postgresql.name"),
user=CONFIG.get("postgresql.user"),
@@ -84,7 +83,6 @@ def run_migrations():
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
sslcert=CONFIG.get("postgresql.sslcert"),
sslkey=CONFIG.get("postgresql.sslkey"),
**conn_opts,
)
curr = conn.cursor()
try:

View File

@@ -18,7 +18,6 @@ def check_postgres():
if attempt >= CHECK_THRESHOLD:
sysexit(1)
try:
conn_opts = CONFIG.get_dict_from_b64_json("postgresql.conn_options", default={})
conn = connect(
dbname=CONFIG.refresh("postgresql.name"),
user=CONFIG.refresh("postgresql.user"),
@@ -29,7 +28,6 @@ def check_postgres():
sslrootcert=CONFIG.get("postgresql.sslrootcert"),
sslcert=CONFIG.get("postgresql.sslcert"),
sslkey=CONFIG.get("postgresql.sslkey"),
**conn_opts,
)
conn.cursor()
break

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-28 00:13+0000\n"
"POT-Creation-Date: 2025-12-17 00:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -712,10 +712,6 @@ msgstr ""
msgid "Enterprise is required to create/update this object."
msgstr ""
#: authentik/enterprise/api.py
msgid "Enterprise is required to use this endpoint."
msgstr ""
#: authentik/enterprise/models.py
msgid "License"
msgstr ""

View File

@@ -1,18 +1,27 @@
"""Convenient shortcuts to manage or check object permissions."""
from functools import lru_cache
from functools import lru_cache, partial
from typing import Any, TypeVar
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.db import connection
from django.db.models import (
AutoField,
BigIntegerField,
CharField,
Count,
ForeignKey,
IntegerField,
Model,
PositiveIntegerField,
PositiveSmallIntegerField,
QuerySet,
SmallIntegerField,
UUIDField,
)
from django.db.models.expressions import RawSQL
from django.db.models.expressions import Value
from django.db.models.functions import Cast, Replace
from guardian.core import ObjectPermissionChecker
from guardian.ctypes import get_content_type
@@ -286,33 +295,42 @@ def get_objects_for_user( # noqa: PLR0912 PLR0915
.filter(object_pk_count__gte=len(codenames))
)
# pk is either UUID or an integer type, while object_pk is a varchar
# object_pk is a varchar, while the queryset's pk is probably an integer or a uuid, so we cast
handle_pk_field = _handle_pk_field(queryset)
if handle_pk_field is not None:
perms_queryset = perms_queryset.annotate(obj_pk=handle_pk_field(expression=pk_field))
pk_field = "obj_pk"
return queryset.filter(pk__in=perms_queryset.values_list(pk_field, flat=True))
def _handle_pk_field(queryset):
pk = queryset.model._meta.pk
def _cast_type(pk):
if isinstance(pk, ForeignKey):
return _cast_type(pk.target_field)
if isinstance(pk, UUIDField):
return "uuid"
return "bigint"
if isinstance(pk, ForeignKey):
return _handle_pk_field(pk.target_field)
cast_type = _cast_type(pk)
if isinstance( # noqa: UP038
pk,
(
IntegerField,
AutoField,
BigIntegerField,
PositiveIntegerField,
PositiveSmallIntegerField,
SmallIntegerField,
),
):
return partial(Cast, output_field=BigIntegerField())
perms_queryset = perms_queryset.values_list(pk_field, flat=True)
# The raw subquery is done to ensure that casting only takes place after the WHERE clause of
# `perms_queryset` is ran. Otherwise, the query planner may decide to cast every `object_pk`,
# which breaks (for example) if it tries to cast an integer to a UUID. In such a case, the WHERE
# of `perms_queryset` will remove any integer.
# However, the subquery might get optimized out by the query planner, which would cause the same
# cast issue as before. To prevent the subquery from being collapsed in the query below, we add
# OFFSET 0.
perms_subquery_sql, perms_subquery_params = perms_queryset.query.sql_with_params()
subquery = RawSQL(
f"""
SELECT ("permission_subquery"."{pk_field}")::{cast_type} as "object_pk"
FROM ({perms_subquery_sql}) "permission_subquery"
OFFSET 0
""", # nosec
perms_subquery_params,
)
return queryset.filter(pk__in=subquery)
if isinstance(pk, UUIDField):
if connection.features.has_native_uuid_field:
return partial(Cast, output_field=UUIDField())
return partial(
Replace,
text=Value("-"),
replacement=Value(""),
output_field=CharField(),
)
return None

View File

@@ -16336,9 +16336,9 @@
}
},
"node_modules/qs": {
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -284,7 +284,6 @@ module = [
"lifecycle.*",
"tests.e2e.*",
"tests.integration.*",
"tests.openid_conformance.*",
]
ignore_errors = true

View File

@@ -26211,7 +26211,7 @@ paths:
tags:
- stages
security:
- {}
- authentik: []
responses:
'200':
content:
@@ -34792,7 +34792,6 @@ components:
CapabilitiesEnum:
enum:
- can_save_media
- can_save_reports
- can_geo_ip
- can_asn
- can_impersonate
@@ -35388,14 +35387,10 @@ components:
model:
type: string
readOnly: true
verbose_name_plural:
type: string
readOnly: true
required:
- app_label
- id
- model
- verbose_name_plural
ContextualFlowInfo:
type: object
description: Contextual flow information for a challenge
@@ -35743,7 +35738,7 @@ components:
readOnly: true
requested_by:
allOf:
- $ref: '#/components/schemas/PartialUser'
- $ref: '#/components/schemas/RequestedBy'
readOnly: true
requested_on:
type: string
@@ -51257,6 +51252,22 @@ components:
minimum: -2147483648
required:
- name
RequestedBy:
type: object
properties:
pk:
type: integer
readOnly: true
title: ID
username:
type: string
description: Required. 150 characters or fewer. Letters, digits and @/./+/-/_
only.
pattern: ^[\w.@+-]+$
maxLength: 150
required:
- pk
- username
ResidentKeyRequirementEnum:
enum:
- discouraged

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