mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 07:02:51 +02:00
Compare commits
2 Commits
devcontain
...
remove-bas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d1d1597f | ||
|
|
c9427cd6c1 |
@@ -1,63 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Start from the same FIPS Python base as production (python-base stage)
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff
|
||||
|
||||
USER root
|
||||
|
||||
# Setup environment matching production python-base stage
|
||||
ENV VENV_PATH="/ak-root/.venv" \
|
||||
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_NATIVE_TLS=1 \
|
||||
UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
WORKDIR /ak-root
|
||||
|
||||
# Copy uv package manager
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.7@sha256:ba4857bf2a068e9bc0e64eed8563b065908a4cd6bfb66b531a9c424c8e25e142 /uv /uvx /bin/
|
||||
|
||||
# Install build dependencies
|
||||
RUN rm -f /etc/apt/apt.conf.d/docker-clean && \
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
# Build essentials
|
||||
build-essential pkg-config libffi-dev git binutils \
|
||||
# cryptography
|
||||
curl \
|
||||
# libxml
|
||||
libxslt-dev zlib1g-dev \
|
||||
# postgresql
|
||||
libpq-dev \
|
||||
# python-kadmin-rs and kerberos testing
|
||||
clang libkrb5-dev sccache krb5-kdc krb5-admin-server \
|
||||
# xmlsec
|
||||
libltdl-dev \
|
||||
# runit (for chpst command used by lifecycle/ak)
|
||||
runit \
|
||||
# sudo (required by devcontainer features)
|
||||
sudo && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Environment for building native Python packages
|
||||
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec" \
|
||||
RUSTUP_PERMIT_COPY_RENAME="true"
|
||||
|
||||
# Create authentik user with proper home directory (required for devcontainer features)
|
||||
RUN adduser --disabled-password --gecos "" --uid 1000 --home /home/authentik authentik && \
|
||||
mkdir -p /certs /media /ak-root && \
|
||||
chown -R authentik:authentik /certs /media /ak-root /home/authentik && \
|
||||
echo "authentik ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/authentik
|
||||
|
||||
# FIPS configuration for Go development
|
||||
# Don't set GOFIPS/GOFIPS140 globally to avoid breaking Go tools like docker-compose
|
||||
# These will be set when building/running authentik Go code (see lifecycle/ak and Makefile)
|
||||
ENV CGO_ENABLED=1
|
||||
|
||||
# Set TMPDIR for PID files and temp data
|
||||
# Use /tmp instead of /dev/shm for development because go run needs to execute binaries
|
||||
ENV TMPDIR=/tmp
|
||||
|
||||
USER authentik
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"name": "authentik",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/ak-root",
|
||||
"containerUser": "authentik",
|
||||
"remoteUser": "authentik",
|
||||
"shutdownAction": "stopCompose",
|
||||
"containerEnv": {
|
||||
"LOCAL_PROJECT_DIR": "/ak-root"
|
||||
},
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/go:1": {
|
||||
"version": "1.24"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "24"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/rust:1": {
|
||||
"version": "latest"
|
||||
},
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"version": "latest",
|
||||
"moby": false
|
||||
}
|
||||
},
|
||||
"mounts": [],
|
||||
"forwardPorts": [9000, 9443],
|
||||
"portsAttributes": {
|
||||
"8000": {
|
||||
"onAutoForward": "ignore"
|
||||
},
|
||||
"3963": {
|
||||
"onAutoForward": "ignore"
|
||||
},
|
||||
"35151": {
|
||||
"onAutoForward": "ignore"
|
||||
},
|
||||
"9901": {
|
||||
"onAutoForward": "ignore"
|
||||
}
|
||||
},
|
||||
"postCreateCommand": "bash .devcontainer/setup.sh",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"bashmish.es6-string-css",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"golang.go",
|
||||
"Gruntfuggly.todo-tree",
|
||||
"ms-python.black-formatter",
|
||||
"ms-python.isort",
|
||||
"ms-python.pylint",
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"redhat.vscode-yaml",
|
||||
"Tobermory.es6-string-html",
|
||||
"charliermarsh.ruff"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "/ak-root/.venv/bin/python",
|
||||
"python.terminal.activateEnvironment": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
user: authentik
|
||||
privileged: true
|
||||
volumes:
|
||||
- ../:/ak-root
|
||||
entrypoint: []
|
||||
command: sleep infinity
|
||||
depends_on:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
PATH: "/ak-root/.venv/bin:${PATH}"
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9443:9443"
|
||||
|
||||
postgresql:
|
||||
image: docker.io/library/postgres:16
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d authentik -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
env_file: .env
|
||||
command: ["postgres", "-c", "log_statement=all", "-c", "log_destination=stderr"]
|
||||
|
||||
s3:
|
||||
image: docker.io/zenko/cloudserver
|
||||
env_file: .env
|
||||
environment:
|
||||
REMOTE_MANAGEMENT_DISABLE: "1"
|
||||
ports:
|
||||
- "8020:8000"
|
||||
volumes:
|
||||
- s3-data:/usr/src/app/localData
|
||||
- s3-metadata:/usr/src/app/localMetadata
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
s3-data:
|
||||
s3-metadata:
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
echo "======================================"
|
||||
echo "Running authentik devcontainer setup"
|
||||
echo "======================================"
|
||||
|
||||
echo ""
|
||||
echo "Step 1/5: Installing dependencies"
|
||||
make install
|
||||
|
||||
echo ""
|
||||
echo "Step 2/5: Generating development config"
|
||||
make gen-dev-config
|
||||
|
||||
echo ""
|
||||
echo "Step 3/5: Running database migrations"
|
||||
make migrate
|
||||
|
||||
echo ""
|
||||
echo "Step 4/5: Generating API clients"
|
||||
make gen
|
||||
|
||||
echo ""
|
||||
echo "Step 5/5: Building web assets"
|
||||
make web
|
||||
|
||||
echo ""
|
||||
echo "======================================"
|
||||
echo "Setup complete!"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
echo "You can now run:"
|
||||
echo " - 'make run-server' to start the backend server"
|
||||
echo " - 'make run-worker' to start the worker (must be ran once after initial setup)"
|
||||
echo " - 'make web-watch' for live web development"
|
||||
echo ""
|
||||
2
.github/actions/test-results/action.yml
vendored
2
.github/actions/test-results/action.yml
vendored
@@ -8,7 +8,7 @@ inputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
- uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
|
||||
with:
|
||||
flags: ${{ inputs.flags }}
|
||||
use_oidc: true
|
||||
|
||||
4
.github/pull_request_template.md
vendored
4
.github/pull_request_template.md
vendored
@@ -2,10 +2,6 @@
|
||||
👋 Hi there! Welcome.
|
||||
|
||||
Please check the Contributing guidelines: https://docs.goauthentik.io/docs/developer-docs/#how-can-i-contribute
|
||||
|
||||
⚠️ IMPORTANT: Make sure you are opening this PR from a FEATURE BRANCH, not from your main branch!
|
||||
If you opened this PR from your main branch, please close it and create a new feature branch instead.
|
||||
For more information, see: https://docs.goauthentik.io/developer-docs/contributing/#always-use-feature-branches
|
||||
-->
|
||||
|
||||
## Details
|
||||
|
||||
@@ -73,12 +73,14 @@ jobs:
|
||||
mkdir -p ./gen-ts-api
|
||||
mkdir -p ./gen-go-api
|
||||
- name: Setup node
|
||||
if: ${{ !inputs.release }}
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v5
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: generate ts client
|
||||
if: ${{ !inputs.release }}
|
||||
run: make gen-client-ts
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
|
||||
2
.github/workflows/api-ts-publish.yml
vendored
2
.github/workflows/api-ts-publish.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
run: |
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
- uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
2
.github/workflows/ci-api-docs.yml
vendored
2
.github/workflows/ci-api-docs.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
- working-directory: website/
|
||||
name: Install Dependencies
|
||||
run: npm ci
|
||||
- uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
|
||||
- uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/website/api/.docusaurus
|
||||
|
||||
2
.github/workflows/ci-main.yml
vendored
2
.github/workflows/ci-main.yml
vendored
@@ -201,7 +201,7 @@ jobs:
|
||||
run: |
|
||||
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
|
||||
2
.github/workflows/gen-image-compress.yml
vendored
2
.github/workflows/gen-image-compress.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
compressOnly: ${{ github.event_name != 'pull_request' }}
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
- uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
if: "${{ github.event_name != 'pull_request' && steps.compress.outputs.markdown != '' }}"
|
||||
id: cpr
|
||||
with:
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- run: uv run ak update_webauthn_mds
|
||||
- uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
- uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
|
||||
2
.github/workflows/packages-npm-publish.yml
vendored
2
.github/workflows/packages-npm-publish.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
with:
|
||||
files: |
|
||||
${{ matrix.package }}/package.json
|
||||
|
||||
2
.github/workflows/release-branch-off.yml
vendored
2
.github/workflows/release-branch-off.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.next_version }}.0-rc1"
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: release-bump-${{ inputs.next_version }}
|
||||
|
||||
4
.github/workflows/release-tag.yml
vendored
4
.github/workflows/release-tag.yml
vendored
@@ -130,7 +130,7 @@ jobs:
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
make web-check-compile
|
||||
- name: Create Pull Request
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v7
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: extract-compile-backend-translation
|
||||
|
||||
@@ -28,10 +28,8 @@ packages/django-channels-postgres @goauthentik/backend
|
||||
packages/django-postgres-cache @goauthentik/backend
|
||||
packages/django-dramatiq-postgres @goauthentik/backend
|
||||
# Web packages
|
||||
package.json @goauthentik/frontend
|
||||
package-lock.json @goauthentik/frontend
|
||||
packages/package.json @goauthentik/frontend
|
||||
packages/package-lock.json @goauthentik/frontend
|
||||
packages/package.json @goauthentik/backend @goauthentik/frontend
|
||||
packages/package-lock.json @goauthentik/backend @goauthentik/frontend
|
||||
packages/docusaurus-config @goauthentik/frontend
|
||||
packages/esbuild-plugin-live-reload @goauthentik/frontend
|
||||
packages/eslint-config @goauthentik/frontend
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS go-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:4f9d98ebaa759f776496d850e0439c48948d587b191fc3949b5f5e4667abef90 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -76,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.16@sha256:ae9ff79d095a61faf534a882ad6378e8159d2ce322691153d68d2afac7422840 AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
|
||||
|
||||
|
||||
2
Makefile
2
Makefile
@@ -46,7 +46,7 @@ help: ## Show this help
|
||||
@echo ""
|
||||
|
||||
go-test:
|
||||
GOFIPS140=latest CGO_ENABLED=1 go test -timeout 0 -v -race -cover ./...
|
||||
go test -timeout 0 -v -race -cover ./...
|
||||
|
||||
test: ## Run the server tests and produce a coverage report (locally)
|
||||
$(KRB_PATH) uv run coverage run manage.py test --keepdb $(or $(filter-out $@,$(MAKECMDGOALS)),authentik)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2026.2.0-rc1"
|
||||
VERSION = "2025.12.0-rc1"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class AuthentikFilesConfig(ManagedAppConfig):
|
||||
@@ -11,20 +6,3 @@ class AuthentikFilesConfig(ManagedAppConfig):
|
||||
label = "authentik_admin_files"
|
||||
verbose_name = "authentik Files"
|
||||
default = True
|
||||
|
||||
@ManagedAppConfig.reconcile_global
|
||||
def check_for_media_mount(self):
|
||||
if settings.TEST:
|
||||
return
|
||||
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
if (
|
||||
CONFIG.get("storage.media.backend", CONFIG.get("storage.backend", "file")) == "file"
|
||||
and Path("/media").exists()
|
||||
):
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="/media has been moved to /data/media. "
|
||||
"Check the release notes for migration steps.",
|
||||
).save()
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
from collections.abc import Callable, Generator, Iterator
|
||||
from typing import cast
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http.request import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
|
||||
CACHE_PREFIX = "goauthentik.io/admin/files"
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@@ -56,19 +53,13 @@ class Backend:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""
|
||||
Get URL for accessing the file.
|
||||
|
||||
Args:
|
||||
file_path: Relative file path
|
||||
request: Optional Django HttpRequest for fully qualifed URL building
|
||||
use_cache: whether to retrieve the URL from cache
|
||||
|
||||
Returns:
|
||||
URL to access the file (may be relative or absolute depending on backend)
|
||||
@@ -141,22 +132,3 @@ class ManageableBackend(Backend):
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _cache_get_or_set(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None,
|
||||
default: Callable[[str, HttpRequest | None], str],
|
||||
timeout: int,
|
||||
) -> str:
|
||||
timeout_ignore = 60
|
||||
timeout = int(timeout * 0.67)
|
||||
if timeout < timeout_ignore:
|
||||
timeout = 0
|
||||
|
||||
request_key = "None"
|
||||
if request is not None:
|
||||
request_key = f"{request.build_absolute_uri('/')}"
|
||||
cache_key = f"{CACHE_PREFIX}/{self.name}/{self.usage}/{request_key}/{name}"
|
||||
|
||||
return cast(str, cache.get_or_set(cache_key, lambda: default(name, request), timeout))
|
||||
|
||||
@@ -63,12 +63,7 @@ class FileBackend(ManageableBackend):
|
||||
rel_path = full_path.relative_to(self.base_path)
|
||||
yield str(rel_path)
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Get URL for accessing the file."""
|
||||
expires_in = timedelta_from_string(
|
||||
CONFIG.get(
|
||||
@@ -77,28 +72,21 @@ class FileBackend(ManageableBackend):
|
||||
)
|
||||
)
|
||||
|
||||
def _file_url(name: str, request: HttpRequest | None) -> str:
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
path = f"{self.usage.value}/{connection.schema_name}/{name}"
|
||||
token = jwt.encode(
|
||||
payload={
|
||||
"path": path,
|
||||
"exp": now() + expires_in,
|
||||
"nbf": now() - timedelta(seconds=15),
|
||||
},
|
||||
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
url = f"{prefix}/files/{path}?token={token}"
|
||||
if request is None:
|
||||
return url
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
if use_cache:
|
||||
timeout = int(expires_in.total_seconds())
|
||||
return self._cache_get_or_set(name, request, _file_url, timeout)
|
||||
else:
|
||||
return _file_url(name, request)
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
path = f"{self.usage.value}/{connection.schema_name}/{name}"
|
||||
token = jwt.encode(
|
||||
payload={
|
||||
"path": path,
|
||||
"exp": now() + expires_in,
|
||||
"nbf": now() - timedelta(seconds=15),
|
||||
},
|
||||
key=sha256(f"{settings.SECRET_KEY}:{self.usage}".encode()).hexdigest(),
|
||||
algorithm="HS256",
|
||||
)
|
||||
url = f"{prefix}/files/{path}?token={token}"
|
||||
if request is None:
|
||||
return url
|
||||
return request.build_absolute_uri(url)
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""Save file to local filesystem."""
|
||||
|
||||
@@ -38,11 +38,6 @@ class PassthroughBackend(Backend):
|
||||
"""External files cannot be listed."""
|
||||
yield from []
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Return the URL as-is for passthrough files."""
|
||||
return name
|
||||
|
||||
@@ -130,57 +130,44 @@ class S3Backend(ManageableBackend):
|
||||
if rel_path: # Skip if it's just the directory itself
|
||||
yield rel_path
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Generate presigned URL for file access."""
|
||||
use_https = CONFIG.get_bool(
|
||||
f"storage.{self.usage.value}.{self.name}.secure_urls",
|
||||
CONFIG.get_bool(f"storage.{self.name}.secure_urls", True),
|
||||
)
|
||||
|
||||
expires_in = int(
|
||||
timedelta_from_string(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.url_expiry",
|
||||
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
|
||||
)
|
||||
).total_seconds()
|
||||
params = {
|
||||
"Bucket": self.bucket_name,
|
||||
"Key": f"{self.base_path}/{name}",
|
||||
}
|
||||
|
||||
expires_in = timedelta_from_string(
|
||||
CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.url_expiry",
|
||||
CONFIG.get(f"storage.{self.name}.url_expiry", "minutes=15"),
|
||||
)
|
||||
)
|
||||
|
||||
def _file_url(name: str, request: HttpRequest | None) -> str:
|
||||
params = {
|
||||
"Bucket": self.bucket_name,
|
||||
"Key": f"{self.base_path}/{name}",
|
||||
}
|
||||
url = self.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=expires_in.total_seconds(),
|
||||
HttpMethod="GET",
|
||||
)
|
||||
|
||||
url = self.client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params=params,
|
||||
ExpiresIn=expires_in,
|
||||
HttpMethod="GET",
|
||||
)
|
||||
# Support custom domain for S3-compatible storage (so not AWS)
|
||||
# Well, can't you do custom domains on AWS as well?
|
||||
custom_domain = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.custom_domain",
|
||||
CONFIG.get(f"storage.{self.name}.custom_domain", None),
|
||||
)
|
||||
if custom_domain:
|
||||
parsed = urlsplit(url)
|
||||
scheme = "https" if use_https else "http"
|
||||
url = f"{scheme}://{custom_domain}{parsed.path}?{parsed.query}"
|
||||
|
||||
# Support custom domain for S3-compatible storage (so not AWS)
|
||||
# Well, can't you do custom domains on AWS as well?
|
||||
custom_domain = CONFIG.get(
|
||||
f"storage.{self.usage.value}.{self.name}.custom_domain",
|
||||
CONFIG.get(f"storage.{self.name}.custom_domain", None),
|
||||
)
|
||||
if custom_domain:
|
||||
parsed = urlsplit(url)
|
||||
scheme = "https" if use_https else "http"
|
||||
url = f"{scheme}://{custom_domain}{parsed.path}?{parsed.query}"
|
||||
|
||||
return url
|
||||
|
||||
if use_cache:
|
||||
return self._cache_get_or_set(name, request, _file_url, expires_in)
|
||||
else:
|
||||
return _file_url(name, request)
|
||||
return url
|
||||
|
||||
def save_file(self, name: str, content: bytes) -> None:
|
||||
"""Save file to S3."""
|
||||
|
||||
@@ -44,12 +44,7 @@ class StaticBackend(Backend):
|
||||
if file_path.is_file() and (file_path.suffix in STATIC_FILE_EXTENSIONS):
|
||||
yield f"{STATIC_PATH_PREFIX}/dist/{dir}/{file_path.name}"
|
||||
|
||||
def file_url(
|
||||
self,
|
||||
name: str,
|
||||
request: HttpRequest | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
def file_url(self, name: str, request: HttpRequest | None = None) -> str:
|
||||
"""Get URL for static file."""
|
||||
prefix = CONFIG.get("web.path", "/")[:-1]
|
||||
url = f"{prefix}{name}"
|
||||
|
||||
@@ -70,7 +70,6 @@ class FileManager:
|
||||
self,
|
||||
name: str | None,
|
||||
request: HttpRequest | Request | None = None,
|
||||
use_cache: bool = True,
|
||||
) -> str:
|
||||
"""
|
||||
Get URL for accessing the file.
|
||||
|
||||
@@ -13,11 +13,6 @@ class Pagination(pagination.PageNumberPagination):
|
||||
page_query_param = "page"
|
||||
page_size_query_param = "page_size"
|
||||
|
||||
def get_page_size(self, request):
|
||||
if self.page_size_query_param in request.query_params:
|
||||
return min(super().get_page_size(request), request.tenant.pagination_max_page_size)
|
||||
return request.tenant.pagination_default_page_size
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
previous_page_number = 0
|
||||
if self.page.has_previous():
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""authentik Blueprints app"""
|
||||
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from importlib import import_module
|
||||
from inspect import ismethod
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||
from dramatiq.broker import get_broker
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
@@ -46,21 +44,8 @@ class ManagedAppConfig(AppConfig):
|
||||
module_name = f"{self.name}.{rel_module}"
|
||||
import_module(module_name)
|
||||
self.logger.info("Imported related module", module=module_name)
|
||||
except ModuleNotFoundError as exc:
|
||||
if settings.DEBUG:
|
||||
# This is a heuristic for determining whether the exception was caused
|
||||
# "directly" by the `import_module` call or whether the initial import
|
||||
# succeeded and a later import (within the existing module) failed.
|
||||
# 1. <the calling function>
|
||||
# 2. importlib.import_module
|
||||
# 3. importlib._bootstrap._gcd_import
|
||||
# 4. importlib._bootstrap._find_and_load
|
||||
# 5. importlib._bootstrap._find_and_load_unlocked
|
||||
STACK_LENGTH_HEURISTIC = 5
|
||||
|
||||
stack_length = len(traceback.extract_tb(exc.__traceback__))
|
||||
if stack_length > STACK_LENGTH_HEURISTIC:
|
||||
raise
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
import_relative("checks")
|
||||
import_relative("tasks")
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
from rest_framework.fields import Field, JSONField, UUIDField
|
||||
from rest_framework.relations import PrimaryKeyRelatedField
|
||||
@@ -33,8 +32,6 @@ class PrimaryKeyRelatedFieldConverter:
|
||||
def convert(self, field: PrimaryKeyRelatedField):
|
||||
model: Model = field.queryset.model
|
||||
pk_field = model._meta.pk
|
||||
if isinstance(pk_field, OneToOneField):
|
||||
pk_field = pk_field.related_fields[0][1]
|
||||
if isinstance(pk_field, fields.UUIDField):
|
||||
return {"type": "string", "format": "uuid"}
|
||||
return {"type": "integer"}
|
||||
|
||||
@@ -4,8 +4,7 @@ from collections.abc import Iterator
|
||||
from copy import copy
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Case, QuerySet
|
||||
from django.db.models.expressions import When
|
||||
from django.db.models import QuerySet
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
@@ -23,7 +22,6 @@ from authentik.api.pagination import Pagination
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.providers import ProviderSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import Application, User
|
||||
from authentik.events.logs import LogEventSerializer, capture_logs
|
||||
@@ -57,21 +55,9 @@ class ApplicationSerializer(ModelSerializer):
|
||||
def get_launch_url(self, app: Application) -> str | None:
|
||||
"""Allow formatting of launch URL"""
|
||||
user = None
|
||||
user_data = None
|
||||
|
||||
if "request" in self.context:
|
||||
user = self.context["request"].user
|
||||
|
||||
# Cache serialized user data to avoid N+1 when formatting launch URLs
|
||||
# for multiple applications. UserSerializer accesses user.ak_groups which
|
||||
# would otherwise trigger a query for each application.
|
||||
if user is not None:
|
||||
if "_cached_user_data" not in self.context:
|
||||
# Prefetch groups to avoid N+1
|
||||
self.context["_cached_user_data"] = UserSerializer(instance=user).data
|
||||
user_data = self.context["_cached_user_data"]
|
||||
|
||||
return app.get_launch_url(user, user_data=user_data)
|
||||
return app.get_launch_url(user)
|
||||
|
||||
def validate_slug(self, slug: str) -> str:
|
||||
if slug in Application.reserved_slugs:
|
||||
@@ -164,26 +150,11 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
applications.append(application)
|
||||
return applications
|
||||
|
||||
def _expand_applications(self, applications: list[Application]) -> QuerySet[Application]:
|
||||
"""
|
||||
Re-fetch with proper prefetching for serialization
|
||||
Cached applications don't have prefetched relationships, causing N+1 queries
|
||||
during serialization when get_provider() is called
|
||||
"""
|
||||
if not applications:
|
||||
return self.get_queryset().none()
|
||||
pks = [app.pk for app in applications]
|
||||
return (
|
||||
self.get_queryset()
|
||||
.filter(pk__in=pks)
|
||||
.order_by(Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(pks)]))
|
||||
)
|
||||
|
||||
def _filter_applications_with_launch_url(
|
||||
self, applications: QuerySet[Application]
|
||||
self, paginated_apps: Iterator[Application]
|
||||
) -> list[Application]:
|
||||
applications = []
|
||||
for app in applications:
|
||||
for app in paginated_apps:
|
||||
if app.get_launch_url():
|
||||
applications.append(app)
|
||||
return applications
|
||||
@@ -283,8 +254,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
except ValueError as exc:
|
||||
raise ValidationError from exc
|
||||
allowed_applications = self._get_allowed_applications(paginated_apps, user=for_user)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
serializer = self.get_serializer(allowed_applications, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
@@ -303,7 +272,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
allowed_applications,
|
||||
timeout=86400,
|
||||
)
|
||||
allowed_applications = self._expand_applications(allowed_applications)
|
||||
|
||||
if only_with_launch_url == "true":
|
||||
allowed_applications = self._filter_applications_with_launch_url(allowed_applications)
|
||||
|
||||
@@ -86,7 +86,6 @@ from authentik.flows.models import FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||
from authentik.lib.avatars import get_avatar
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
from authentik.rbac.api.roles import RoleSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.models import Role, get_permission_choices
|
||||
@@ -485,11 +484,7 @@ class UsersFilter(FilterSet):
|
||||
]
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"),
|
||||
UsedByMixin,
|
||||
ModelViewSet,
|
||||
):
|
||||
class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
"""User Viewset"""
|
||||
|
||||
queryset = User.objects.none()
|
||||
|
||||
@@ -152,7 +152,7 @@ class AttributesMixin(models.Model):
|
||||
@classmethod
|
||||
def update_or_create_attributes(
|
||||
cls, query: dict[str, Any], properties: dict[str, Any]
|
||||
) -> tuple[Self, bool]:
|
||||
) -> tuple[models.Model, bool]:
|
||||
"""Same as django's update_or_create but correctly updates attributes by merging dicts"""
|
||||
instance = cls.objects.filter(**query).first()
|
||||
if not instance:
|
||||
@@ -658,10 +658,6 @@ class ApplicationQuerySet(QuerySet):
|
||||
qs = self.select_related("provider")
|
||||
for subclass in Provider.objects.get_queryset()._get_subclasses_recurse(Provider):
|
||||
qs = qs.select_related(f"provider__{subclass}")
|
||||
# Also prefetch/select through each subclass path to ensure casted instances have access
|
||||
qs = qs.prefetch_related(f"provider__{subclass}__property_mappings")
|
||||
qs = qs.select_related(f"provider__{subclass}__application")
|
||||
qs = qs.select_related(f"provider__{subclass}__backchannel_application")
|
||||
return qs
|
||||
|
||||
|
||||
@@ -713,15 +709,8 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
|
||||
return get_file_manager(FileUsage.MEDIA).file_url(self.meta_icon)
|
||||
|
||||
def get_launch_url(
|
||||
self, user: Optional["User"] = None, user_data: dict | None = None
|
||||
) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.
|
||||
|
||||
Args:
|
||||
user: User instance for formatting the URL
|
||||
user_data: Pre-serialized user data to avoid re-serialization (performance optimization)
|
||||
"""
|
||||
def get_launch_url(self, user: Optional["User"] = None) -> str | None:
|
||||
"""Get launch URL if set, otherwise attempt to get launch URL based on provider."""
|
||||
from authentik.core.api.users import UserSerializer
|
||||
|
||||
url = None
|
||||
@@ -731,10 +720,7 @@ class Application(SerializerModel, PolicyBindingModel):
|
||||
url = provider.launch_url
|
||||
if user and url:
|
||||
try:
|
||||
# Use pre-serialized data if available, otherwise serialize now
|
||||
if user_data is None:
|
||||
user_data = UserSerializer(instance=user).data
|
||||
return url % user_data
|
||||
return url % UserSerializer(instance=user).data
|
||||
except Exception as exc: # noqa
|
||||
LOGGER.warning("Failed to format launch url", exc=exc)
|
||||
return url
|
||||
|
||||
@@ -34,12 +34,18 @@ class SessionStore(SessionBase):
|
||||
|
||||
def _get_session_from_db(self):
|
||||
try:
|
||||
return self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
).get(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
return (
|
||||
self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
)
|
||||
.prefetch_related(
|
||||
"authenticatedsession__user__groups",
|
||||
)
|
||||
.get(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
)
|
||||
)
|
||||
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
|
||||
if isinstance(exc, SuspiciousOperation):
|
||||
@@ -48,12 +54,18 @@ class SessionStore(SessionBase):
|
||||
|
||||
async def _aget_session_from_db(self):
|
||||
try:
|
||||
return await self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
).aget(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
return (
|
||||
await self.model.objects.select_related(
|
||||
"authenticatedsession",
|
||||
"authenticatedsession__user",
|
||||
)
|
||||
.prefetch_related(
|
||||
"authenticatedsession__user__groups",
|
||||
)
|
||||
.aget(
|
||||
session_key=self.session_key,
|
||||
expires__gt=timezone.now(),
|
||||
)
|
||||
)
|
||||
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
|
||||
if isinstance(exc, SuspiciousOperation):
|
||||
|
||||
@@ -39,7 +39,7 @@ def source_tester_factory(test_model: type[Source]) -> Callable:
|
||||
def tester(self: TestModels):
|
||||
model_class = None
|
||||
if test_model._meta.abstract:
|
||||
return
|
||||
model_class = [x for x in test_model.__bases__ if issubclass(x, Source)][0]()
|
||||
else:
|
||||
model_class = test_model()
|
||||
model_class.slug = "test"
|
||||
|
||||
@@ -175,7 +175,7 @@ class Connector(ScheduledModel, SerializerModel):
|
||||
]
|
||||
|
||||
|
||||
class DeviceAccessGroup(SerializerModel, PolicyBindingModel):
|
||||
class DeviceAccessGroup(PolicyBindingModel):
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import QuerySet
|
||||
from django.urls import reverse
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField
|
||||
from rest_framework.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.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)
|
||||
|
||||
class Meta:
|
||||
model = ContentType
|
||||
fields = ("id", "app_label", "model")
|
||||
|
||||
|
||||
class DataExportSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
requested_by = RequestedBySerializer(read_only=True)
|
||||
content_type = ContentTypeSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DataExport
|
||||
fields = (
|
||||
"id",
|
||||
"requested_by",
|
||||
"requested_on",
|
||||
"content_type",
|
||||
"query_params",
|
||||
"file_url",
|
||||
"completed",
|
||||
)
|
||||
read_only_fields = (
|
||||
"id",
|
||||
"requested_by",
|
||||
"requested_on",
|
||||
"content_type",
|
||||
"file_url",
|
||||
"completed",
|
||||
)
|
||||
|
||||
|
||||
class DataExportViewSet(
|
||||
mixins.RetrieveModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, GenericViewSet
|
||||
):
|
||||
queryset = DataExport.objects.all()
|
||||
serializer_class = DataExportSerializer
|
||||
owner_field = "requested_by"
|
||||
ordering_fields = ["completed", "requested_by", "requested_on", "content_type__model"]
|
||||
ordering = ["-requested_on"]
|
||||
search_fields = ["requested_by__username", "content_type__model"]
|
||||
|
||||
def get_queryset(self) -> QuerySet[DataExport]:
|
||||
"""Limit to exports of content types the user has view permission on"""
|
||||
qs = super().get_queryset()
|
||||
permitted_cts = []
|
||||
for ct in ContentType.objects.filter(
|
||||
id__in=qs.values_list("content_type_id", flat=True).distinct()
|
||||
):
|
||||
model = ct.model_class()
|
||||
if model is None:
|
||||
continue
|
||||
perm = f"{ct.app_label}.view_{ct.model}"
|
||||
if self.request.user.has_perm(perm):
|
||||
permitted_cts.append(ct)
|
||||
return qs.filter(content_type__in=permitted_cts)
|
||||
|
||||
|
||||
class ExportMixin:
|
||||
@extend_schema(
|
||||
request=None,
|
||||
parameters=[],
|
||||
responses={201: DataExportSerializer},
|
||||
filters=True,
|
||||
)
|
||||
@action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
permission_classes=[HasPermission("authentik_reports.add_dataexport")],
|
||||
)
|
||||
def export(self: GenericViewSet, request: Request) -> Response:
|
||||
"""
|
||||
Create a data export for this data type. Note that the export is generated asynchronously:
|
||||
this method returns a `DataExport` object that will initially have `completed=false` as well
|
||||
as the permanent URL to that object in the `Location` header.
|
||||
You can poll that URL until `completed=true`, at which point the `file_url` property will
|
||||
contain a URL to download
|
||||
"""
|
||||
|
||||
s = DataExportSerializer(data={"query_params": request.query_params.dict()})
|
||||
s.is_valid(raise_exception=True)
|
||||
export = s.save(
|
||||
requested_by=request.user,
|
||||
content_type=ContentType.objects.get_for_model(self.queryset.model),
|
||||
)
|
||||
generate_export.send(export.id)
|
||||
|
||||
set = export.serializer(instance=export)
|
||||
|
||||
return Response(
|
||||
set.data,
|
||||
status=201,
|
||||
headers={"Location": reverse("authentik_api:dataexport-detail", args=[export.id])},
|
||||
)
|
||||
|
||||
def get_permissions(self: GenericViewSet) -> list[BasePermission]:
|
||||
perms = super().get_permissions()
|
||||
if self.action == "export":
|
||||
model = self.get_queryset().model
|
||||
perms.append(HasPermission(f"{model._meta.app_label}.view_{model._meta.model_name}")())
|
||||
return perms
|
||||
@@ -1,8 +0,0 @@
|
||||
from authentik.enterprise.apps import EnterpriseConfig
|
||||
|
||||
|
||||
class ReportsConfig(EnterpriseConfig):
|
||||
name = "authentik.enterprise.reports"
|
||||
label = "authentik_reports"
|
||||
verbose_name = "authentik Enterprise.Reports"
|
||||
default = True
|
||||
@@ -1,48 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-12-02 17:19
|
||||
|
||||
import authentik.admin.files.fields
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="DataExport",
|
||||
fields=[
|
||||
("id", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
("requested_on", models.DateTimeField(auto_now_add=True)),
|
||||
("query_params", models.JSONField()),
|
||||
("file", authentik.admin.files.fields.FileField(blank=True)),
|
||||
("completed", models.BooleanField(default=False)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
|
||||
),
|
||||
),
|
||||
(
|
||||
"requested_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Data Export",
|
||||
"verbose_name_plural": "Data Exports",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,123 +0,0 @@
|
||||
import csv
|
||||
import io
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext as _
|
||||
from rest_framework.serializers import Serializer
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.admin.files.fields import FileField
|
||||
from authentik.admin.files.manager import get_file_manager
|
||||
from authentik.admin.files.usage import FileUsage
|
||||
from authentik.core.models import User
|
||||
from authentik.enterprise.reports.utils import MockRequest
|
||||
from authentik.events.models import Event, EventAction, Notification, NotificationSeverity
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.db import chunked_queryset
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
class DataExport(SerializerModel):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4)
|
||||
requested_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
|
||||
requested_on = models.DateTimeField(auto_now_add=True)
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
query_params = models.JSONField()
|
||||
file = FileField(blank=True)
|
||||
completed = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Data Export")
|
||||
verbose_name_plural = _("Data Exports")
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[Serializer]:
|
||||
"""Get serializer for this model"""
|
||||
from authentik.enterprise.reports.api.reports import DataExportSerializer
|
||||
|
||||
return DataExportSerializer
|
||||
|
||||
def generate(self) -> None:
|
||||
if self.completed:
|
||||
raise AssertionError("Data export must only be generated once")
|
||||
|
||||
model_class = self.content_type.model_class()
|
||||
model_verbose_name = model_class._meta.verbose_name
|
||||
model_verbose_name_plural = model_class._meta.verbose_name_plural
|
||||
|
||||
queryset = chunked_queryset(self.get_queryset())
|
||||
|
||||
serializer = self.get_serializer_class()(
|
||||
context={"request": self._get_request()}, instance=queryset, many=True
|
||||
)
|
||||
self.file = f"{model_verbose_name_plural.lower()}_{self.id}.csv"
|
||||
|
||||
with get_file_manager(FileUsage.REPORTS).save_file_stream(self.file) as f:
|
||||
with io.TextIOWrapper(f, encoding="utf-8", newline="") as text:
|
||||
writer = csv.writer(text)
|
||||
fields = [field.label for field in serializer.child.fields.values()]
|
||||
writer.writerow(fields)
|
||||
for record in queryset:
|
||||
data = serializer.child.to_representation(record).values()
|
||||
writer.writerow(data)
|
||||
self.completed = True
|
||||
self.save()
|
||||
|
||||
message = _(f"{model_verbose_name} export generated successfully")
|
||||
e = Event.new(
|
||||
EventAction.EXPORT_READY,
|
||||
message=message,
|
||||
export=self,
|
||||
).set_user(self.requested_by)
|
||||
e.save()
|
||||
Notification.objects.create(
|
||||
event=e,
|
||||
severity=NotificationSeverity.NOTICE,
|
||||
body=message,
|
||||
hyperlink=self.file_url,
|
||||
hyperlink_label=_("Download"),
|
||||
user=self.requested_by,
|
||||
)
|
||||
|
||||
@property
|
||||
def file_url(self) -> str:
|
||||
return get_file_manager(FileUsage.REPORTS).file_url(self.file)
|
||||
|
||||
def _get_request(self) -> MockRequest:
|
||||
return MockRequest(
|
||||
user=self.requested_by, query_params=self.query_params, tenant=get_current_tenant()
|
||||
)
|
||||
|
||||
def get_queryset(self) -> models.QuerySet:
|
||||
request = self._get_request()
|
||||
viewset = self.get_viewset()
|
||||
viewset.request = request
|
||||
queryset = viewset.get_queryset()
|
||||
queryset = viewset.filter_queryset(queryset)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_viewset(self) -> ModelViewSet:
|
||||
from authentik.core.api.users import UserViewSet
|
||||
from authentik.events.api.events import EventViewSet
|
||||
|
||||
model = (self.content_type.app_label, self.content_type.model)
|
||||
if model == ("authentik_core", "user"):
|
||||
return UserViewSet()
|
||||
elif model == ("authentik_events", "event"):
|
||||
return EventViewSet()
|
||||
raise NotImplementedError(f"Unsupported data export type {self.content_type.model}")
|
||||
|
||||
def get_serializer_class(self) -> type[Serializer]:
|
||||
from authentik.enterprise.reports.serializers import (
|
||||
ExportEventSerializer,
|
||||
ExportUserSerializer,
|
||||
)
|
||||
|
||||
if self.content_type.model == "user":
|
||||
return ExportUserSerializer
|
||||
elif self.content_type.model == "event":
|
||||
return ExportEventSerializer
|
||||
return self.get_viewset().get_serializer_class()
|
||||
@@ -1,32 +0,0 @@
|
||||
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
|
||||
|
||||
from authentik.core.api.users import UserSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.events.api.events import EventSerializer
|
||||
|
||||
|
||||
class ExportUserSerializer(UserSerializer):
|
||||
"""Serializer for exporting users"""
|
||||
|
||||
groups = SerializerMethodField(source="get_groups")
|
||||
|
||||
def get_groups(self, instance: User) -> str:
|
||||
return ",".join([group.name for group in instance.ak_groups.all()])
|
||||
|
||||
class Meta(UserSerializer.Meta):
|
||||
fields = [f for f in UserSerializer.Meta.fields if f != "groups_obj"] + ["groups"]
|
||||
|
||||
|
||||
class ExportEventSerializer(EventSerializer):
|
||||
"""Serializer for exporting events"""
|
||||
|
||||
user_pk = IntegerField(source="user.pk", read_only=True)
|
||||
username = CharField(source="user.username", read_only=True)
|
||||
email = CharField(source="user.email", read_only=True)
|
||||
|
||||
class Meta(EventSerializer.Meta):
|
||||
fields = [f for f in EventSerializer.Meta.fields if f != "user"] + [
|
||||
"user_pk",
|
||||
"username",
|
||||
"email",
|
||||
]
|
||||
@@ -1,10 +0,0 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dramatiq import actor
|
||||
|
||||
from authentik.enterprise.reports.models import DataExport
|
||||
|
||||
|
||||
@actor(description=_("Generate data export."))
|
||||
def generate_export(export_id: int):
|
||||
export = DataExport.objects.get(id=export_id)
|
||||
export.generate()
|
||||
@@ -1,53 +0,0 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.enterprise.reports.tests.utils import patch_license
|
||||
from authentik.events.models import Event
|
||||
|
||||
|
||||
@patch_license
|
||||
class TestExportAPI(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_create_user_export(self):
|
||||
"""Test User export endpoint"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-export"),
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(
|
||||
response.headers["Location"],
|
||||
reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]}),
|
||||
)
|
||||
self.assertEqual(response.data["requested_by"]["pk"], self.user.pk)
|
||||
self.assertEqual(response.data["completed"], False)
|
||||
self.assertEqual(response.data["file_url"], "")
|
||||
self.assertEqual(response.data["query_params"], {})
|
||||
self.assertEqual(
|
||||
response.data["content_type"]["id"],
|
||||
ContentType.objects.get_for_model(User).id,
|
||||
)
|
||||
|
||||
def test_create_event_export(self):
|
||||
"""Test Event export endpoint"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:event-export"),
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(
|
||||
response.headers["Location"],
|
||||
reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]}),
|
||||
)
|
||||
self.assertEqual(response.data["requested_by"]["pk"], self.user.pk)
|
||||
self.assertEqual(response.data["completed"], False)
|
||||
self.assertEqual(response.data["file_url"], "")
|
||||
self.assertEqual(response.data["query_params"], {})
|
||||
self.assertEqual(
|
||||
response.data["content_type"]["id"],
|
||||
ContentType.objects.get_for_model(Event).id,
|
||||
)
|
||||
@@ -1,29 +0,0 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test.testcases import TestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.enterprise.reports.models import DataExport
|
||||
from authentik.enterprise.reports.tests.utils import patch_license
|
||||
from authentik.events.models import Event, EventAction
|
||||
|
||||
|
||||
@patch_license
|
||||
class TestEventExport(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_user()
|
||||
self.user.assign_perms_to_managed_role("authentik_events.view_event")
|
||||
|
||||
self.e1 = Event.new(EventAction.LOGIN, user=self.user)
|
||||
self.e1.save()
|
||||
self.e2 = Event.new(EventAction.LOGIN_FAILED, user=self.user)
|
||||
self.e2.save()
|
||||
|
||||
def test_type_filter(self):
|
||||
export = DataExport.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(Event),
|
||||
requested_by=self.user,
|
||||
query_params={"actions": [EventAction.LOGIN]},
|
||||
)
|
||||
records = list(export.get_queryset())
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(records[0], self.e1)
|
||||
@@ -1,80 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.enterprise.reports.tests.utils import patch_license
|
||||
|
||||
|
||||
@patch_license
|
||||
class TestExportPermissions(APITestCase):
|
||||
def setUp(self) -> None:
|
||||
self.user = create_test_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_export_without_permission(self):
|
||||
"""Test User export endpoint without permission"""
|
||||
response = self.client.post(reverse("authentik_api:user-export"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_export_only_user_permission(self):
|
||||
"""Test User export endpoint with only view_user permission"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
response = self.client.post(reverse("authentik_api:user-export"))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_export_with_permission(self):
|
||||
"""Test User export endpoint with view_user and add_dataexport permission"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
|
||||
response = self.client.post(reverse("authentik_api:user-export"))
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
def test_export_access(self):
|
||||
"""Test that data export access is restricted to the user who created it"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
|
||||
response = self.client.post(reverse("authentik_api:user-export"))
|
||||
self.assertEqual(response.status_code, 201)
|
||||
export_url = reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]})
|
||||
response = self.client.get(export_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
other_user = create_test_user()
|
||||
other_user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
other_user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
|
||||
self.client.logout()
|
||||
self.client.force_login(other_user)
|
||||
response = self.client.get(export_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_export_access_no_datatype_permission(self):
|
||||
"""Test that data export access requires view permission on the data type"""
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
|
||||
self.user.assign_perms_to_managed_role("authentik_reports.view_dataexport")
|
||||
response = self.client.post(reverse("authentik_api:user-export"))
|
||||
self.assertEqual(response.status_code, 201)
|
||||
export_url = reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]})
|
||||
|
||||
response = self.client.get(export_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.user.remove_perms_from_managed_role("authentik_core.view_user")
|
||||
response = self.client.get(export_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
response = self.client.get(reverse("authentik_api:dataexport-list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data["results"]), 0)
|
||||
|
||||
def test_export_access_owner(self):
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.user.assign_perms_to_managed_role("authentik_reports.add_dataexport")
|
||||
response = self.client.post(reverse("authentik_api:user-export"))
|
||||
self.assertEqual(response.status_code, 201)
|
||||
export_url = reverse("authentik_api:dataexport-detail", kwargs={"pk": response.data["id"]})
|
||||
response = self.client.get(export_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.user.remove_perms_from_managed_role("authentik_core.view_user")
|
||||
response = self.client.get(export_url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -1,48 +0,0 @@
|
||||
from django.test.testcases import TestCase
|
||||
from drf_spectacular.generators import SchemaGenerator
|
||||
|
||||
from authentik.enterprise.reports.tests.utils import patch_license
|
||||
|
||||
|
||||
@patch_license
|
||||
class TestSchemaMatch(TestCase):
|
||||
def setUp(self) -> None:
|
||||
generator = SchemaGenerator()
|
||||
self.schema = generator.get_schema(request=None, public=True)
|
||||
|
||||
def _index_params_by_name(self, parameters):
|
||||
result = {}
|
||||
for p in parameters or []:
|
||||
if p.get("in") != "query":
|
||||
continue
|
||||
schema = p.get("schema", {})
|
||||
result[p["name"]] = {
|
||||
"required": p.get("required", False),
|
||||
"type": schema.get("type"),
|
||||
"format": schema.get("format"),
|
||||
"enum": tuple(schema.get("enum", [])),
|
||||
}
|
||||
return result
|
||||
|
||||
def _find_operation_by_operation_id(self, operation_id):
|
||||
for path_item in self.schema.get("paths", {}).values():
|
||||
for operation in path_item.values():
|
||||
if isinstance(operation, dict) and operation.get("operationId") == operation_id:
|
||||
return operation
|
||||
raise AssertionError(f"operationId '{operation_id}' not found in schema")
|
||||
|
||||
def _get_op_params(self, operation_id):
|
||||
operation = self._find_operation_by_operation_id(operation_id)
|
||||
return self._index_params_by_name(operation.get("parameters", []))
|
||||
|
||||
def test_user_export_action_query_params_match_list(self):
|
||||
list_params = self._get_op_params("core_users_list")
|
||||
del list_params["include_groups"] # Not applicable for export
|
||||
del list_params["include_roles"] # Not applicable for export
|
||||
export_params = self._get_op_params("core_users_export_create")
|
||||
self.assertDictEqual(list_params, export_params)
|
||||
|
||||
def test_event_export_action_query_params_match_list(self):
|
||||
list_params = self._get_op_params("events_events_list")
|
||||
export_params = self._get_op_params("events_events_export_create")
|
||||
self.assertDictEqual(list_params, export_params)
|
||||
@@ -1,75 +0,0 @@
|
||||
import csv
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test.testcases import TestCase
|
||||
|
||||
from authentik.admin.files.tests.utils import FileTestFileBackendMixin
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_user
|
||||
from authentik.enterprise.reports.models import DataExport
|
||||
from authentik.enterprise.reports.tests.utils import patch_license
|
||||
|
||||
|
||||
@patch_license
|
||||
class TestUserExport(FileTestFileBackendMixin, TestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
self.u1 = create_test_user(username="a")
|
||||
self.u1.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
self.u2 = create_test_user(username="b", path="abcd")
|
||||
self.u1.assign_perms_to_managed_role("authentik_core.view_user")
|
||||
|
||||
def _read_export(self, filename):
|
||||
with open(f"{self.reports_backend_path}/reports/public/{filename}") as f:
|
||||
reader = csv.DictReader(f)
|
||||
return list(reader)
|
||||
|
||||
def test_generate_user_export(self):
|
||||
export = DataExport.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(User),
|
||||
requested_by=self.u1,
|
||||
query_params={"email": str(self.u1.email)},
|
||||
)
|
||||
export.generate()
|
||||
|
||||
self.assertEqual(export.completed, True)
|
||||
data = self._read_export(export.file)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["Username"], self.u1.username)
|
||||
|
||||
def test_path_filter(self):
|
||||
export = DataExport.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(User),
|
||||
requested_by=self.u1,
|
||||
query_params={"path": str(self.u2.path)},
|
||||
)
|
||||
records = list(export.get_queryset())
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(records[0], self.u2)
|
||||
|
||||
def test_search_filter(self):
|
||||
export = DataExport.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(User),
|
||||
requested_by=self.u1,
|
||||
query_params={"search": f'username = "{self.u2.username}"'},
|
||||
)
|
||||
records = list(export.get_queryset())
|
||||
self.assertEqual(len(records), 1)
|
||||
self.assertEqual(records[0], self.u2)
|
||||
|
||||
def test_ordering(self):
|
||||
export = DataExport.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(User),
|
||||
requested_by=self.u1,
|
||||
query_params={"ordering": "-username"},
|
||||
)
|
||||
records = list(export.get_queryset())
|
||||
self.assertGreaterEqual(records[0].username, records[-1].username)
|
||||
export = DataExport.objects.create(
|
||||
content_type=ContentType.objects.get_for_model(User),
|
||||
requested_by=self.u1,
|
||||
query_params={"ordering": "username"},
|
||||
)
|
||||
records = list(export.get_queryset())
|
||||
self.assertLess(records[0].username, records[-1].username)
|
||||
@@ -1,6 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
patch_license = patch(
|
||||
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
@@ -1,7 +0,0 @@
|
||||
"""API URLs"""
|
||||
|
||||
from authentik.enterprise.reports.api.reports import DataExportViewSet
|
||||
|
||||
api_urlpatterns = [
|
||||
("reports/exports", DataExportViewSet),
|
||||
]
|
||||
@@ -1,11 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockRequest:
|
||||
user: User
|
||||
query_params: dict[str, str]
|
||||
tenant: Tenant
|
||||
@@ -9,7 +9,6 @@ TENANT_APPS = [
|
||||
"authentik.enterprise.providers.radius",
|
||||
"authentik.enterprise.providers.scim",
|
||||
"authentik.enterprise.providers.ssf",
|
||||
"authentik.enterprise.reports",
|
||||
"authentik.enterprise.search",
|
||||
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
|
||||
"authentik.enterprise.stages.mtls",
|
||||
|
||||
@@ -21,7 +21,6 @@ from rest_framework.viewsets import ModelViewSet
|
||||
from authentik.core.api.object_types import TypeCreateSerializer
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.reflection import ConditionalInheritance
|
||||
|
||||
|
||||
class EventVolumeSerializer(PassiveSerializer):
|
||||
@@ -117,9 +116,7 @@ class EventsFilter(django_filters.FilterSet):
|
||||
fields = ["action", "client_ip", "username"]
|
||||
|
||||
|
||||
class EventViewSet(
|
||||
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"), ModelViewSet
|
||||
):
|
||||
class EventViewSet(ModelViewSet):
|
||||
"""Event Read-Only Viewset"""
|
||||
|
||||
queryset = Event.objects.all()
|
||||
|
||||
@@ -29,8 +29,6 @@ class NotificationSerializer(ModelSerializer):
|
||||
"pk",
|
||||
"severity",
|
||||
"body",
|
||||
"hyperlink",
|
||||
"hyperlink_label",
|
||||
"created",
|
||||
"event",
|
||||
"seen",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-05 10:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0013_delete_systemtask"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="notification",
|
||||
name="hyperlink",
|
||||
field=models.TextField(blank=True, max_length=4096, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notification",
|
||||
name="hyperlink_label",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="event",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("user_write", "User Write"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("secret_view", "Secret View"),
|
||||
("secret_rotate", "Secret Rotate"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("flow_execution", "Flow Execution"),
|
||||
("policy_execution", "Policy Execution"),
|
||||
("policy_exception", "Policy Exception"),
|
||||
("property_mapping_exception", "Property Mapping Exception"),
|
||||
("system_task_execution", "System Task Execution"),
|
||||
("system_task_exception", "System Task Exception"),
|
||||
("system_exception", "System Exception"),
|
||||
("configuration_error", "Configuration Error"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("email_sent", "Email Sent"),
|
||||
("update_available", "Update Available"),
|
||||
("export_ready", "Export Ready"),
|
||||
("custom_", "Custom Prefix"),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -117,8 +117,6 @@ class EventAction(models.TextChoices):
|
||||
|
||||
UPDATE_AVAILABLE = "update_available"
|
||||
|
||||
EXPORT_READY = "export_ready"
|
||||
|
||||
CUSTOM_PREFIX = "custom_"
|
||||
|
||||
|
||||
@@ -263,14 +261,6 @@ class Event(SerializerModel, ExpiringModel):
|
||||
return self.context["message"]
|
||||
return f"{self.action}: {self.context}"
|
||||
|
||||
@property
|
||||
def hyperlink(self) -> str | None:
|
||||
return self.context.get("hyperlink")
|
||||
|
||||
@property
|
||||
def hyperlink_label(self) -> str | None:
|
||||
return self.context.get("hyperlink_label")
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Event action={self.action} user={self.user} context={self.context}"
|
||||
|
||||
@@ -489,11 +479,6 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
context["key_value"]["event_user_username"] = notification.event.user.get(
|
||||
"username", None
|
||||
)
|
||||
if notification.hyperlink:
|
||||
context["link"] = {
|
||||
"target": notification.hyperlink,
|
||||
"label": notification.hyperlink_label,
|
||||
}
|
||||
if notification.event:
|
||||
context["title"] += notification.event.action
|
||||
for key, value in notification.event.context.items():
|
||||
@@ -547,8 +532,6 @@ class Notification(SerializerModel):
|
||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
severity = models.TextField(choices=NotificationSeverity.choices)
|
||||
body = models.TextField()
|
||||
hyperlink = models.TextField(blank=True, null=True, max_length=4096)
|
||||
hyperlink_label = models.TextField(blank=True, null=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
event = models.ForeignKey(Event, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
seen = models.BooleanField(default=False)
|
||||
|
||||
@@ -110,12 +110,7 @@ def notification_transport(transport_pk: int, event_pk: str, user_pk: int, trigg
|
||||
if not trigger:
|
||||
return
|
||||
notification = Notification(
|
||||
severity=trigger.severity,
|
||||
body=event.summary,
|
||||
event=event,
|
||||
user=user,
|
||||
hyperlink=event.hyperlink,
|
||||
hyperlink_label=event.hyperlink_label,
|
||||
severity=trigger.severity, body=event.summary, event=event, user=user
|
||||
)
|
||||
transport: NotificationTransport = NotificationTransport.objects.filter(pk=transport_pk).first()
|
||||
if not transport:
|
||||
|
||||
@@ -27,7 +27,7 @@ class StageSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Stage Serializer"""
|
||||
|
||||
component = SerializerMethodField()
|
||||
flow_set = FlowSetSerializer(many=True, required=False, read_only=True)
|
||||
flow_set = FlowSetSerializer(many=True, required=False)
|
||||
|
||||
def to_representation(self, instance: Stage):
|
||||
if isinstance(instance, Stage) and instance.is_in_memory:
|
||||
|
||||
@@ -56,7 +56,6 @@ class TestFlowInspector(APITestCase):
|
||||
"layout": "stacked",
|
||||
},
|
||||
"flow_designation": "authentication",
|
||||
"passkey_challenge": None,
|
||||
"password_fields": False,
|
||||
"primary_action": "Log in",
|
||||
"sources": [],
|
||||
|
||||
@@ -301,6 +301,8 @@ class ConfigLoader:
|
||||
return {}
|
||||
try:
|
||||
b64decoded_str = base64.b64decode(config_value).decode("utf-8")
|
||||
b64decoded_str = b64decoded_str.strip().lstrip("{").rstrip("}")
|
||||
b64decoded_str = "{" + b64decoded_str + "}"
|
||||
return json.loads(b64decoded_str)
|
||||
except (JSONDecodeError, TypeError, ValueError) as exc:
|
||||
self.log(
|
||||
|
||||
@@ -63,7 +63,6 @@ class BaseEvaluator:
|
||||
"ak_call_policy": self.expr_func_call_policy,
|
||||
"ak_create_event": self.expr_event_create,
|
||||
"ak_create_jwt": self.expr_create_jwt,
|
||||
"ak_create_jwt_raw": self.expr_create_jwt_raw,
|
||||
"ak_is_group_member": BaseEvaluator.expr_is_group_member,
|
||||
"ak_logger": get_logger(self._filename).bind(),
|
||||
"ak_send_email": self.expr_send_email,
|
||||
@@ -223,16 +222,6 @@ class BaseEvaluator:
|
||||
access_token.save()
|
||||
return access_token.token
|
||||
|
||||
def expr_create_jwt_raw(
|
||||
self, provider: OAuth2Provider | str, validity: str = "seconds=60", **kwargs
|
||||
) -> str:
|
||||
"""Issue a JWT for a given provider with completely customized data"""
|
||||
if not isinstance(provider, OAuth2Provider):
|
||||
provider = OAuth2Provider.objects.get(name=provider)
|
||||
kwargs["exp"] = int((now() + timedelta_from_string(validity)).timestamp())
|
||||
kwargs["aud"] = provider.client_id
|
||||
return provider.encode(kwargs)
|
||||
|
||||
def expr_send_email(
|
||||
self,
|
||||
address: str | list[str],
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.core.models import Source
|
||||
from authentik.tasks.schedules.models import ScheduledModel
|
||||
|
||||
|
||||
class SyncOutgoingTriggerMode(models.TextChoices):
|
||||
# Do not trigger outgoing syncs
|
||||
NONE = "none"
|
||||
# Trigger immediately after object changed
|
||||
IMMEDIATE = "immediate"
|
||||
# Trigger at the end of full sync
|
||||
DEFERRED_END = "deferred_end"
|
||||
|
||||
|
||||
class IncomingSyncSource(ScheduledModel, Source):
|
||||
sync_outgoing_trigger_mode = models.TextField(
|
||||
choices=SyncOutgoingTriggerMode.choices,
|
||||
default=SyncOutgoingTriggerMode.DEFERRED_END,
|
||||
help_text=_("When to trigger sync for outgoing providers"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -83,10 +83,6 @@ class OutgoingSyncProvider(ScheduledModel, Model):
|
||||
def sync_actor(self) -> Actor:
|
||||
raise NotImplementedError
|
||||
|
||||
def sync_dispatch(self) -> None:
|
||||
for schedule in self.schedules:
|
||||
schedule.send()
|
||||
|
||||
@property
|
||||
def schedule_specs(self) -> list[ScheduleSpec]:
|
||||
return [
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from dramatiq.actor import Actor
|
||||
@@ -10,23 +7,6 @@ from authentik.lib.sync.outgoing.base import Direction
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
|
||||
_CTX_INHIBIT_DISPATCH = ContextVar[bool](
|
||||
"authentik_sync_outgoing_inhibit_dispatch",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def sync_outgoing_inhibit_dispatch():
|
||||
"""
|
||||
Prevent direct and m2m tasks from being dispatched when User/Group/membership change
|
||||
"""
|
||||
_CTX_INHIBIT_DISPATCH.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_CTX_INHIBIT_DISPATCH.set(False)
|
||||
|
||||
|
||||
def register_signals(
|
||||
provider_type: type[OutgoingSyncProvider],
|
||||
@@ -48,8 +28,6 @@ def register_signals(
|
||||
# This primarily happens during user login
|
||||
if sender == User and update_fields == {"last_login"}:
|
||||
return
|
||||
if _CTX_INHIBIT_DISPATCH.get():
|
||||
return
|
||||
if not provider_type.objects.exists():
|
||||
return
|
||||
task_sync_direct_dispatch.send(
|
||||
@@ -63,8 +41,6 @@ def register_signals(
|
||||
|
||||
def model_pre_delete(sender: type[Model], instance: User | Group, **_):
|
||||
"""Pre-delete handler"""
|
||||
if _CTX_INHIBIT_DISPATCH.get():
|
||||
return
|
||||
if not provider_type.objects.exists():
|
||||
return
|
||||
task_sync_direct_dispatch.send(
|
||||
@@ -82,8 +58,6 @@ def register_signals(
|
||||
"""Sync group membership"""
|
||||
if action not in ["post_add", "post_remove"]:
|
||||
return
|
||||
if _CTX_INHIBIT_DISPATCH.get():
|
||||
return
|
||||
if not provider_type.objects.exists():
|
||||
return
|
||||
task_sync_m2m_dispatch.send(instance.pk, action, list(pk_set), reverse)
|
||||
|
||||
@@ -129,7 +129,7 @@ class TestConfig(TestCase):
|
||||
test_value = b' "foo": "bar" '
|
||||
b64_value = base64.b64encode(test_value)
|
||||
config.set("foo", b64_value)
|
||||
self.assertEqual(config.get_dict_from_b64_json("foo"), {})
|
||||
self.assertEqual(config.get_dict_from_b64_json("foo"), {"foo": "bar"})
|
||||
|
||||
def test_get_dict_from_b64_json_invalid(self):
|
||||
"""Test get_dict_from_b64_json with invalid value"""
|
||||
|
||||
@@ -80,26 +80,6 @@ class TestEvaluator(TestCase):
|
||||
)
|
||||
self.assertEqual(decoded["preferred_username"], user.username)
|
||||
|
||||
def test_expr_create_jwt_raw(self):
|
||||
"""Test expr_create_jwt_raw"""
|
||||
rf = RequestFactory()
|
||||
user = create_test_user()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
)
|
||||
evaluator = BaseEvaluator(generate_id())
|
||||
evaluator._context = {
|
||||
"http_request": rf.get(reverse("authentik_core:root-redirect")),
|
||||
"user": user,
|
||||
"provider": provider.name,
|
||||
}
|
||||
jwt = evaluator.evaluate("return ak_create_jwt_raw(provider, foo='bar')")
|
||||
decoded = decode(
|
||||
jwt, provider.client_secret, algorithms=["HS256"], audience=provider.client_id
|
||||
)
|
||||
self.assertEqual(decoded["foo"], "bar")
|
||||
|
||||
@patch("authentik.stages.email.tasks.send_mails")
|
||||
def test_expr_send_email_with_body(self, mock_send_mails):
|
||||
"""Test ak_send_email with body parameter"""
|
||||
|
||||
@@ -26,4 +26,4 @@ def chunked_queryset(queryset: QuerySet, chunk_size: int = 1_000):
|
||||
for chunk in get_chunks(queryset):
|
||||
reset_queries()
|
||||
gc.collect()
|
||||
yield from chunk.iterator(chunk_size=chunk_size)
|
||||
yield from chunk.iterator()
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Generated by Django 5.1.12 on 2025-10-02 12:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_policies_event_matcher", "0023_alter_eventmatcherpolicy_action_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="eventmatcherpolicy",
|
||||
name="action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("login", "Login"),
|
||||
("login_failed", "Login Failed"),
|
||||
("logout", "Logout"),
|
||||
("user_write", "User Write"),
|
||||
("suspicious_request", "Suspicious Request"),
|
||||
("password_set", "Password Set"),
|
||||
("secret_view", "Secret View"),
|
||||
("secret_rotate", "Secret Rotate"),
|
||||
("invitation_used", "Invite Used"),
|
||||
("authorize_application", "Authorize Application"),
|
||||
("source_linked", "Source Linked"),
|
||||
("impersonation_started", "Impersonation Started"),
|
||||
("impersonation_ended", "Impersonation Ended"),
|
||||
("flow_execution", "Flow Execution"),
|
||||
("policy_execution", "Policy Execution"),
|
||||
("policy_exception", "Policy Exception"),
|
||||
("property_mapping_exception", "Property Mapping Exception"),
|
||||
("system_task_execution", "System Task Execution"),
|
||||
("system_task_exception", "System Task Exception"),
|
||||
("system_exception", "System Exception"),
|
||||
("configuration_error", "Configuration Error"),
|
||||
("model_created", "Model Created"),
|
||||
("model_updated", "Model Updated"),
|
||||
("model_deleted", "Model Deleted"),
|
||||
("email_sent", "Email Sent"),
|
||||
("update_available", "Update Available"),
|
||||
("export_ready", "Export Ready"),
|
||||
("custom_", "Custom Prefix"),
|
||||
],
|
||||
default=None,
|
||||
help_text="Match created events with this action type. When left empty, all action types will be matched.",
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -313,7 +313,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"multipart/form-data": SAMLProviderImportSerializer,
|
||||
},
|
||||
responses={
|
||||
201: SAMLProviderSerializer,
|
||||
204: OpenApiResponse(description="Successfully imported provider"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@@ -330,18 +330,17 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
file.seek(0)
|
||||
try:
|
||||
metadata = ServiceProviderMetadataParser().parse(file.read().decode())
|
||||
provider = metadata.to_provider(
|
||||
metadata.to_provider(
|
||||
body.validated_data["name"],
|
||||
body.validated_data["authorization_flow"],
|
||||
body.validated_data["invalidation_flow"],
|
||||
)
|
||||
# Return the created provider for use in workflows like the application wizard
|
||||
return Response(SAMLProviderSerializer(provider).data, status=201)
|
||||
except ValueError as exc: # pragma: no cover
|
||||
LOGGER.warning(str(exc))
|
||||
raise ValidationError(
|
||||
_("Failed to import Metadata: {messages}".format_map({"messages": str(exc)})),
|
||||
) from None
|
||||
return Response(status=204)
|
||||
|
||||
@permission_required(
|
||||
"authentik_providers_saml.view_samlprovider",
|
||||
|
||||
@@ -145,9 +145,6 @@ class TestSAMLProviderAPI(APITestCase):
|
||||
|
||||
def test_import_success(self):
|
||||
"""Test metadata import (success case)"""
|
||||
name = generate_id()
|
||||
authorization_flow = create_test_flow(FlowDesignation.AUTHORIZATION)
|
||||
invalidation_flow = create_test_flow(FlowDesignation.INVALIDATION)
|
||||
with TemporaryFile() as metadata:
|
||||
metadata.write(load_fixture("fixtures/simple.xml").encode())
|
||||
metadata.seek(0)
|
||||
@@ -155,18 +152,14 @@ class TestSAMLProviderAPI(APITestCase):
|
||||
reverse("authentik_api:samlprovider-import-metadata"),
|
||||
{
|
||||
"file": metadata,
|
||||
"name": name,
|
||||
"authorization_flow": authorization_flow.pk,
|
||||
"invalidation_flow": invalidation_flow.pk,
|
||||
"name": generate_id(),
|
||||
"authorization_flow": create_test_flow(FlowDesignation.AUTHORIZATION).pk,
|
||||
"invalidation_flow": create_test_flow(FlowDesignation.INVALIDATION).pk,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(201, response.status_code)
|
||||
body = response.json()
|
||||
self.assertIn("pk", body)
|
||||
self.assertEqual(body["name"], name)
|
||||
self.assertEqual(body["authorization_flow"], str(authorization_flow.pk))
|
||||
self.assertEqual(body["invalidation_flow"], str(invalidation_flow.pk))
|
||||
self.assertEqual(204, response.status_code)
|
||||
# We don't test the actual object being created here, that has its own tests
|
||||
|
||||
def test_import_failed(self):
|
||||
"""Test metadata import (invalid xml)"""
|
||||
|
||||
@@ -207,6 +207,7 @@ SPECTACULAR_SETTINGS = {
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_PAGINATION_CLASS": "authentik.api.pagination.Pagination",
|
||||
"PAGE_SIZE": 100,
|
||||
"DEFAULT_FILTER_BACKENDS": [
|
||||
"authentik.rbac.filters.ObjectFilter",
|
||||
"django_filters.rest_framework.DjangoFilterBackend",
|
||||
@@ -259,7 +260,7 @@ MIDDLEWARE_FIRST = [
|
||||
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||
]
|
||||
MIDDLEWARE = [
|
||||
"authentik.tenants.middleware.DefaultTenantMiddleware",
|
||||
"django_tenants.middleware.default.DefaultTenantMiddleware",
|
||||
"authentik.root.middleware.LoggingMiddleware",
|
||||
"authentik.root.middleware.ClientIPMiddleware",
|
||||
"authentik.stages.user_login.middleware.BoundSessionMiddleware",
|
||||
|
||||
@@ -44,7 +44,6 @@ class KerberosSourceSerializer(SourceSerializer):
|
||||
"spnego_keytab",
|
||||
"spnego_ccache",
|
||||
"password_login_update_internal_password",
|
||||
"sync_outgoing_trigger_mode",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"sync_password": {"write_only": True},
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-08 13:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_kerberos", "0003_migrate_userkerberossourceconnection_identifier"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="kerberossource",
|
||||
name="sync_outgoing_trigger_mode",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("none", "None"),
|
||||
("immediate", "Immediate"),
|
||||
("deferred_end", "Deferred End"),
|
||||
],
|
||||
default="deferred_end",
|
||||
help_text="When to trigger sync for outgoing providers",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -22,14 +22,15 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.models import (
|
||||
GroupSourceConnection,
|
||||
PropertyMapping,
|
||||
Source,
|
||||
UserSourceConnection,
|
||||
UserTypes,
|
||||
)
|
||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||
from authentik.flows.challenge import RedirectChallenge
|
||||
from authentik.lib.sync.incoming.models import IncomingSyncSource
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
from authentik.tasks.schedules.common import ScheduleSpec
|
||||
from authentik.tasks.schedules.models import ScheduledModel
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -45,7 +46,7 @@ class KAdminType(models.TextChoices):
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class KerberosSource(IncomingSyncSource):
|
||||
class KerberosSource(ScheduledModel, Source):
|
||||
"""Federate Kerberos realm with authentik"""
|
||||
|
||||
realm = models.TextField(help_text=_("Kerberos realm"), unique=True)
|
||||
|
||||
@@ -6,11 +6,7 @@ from dramatiq.actor import actor
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.sync.incoming.models import SyncOutgoingTriggerMode
|
||||
from authentik.lib.sync.outgoing.exceptions import StopSync
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
|
||||
from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
from authentik.sources.kerberos.models import KerberosSource
|
||||
from authentik.sources.kerberos.sync import KerberosSync
|
||||
from authentik.tasks.middleware import CurrentTask
|
||||
@@ -49,15 +45,7 @@ def kerberos_sync(pk: str):
|
||||
)
|
||||
return
|
||||
syncer = KerberosSync(source, self)
|
||||
if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.IMMEDIATE:
|
||||
syncer.sync()
|
||||
else:
|
||||
with sync_outgoing_inhibit_dispatch():
|
||||
syncer.sync()
|
||||
if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.DEFERRED_END:
|
||||
for outgoing_sync_provider_cls in all_subclasses(OutgoingSyncProvider):
|
||||
for provider in outgoing_sync_provider_cls.objects.all():
|
||||
provider.sync_dispatch()
|
||||
syncer.sync()
|
||||
except StopSync as exc:
|
||||
LOGGER.warning("Error syncing kerberos", exc=exc, source=source)
|
||||
self.error(exc)
|
||||
|
||||
@@ -114,7 +114,6 @@ class LDAPSourceSerializer(SourceSerializer):
|
||||
"connectivity",
|
||||
"lookup_groups_from_user",
|
||||
"delete_not_found_objects",
|
||||
"sync_outgoing_trigger_mode",
|
||||
]
|
||||
extra_kwargs = {"bind_password": {"write_only": True}}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-08 13:45
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_sources_ldap", "0010_ldapsource_user_membership_attribute"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ldapsource",
|
||||
name="sync_outgoing_trigger_mode",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("none", "None"),
|
||||
("immediate", "Immediate"),
|
||||
("deferred_end", "Deferred End"),
|
||||
],
|
||||
default="deferred_end",
|
||||
help_text="When to trigger sync for outgoing providers",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -19,14 +19,15 @@ from authentik.core.models import (
|
||||
Group,
|
||||
GroupSourceConnection,
|
||||
PropertyMapping,
|
||||
Source,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.lib.sync.incoming.models import IncomingSyncSource
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
from authentik.tasks.schedules.common import ScheduleSpec
|
||||
from authentik.tasks.schedules.models import ScheduledModel
|
||||
|
||||
LDAP_TIMEOUT = 15
|
||||
LDAP_UNIQUENESS = "ldap_uniq"
|
||||
@@ -55,7 +56,7 @@ class MultiURLValidator(DomainlessURLValidator):
|
||||
super().__call__(value)
|
||||
|
||||
|
||||
class LDAPSource(IncomingSyncSource):
|
||||
class LDAPSource(ScheduledModel, Source):
|
||||
"""Federate LDAP Directory with authentik, or create new accounts in LDAP."""
|
||||
|
||||
server_uri = models.TextField(
|
||||
|
||||
@@ -11,11 +11,8 @@ from ldap3.core.exceptions import LDAPException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.sync.incoming.models import SyncOutgoingTriggerMode
|
||||
from authentik.lib.sync.outgoing.exceptions import StopSync
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
|
||||
from authentik.lib.sync.outgoing.signals import sync_outgoing_inhibit_dispatch
|
||||
from authentik.lib.utils.reflection import all_subclasses, class_to_path, path_to_class
|
||||
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
||||
from authentik.sources.ldap.models import LDAPSource
|
||||
from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
|
||||
from authentik.sources.ldap.sync.forward_delete_groups import GroupLDAPForwardDeletion
|
||||
@@ -105,11 +102,6 @@ def ldap_sync(source_pk: str):
|
||||
timeout=60 * 60 * CONFIG.get_int("ldap.task_timeout_hours") * 1000,
|
||||
)
|
||||
|
||||
if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.DEFERRED_END:
|
||||
for outgoing_sync_provider_cls in all_subclasses(OutgoingSyncProvider):
|
||||
for provider in outgoing_sync_provider_cls.objects.all():
|
||||
provider.sync_dispatch()
|
||||
|
||||
|
||||
def ldap_sync_paginator(
|
||||
task: Task, source: LDAPSource, sync: type[BaseLDAPSynchronizer]
|
||||
@@ -155,11 +147,7 @@ def ldap_sync_page(source_pk: str, sync_class: str, page_cache_key: str):
|
||||
self.error(error_message)
|
||||
return
|
||||
cache.touch(page_cache_key)
|
||||
if source.sync_outgoing_trigger_mode == SyncOutgoingTriggerMode.IMMEDIATE:
|
||||
count = sync_inst.sync(page)
|
||||
else:
|
||||
with sync_outgoing_inhibit_dispatch():
|
||||
count = sync_inst.sync(page)
|
||||
count = sync_inst.sync(page)
|
||||
self.info(f"Synced {count} objects.")
|
||||
cache.delete(page_cache_key)
|
||||
except (LDAPException, StopSync) as exc:
|
||||
|
||||
@@ -25,7 +25,6 @@ AUTHENTIK_SOURCES_OAUTH_TYPES = [
|
||||
"authentik.sources.oauth.types.slack",
|
||||
"authentik.sources.oauth.types.twitch",
|
||||
"authentik.sources.oauth.types.twitter",
|
||||
"authentik.sources.oauth.types.wechat",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -307,15 +307,6 @@ class RedditOAuthSource(CreatableType, OAuthSource):
|
||||
verbose_name_plural = _("Reddit OAuth Sources")
|
||||
|
||||
|
||||
class WeChatOAuthSource(CreatableType, OAuthSource):
|
||||
"""Social Login using WeChat."""
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
verbose_name = _("WeChat OAuth Source")
|
||||
verbose_name_plural = _("WeChat OAuth Sources")
|
||||
|
||||
|
||||
class OAuthSourcePropertyMapping(PropertyMapping):
|
||||
"""Map OAuth properties to User or Group object attributes"""
|
||||
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""WeChat Type tests"""
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.wechat import WeChatType
|
||||
|
||||
WECHAT_USER = {
|
||||
"openid": "OPENID",
|
||||
"nickname": "NICKNAME",
|
||||
"sex": 1,
|
||||
"province": "PROVINCE",
|
||||
"city": "CITY",
|
||||
"country": "COUNTRY",
|
||||
"headimgurl": "https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0",
|
||||
"privilege": ["PRIVILEGE1", "PRIVILEGE2"],
|
||||
"unionid": " o6_buyCrymLUUFYHxvDU6M2PHl22",
|
||||
}
|
||||
|
||||
|
||||
class TestTypeWeChat(TestCase):
|
||||
"""OAuth Source tests"""
|
||||
|
||||
def setUp(self):
|
||||
self.source = OAuthSource.objects.create(
|
||||
name="test",
|
||||
slug="test",
|
||||
provider_type="wechat",
|
||||
)
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_enroll_context(self):
|
||||
"""Test WeChat Enrollment context"""
|
||||
ak_context = WeChatType().get_base_user_properties(
|
||||
source=self.source, info=WECHAT_USER, client=None, token={}
|
||||
)
|
||||
self.assertEqual(ak_context["username"], WECHAT_USER["unionid"])
|
||||
self.assertIsNone(ak_context["email"])
|
||||
self.assertEqual(ak_context["name"], WECHAT_USER["nickname"])
|
||||
self.assertEqual(ak_context["attributes"]["openid"], WECHAT_USER["openid"])
|
||||
self.assertEqual(ak_context["attributes"]["unionid"], WECHAT_USER["unionid"])
|
||||
|
||||
def test_enroll_context_no_unionid(self):
|
||||
"""Test WeChat Enrollment context without unionid"""
|
||||
user = WECHAT_USER.copy()
|
||||
del user["unionid"]
|
||||
ak_context = WeChatType().get_base_user_properties(
|
||||
source=self.source, info=user, client=None, token={}
|
||||
)
|
||||
self.assertEqual(ak_context["username"], WECHAT_USER["openid"])
|
||||
self.assertIsNone(ak_context["email"])
|
||||
self.assertEqual(ak_context["name"], WECHAT_USER["nickname"])
|
||||
@@ -1,162 +0,0 @@
|
||||
"""WeChat (Weixin) OAuth Views"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from requests.exceptions import RequestException
|
||||
|
||||
from authentik.sources.oauth.clients.oauth2 import OAuth2Client
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
from authentik.sources.oauth.types.registry import SourceType, registry
|
||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||
|
||||
|
||||
class WeChatOAuthRedirect(OAuthRedirect):
|
||||
"""WeChat OAuth2 Redirect"""
|
||||
|
||||
def get_additional_parameters(self, source: OAuthSource): # pragma: no cover
|
||||
# WeChat (Weixin) for Websites official documentation requires 'snsapi_login'
|
||||
# as the *only* scope for the QR code-based login flow.
|
||||
# Ref: https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html (Step 1) # noqa: E501
|
||||
return {
|
||||
"scope": ["snsapi_login"],
|
||||
}
|
||||
|
||||
|
||||
class WeChatOAuth2Client(OAuth2Client):
|
||||
"""
|
||||
WeChat OAuth2 Client
|
||||
|
||||
Handles the non-standard parts of the WeChat OAuth2 flow.
|
||||
"""
|
||||
|
||||
def get_access_token(self, redirect_uri: str, code: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get access token from WeChat.
|
||||
|
||||
WeChat uses a non-standard GET request for the token exchange,
|
||||
unlike the standard OAuth2 POST request. The AppID (client_id)
|
||||
and AppSecret (client_secret) are passed as URL query parameters.
|
||||
"""
|
||||
token_url = self.get_access_token_url()
|
||||
params = {
|
||||
"appid": self.get_client_id(),
|
||||
"secret": self.get_client_secret(),
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
|
||||
# Send the GET request using the base class's session handler
|
||||
response = self.do_request("get", token_url, params=params)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
self.logger.warning("Unable to fetch wechat token", exc=exc)
|
||||
raise exc
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Handle WeChat's specific error format (JSON with 'errcode' and 'errmsg')
|
||||
if "errcode" in data:
|
||||
self.logger.warning(
|
||||
"Unable to fetch wechat token",
|
||||
errcode=data.get("errcode"),
|
||||
errmsg=data.get("errmsg"),
|
||||
)
|
||||
raise RequestException(data.get("errmsg"))
|
||||
|
||||
return data
|
||||
|
||||
def get_profile_info(self, token: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Get Userinfo from WeChat.
|
||||
|
||||
This API call requires both the 'access_token' and the 'openid'
|
||||
(which was returned during the token exchange).
|
||||
"""
|
||||
profile_url = self.get_profile_url()
|
||||
params = {
|
||||
"access_token": token.get("access_token"),
|
||||
"openid": token.get("openid"),
|
||||
"lang": "en", # or 'zh_CN' (Simplified Chinese), 'zh_TW' (Traditional)
|
||||
}
|
||||
|
||||
response = self.do_request("get", profile_url, params=params)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
except RequestException as exc:
|
||||
self.logger.warning("Unable to fetch wechat userinfo", exc=exc)
|
||||
raise exc
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Handle WeChat's specific error format
|
||||
if "errcode" in data:
|
||||
self.logger.warning(
|
||||
"Unable to fetch wechat userinfo",
|
||||
errcode=data.get("errcode"),
|
||||
errmsg=data.get("errmsg"),
|
||||
)
|
||||
raise RequestException(data.get("errmsg"))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class WeChatOAuth2Callback(OAuthCallback):
|
||||
"""WeChat OAuth2 Callback"""
|
||||
|
||||
# Specify our custom Client to handle the non-standard WeChat flow
|
||||
client_class = WeChatOAuth2Client
|
||||
|
||||
|
||||
@registry.register()
|
||||
class WeChatType(SourceType):
|
||||
"""WeChat Type definition"""
|
||||
|
||||
callback_view = WeChatOAuth2Callback
|
||||
redirect_view = WeChatOAuthRedirect
|
||||
verbose_name = "WeChat"
|
||||
name = "wechat"
|
||||
|
||||
# WeChat API URLs are fixed and not customizable
|
||||
urls_customizable = False
|
||||
|
||||
# URLs for the WeChat "Login for Websites" authorization flow
|
||||
authorization_url = "https://open.weixin.qq.com/connect/qrconnect"
|
||||
# nosec: B105 This is a public URL, not a hardcoded secret
|
||||
access_token_url = "https://api.weixin.qq.com/sns/oauth2/access_token" # nosec
|
||||
profile_url = "https://api.weixin.qq.com/sns/userinfo"
|
||||
|
||||
# Note: 'authorization_code_auth_method' is intentionally omitted.
|
||||
# The base OAuth2Client defaults to POST_BODY, but our custom
|
||||
# WeChatOAuth2Client overrides get_access_token() to use GET,
|
||||
# so this setting would be misleading.
|
||||
|
||||
def get_base_user_properties(self, info: dict[str, Any], **kwargs) -> dict[str, Any]:
|
||||
"""
|
||||
Map WeChat userinfo to authentik user properties.
|
||||
"""
|
||||
# The WeChat userinfo API (sns/userinfo) does *not* return an email address.
|
||||
# We explicitly set 'email' to None. Authentik will typically
|
||||
# prompt the user to provide one on their first login if it's required.
|
||||
|
||||
# 'unionid' is the preferred unique identifier as it's consistent
|
||||
# across multiple apps under the same WeChat Open Platform account.
|
||||
# 'openid' is the fallback, which is only unique to this specific AppID.
|
||||
return {
|
||||
"username": info.get("unionid", info.get("openid")),
|
||||
"email": None, # WeChat API does not provide Email
|
||||
"name": info.get("nickname"),
|
||||
"attributes": {
|
||||
# Save all other relevant info as user attributes
|
||||
"headimgurl": info.get("headimgurl"),
|
||||
"sex": info.get("sex"),
|
||||
"city": info.get("city"),
|
||||
"province": info.get("province"),
|
||||
"country": info.get("country"),
|
||||
"unionid": info.get("unionid"),
|
||||
"openid": info.get("openid"),
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.paginator import Page, Paginator
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.http import HttpRequest
|
||||
@@ -85,9 +86,10 @@ class SCIMView(APIView):
|
||||
)
|
||||
|
||||
def paginate_query(self, query: QuerySet) -> Page:
|
||||
per_page = int(self.request.tenant.pagination_default_page_size)
|
||||
per_page = 50
|
||||
start_index = 1
|
||||
try:
|
||||
per_page = int(settings.REST_FRAMEWORK["PAGE_SIZE"])
|
||||
start_index = int(self.request.query_params.get("startIndex", 1))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -37,7 +37,7 @@ class ServiceProviderConfigView(SCIMView):
|
||||
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
|
||||
"filter": {
|
||||
"supported": True,
|
||||
"maxResults": request.tenant.pagination_default_page_size,
|
||||
"maxResults": int(settings.REST_FRAMEWORK["PAGE_SIZE"]),
|
||||
},
|
||||
"changePassword": {"supported": False},
|
||||
"sort": {"supported": False},
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.generics import get_object_or_404
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from authentik.core.api.sources import SourceSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.sources.telegram.api.source_connection import UserTelegramSourceConnectionSerializer
|
||||
from authentik.sources.telegram.models import TelegramSource, UserTelegramSourceConnection
|
||||
from authentik.sources.telegram.telegram import TelegramAuth
|
||||
from authentik.sources.telegram.models import TelegramSource
|
||||
|
||||
|
||||
class TelegramSourceSerializer(SourceSerializer):
|
||||
@@ -28,16 +19,6 @@ class TelegramSourceSerializer(SourceSerializer):
|
||||
}
|
||||
|
||||
|
||||
class TelegramAuthSerializer(TelegramAuth):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._bot_token = kwargs.pop("bot_token", None)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_bot_token(self):
|
||||
return self._bot_token
|
||||
|
||||
|
||||
class TelegramSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = TelegramSource.objects.all()
|
||||
serializer_class = TelegramSourceSerializer
|
||||
@@ -58,38 +39,3 @@ class TelegramSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
]
|
||||
search_fields = ["name", "slug"]
|
||||
ordering = ["name"]
|
||||
|
||||
@extend_schema(
|
||||
request=TelegramAuthSerializer,
|
||||
responses={
|
||||
201: UserTelegramSourceConnectionSerializer,
|
||||
403: OpenApiResponse(description="Access denied"),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
url_path="connect_user",
|
||||
detail=True,
|
||||
pagination_class=None,
|
||||
filter_backends=[],
|
||||
permission_classes=[IsAuthenticated],
|
||||
)
|
||||
def connect_user(self, request: Request, slug: str) -> Response:
|
||||
|
||||
source: TelegramSource = get_object_or_404(TelegramSource, slug=slug)
|
||||
serializer = TelegramAuthSerializer(bot_token=source.bot_token, data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
connection, created = UserTelegramSourceConnection.objects.get_or_create(
|
||||
source=source,
|
||||
identifier=serializer.validated_data["id"],
|
||||
defaults={"user": request.user},
|
||||
)
|
||||
if not created and connection.user != request.user:
|
||||
return Response(
|
||||
data={"detail": _("This Telegram account is already connected to another user.")},
|
||||
status=403,
|
||||
)
|
||||
return Response(
|
||||
data=UserTelegramSourceConnectionSerializer(instance=connection).data, status=201
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Telegram source"""
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
@@ -76,12 +75,6 @@ class TelegramSource(Source):
|
||||
"title": self.name,
|
||||
"component": "ak-user-settings-source-telegram",
|
||||
"icon_url": self.icon_url,
|
||||
"configure_url": urlencode(
|
||||
{
|
||||
"bot_username": self.bot_username,
|
||||
"request_message_access": str(self.request_message_access),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.fields import BooleanField, CharField
|
||||
from rest_framework.fields import BooleanField, CharField, IntegerField, URLField
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from authentik.flows.challenge import Challenge, ChallengeResponse
|
||||
from authentik.sources.telegram.telegram import TelegramAuth
|
||||
from authentik.stages.identification.stage import LoginChallengeMixin
|
||||
|
||||
|
||||
@@ -12,15 +16,33 @@ class TelegramLoginChallenge(LoginChallengeMixin, Challenge):
|
||||
request_message_access = BooleanField()
|
||||
|
||||
|
||||
class TelegramChallengeResponse(TelegramAuth, ChallengeResponse):
|
||||
class TelegramChallengeResponse(ChallengeResponse):
|
||||
component = CharField(default="ak-source-telegram")
|
||||
|
||||
def get_bot_token(self) -> str:
|
||||
return self.stage.source.bot_token
|
||||
id = IntegerField()
|
||||
first_name = CharField(max_length=255, required=False)
|
||||
last_name = CharField(max_length=255, required=False)
|
||||
username = CharField(max_length=255, required=False)
|
||||
photo_url = URLField(required=False)
|
||||
auth_date = IntegerField(required=True)
|
||||
hash = CharField(max_length=64, required=True)
|
||||
|
||||
def validate_auth_date(self, auth_date: int) -> int:
|
||||
if datetime.fromtimestamp(auth_date) < datetime.now() - timedelta(minutes=5):
|
||||
raise ValidationError(_("Authentication date is too old"))
|
||||
return auth_date
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
# Check the response as defined in https://core.telegram.org/widgets/login
|
||||
attrs_to_check = attrs.copy()
|
||||
component = attrs_to_check.pop("component")
|
||||
validated = super().validate(attrs_to_check)
|
||||
validated["component"] = component
|
||||
return validated
|
||||
attrs_to_check.pop("component")
|
||||
attrs_to_check.pop("hash")
|
||||
check_str = "\n".join([f"{key}={value}" for key, value in sorted(attrs_to_check.items())])
|
||||
digest = hmac.new(
|
||||
hashlib.sha256(self.stage.source.bot_token.encode("utf-8")).digest(),
|
||||
check_str.encode("utf-8"),
|
||||
"sha256",
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(digest, attrs["hash"]):
|
||||
raise ValidationError(_("Invalid hash"))
|
||||
return attrs
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.fields import CharField, IntegerField, URLField
|
||||
from rest_framework.serializers import Serializer, ValidationError
|
||||
|
||||
|
||||
class TelegramAuth(Serializer):
|
||||
id = IntegerField()
|
||||
first_name = CharField(max_length=255, required=False)
|
||||
last_name = CharField(max_length=255, required=False)
|
||||
username = CharField(max_length=255, required=False)
|
||||
photo_url = URLField(required=False)
|
||||
auth_date = IntegerField(required=True)
|
||||
hash = CharField(max_length=64, required=True)
|
||||
|
||||
def validate_auth_date(self, auth_date: int) -> int:
|
||||
if datetime.fromtimestamp(auth_date) < datetime.now() - timedelta(minutes=5):
|
||||
raise ValidationError(_("Authentication date is too old"))
|
||||
return auth_date
|
||||
|
||||
def validate(self, attrs: dict) -> dict:
|
||||
# Check the response as defined in https://core.telegram.org/widgets/login
|
||||
check_str = "\n".join(
|
||||
[f"{key}={value}" for key, value in sorted(attrs.items()) if key != "hash"]
|
||||
)
|
||||
digest = hmac.new(
|
||||
hashlib.sha256(self.get_bot_token().encode("utf-8")).digest(),
|
||||
check_str.encode("utf-8"),
|
||||
"sha256",
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(digest, attrs["hash"]):
|
||||
raise ValidationError(_("Invalid hash"))
|
||||
return attrs
|
||||
@@ -9,10 +9,9 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.sources.telegram.models import UserTelegramSourceConnection
|
||||
from authentik.sources.telegram.stage import TelegramChallengeResponse
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
@@ -184,23 +183,3 @@ class TestTelegramViews(MockTelegramResponseMixin, FlowTestCase):
|
||||
"authentik_core:if-flow", kwargs={"flow_slug": self.source.enrollment_flow.slug}
|
||||
),
|
||||
)
|
||||
|
||||
def test_connect_user(self):
|
||||
user = create_test_user("testuser")
|
||||
user2 = create_test_user("testuser2")
|
||||
self.client.force_login(user)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:telegramsource-connect-user", args=[self.source.slug]),
|
||||
self._make_valid_response(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertTrue(
|
||||
UserTelegramSourceConnection.objects.filter(user=user, source=self.source).exists()
|
||||
)
|
||||
self.client.logout()
|
||||
self.client.force_login(user2)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:telegramsource-connect-user", args=[self.source.slug]),
|
||||
self._make_valid_response(),
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
@@ -152,17 +152,11 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
|
||||
return device
|
||||
|
||||
|
||||
def validate_challenge_webauthn(
|
||||
data: dict,
|
||||
stage_view: StageView,
|
||||
user: User,
|
||||
stage: AuthenticatorValidateStage | None = None,
|
||||
) -> Device:
|
||||
def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device:
|
||||
"""Validate WebAuthn Challenge"""
|
||||
request = stage_view.request
|
||||
challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE)
|
||||
stage = stage or stage_view.executor.current_stage
|
||||
|
||||
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
|
||||
if "MinuteMaid" in request.META.get("HTTP_USER_AGENT", ""):
|
||||
# Workaround for Android sign-in, when signing into Google Workspace on android while
|
||||
# adding the account to the system (not in Chrome), for some reason `type` is not set
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -255,7 +255,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
|
||||
def test_register_restricted_device_type_allow_unknown(self):
|
||||
"""Test registration with restricted devices (allow, unknown device type)"""
|
||||
webauthn_mds_import.send(force=True)
|
||||
WebAuthnDeviceType.objects.filter(aaguid="fbfc3007-154e-4ecc-8c0b-6e020557d7bd").delete()
|
||||
WebAuthnDeviceType.objects.filter(description="iCloud Keychain").delete()
|
||||
self.stage.device_type_restrictions.set(
|
||||
WebAuthnDeviceType.objects.filter(aaguid=UNKNOWN_DEVICE_TYPE_AAGUID)
|
||||
)
|
||||
|
||||
@@ -18,13 +18,6 @@
|
||||
{{ body }}
|
||||
</td>
|
||||
</tr>
|
||||
{% if link %}
|
||||
<tr>
|
||||
<td align="center" class="btn btn-primary">
|
||||
<a id="confirm" href="{{ link.target }}" rel="noopener noreferrer" target="_blank">{{ link.label }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% if key_value %}
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
@@ -37,7 +37,6 @@ class IdentificationStageSerializer(StageSerializer):
|
||||
"show_source_labels",
|
||||
"pretend_user_exists",
|
||||
"enable_remember_me",
|
||||
"webauthn_stage",
|
||||
]
|
||||
|
||||
|
||||
@@ -50,7 +49,6 @@ class IdentificationStageViewSet(UsedByMixin, ModelViewSet):
|
||||
"name",
|
||||
"password_stage",
|
||||
"captcha_stage",
|
||||
"webauthn_stage",
|
||||
"case_insensitive_matching",
|
||||
"show_matched_user",
|
||||
"enrollment_flow",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user