mirror of
https://github.com/goauthentik/authentik
synced 2026-05-06 07:02:51 +02:00
Compare commits
100 Commits
saml-sourc
...
npm-corepa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a544493a | ||
|
|
d2e0d62cb8 | ||
|
|
afd1b7f9e0 | ||
|
|
479d4785b6 | ||
|
|
3e524a68ff | ||
|
|
8bf756ed40 | ||
|
|
d537aeecd6 | ||
|
|
5f614e1f48 | ||
|
|
2a0d867e10 | ||
|
|
da572834fd | ||
|
|
67fa7bc6e6 | ||
|
|
6be7b2f7b7 | ||
|
|
7cffbb4d07 | ||
|
|
5d629bec9b | ||
|
|
5357f42029 | ||
|
|
716bc6e136 | ||
|
|
60355fdf80 | ||
|
|
828a380569 | ||
|
|
b04f8a6177 | ||
|
|
ff190847f2 | ||
|
|
a7339c7f87 | ||
|
|
38ae472f6c | ||
|
|
7d0656c6fa | ||
|
|
0bbe415b5b | ||
|
|
e52c1b2bdc | ||
|
|
5064167f28 | ||
|
|
bca0f51b53 | ||
|
|
67c197e5a5 | ||
|
|
32b17da699 | ||
|
|
c75eed630a | ||
|
|
9f17d6df96 | ||
|
|
13c8ad5c56 | ||
|
|
28209c03e2 | ||
|
|
f47cf08d8a | ||
|
|
d69433b314 | ||
|
|
849a6053ad | ||
|
|
abdbe0269f | ||
|
|
55384c384a | ||
|
|
06fd68f076 | ||
|
|
d35ab99b2d | ||
|
|
a3b0180049 | ||
|
|
88a545f4fb | ||
|
|
ba62507fc2 | ||
|
|
82fc2e2c80 | ||
|
|
80b3739640 | ||
|
|
1258e1eada | ||
|
|
96ed17e760 | ||
|
|
4b17468b6e | ||
|
|
c834681251 | ||
|
|
9edd7cfbda | ||
|
|
4851179522 | ||
|
|
685f920de2 | ||
|
|
3b4d51b0c5 | ||
|
|
a1098d00b7 | ||
|
|
0d4984b964 | ||
|
|
38330df1f9 | ||
|
|
8b03c36d5a | ||
|
|
07a53a101c | ||
|
|
a3db2ce6a3 | ||
|
|
5487cdb874 | ||
|
|
2d5160d09b | ||
|
|
973fe0bd65 | ||
|
|
58b5e605de | ||
|
|
626e23b87a | ||
|
|
3559beba9c | ||
|
|
0b6d3a2850 | ||
|
|
56ca192391 | ||
|
|
6df62aaa2a | ||
|
|
ca344a64c4 | ||
|
|
a0cdd81f71 | ||
|
|
8eff4c7e0b | ||
|
|
d241a0e8f1 | ||
|
|
ebfc01fcda | ||
|
|
4b0e8a411b | ||
|
|
9bf6595fc6 | ||
|
|
5c07e845d2 | ||
|
|
4f76232e7c | ||
|
|
846f8a7e30 | ||
|
|
fa1c3490c3 | ||
|
|
a35edf7d0f | ||
|
|
9d4d5b7133 | ||
|
|
8d91a76bc9 | ||
|
|
6910428a93 | ||
|
|
cb181d388a | ||
|
|
aad4b6f925 | ||
|
|
821b74d7c1 | ||
|
|
8963d29ab4 | ||
|
|
699360064e | ||
|
|
3f94f830fc | ||
|
|
aaba353a9e | ||
|
|
abdff1c877 | ||
|
|
16fd8183b0 | ||
|
|
d3eaa3a4d9 | ||
|
|
02aba83017 | ||
|
|
e78c43e9d9 | ||
|
|
d6c0ae21de | ||
|
|
2c35df35b6 | ||
|
|
90d4f4296b | ||
|
|
bf7747268b | ||
|
|
552cb78458 |
81
.github/actions/setup-node/action.yml
vendored
Normal file
81
.github/actions/setup-node/action.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: "Setup Node.js and NPM"
|
||||
description: "Sets up Node.js with a specific NPM version via Corepack"
|
||||
inputs:
|
||||
working-directory:
|
||||
description: "Path to the working directory containing the package.json file"
|
||||
required: false
|
||||
default: "."
|
||||
dependencies:
|
||||
required: false
|
||||
description: "List of dependencies to setup"
|
||||
default: "monorepo,working-directory"
|
||||
node-version-file:
|
||||
description: "Path to file containing the Node.js version"
|
||||
required: false
|
||||
default: "package.json"
|
||||
cache-dependency-path:
|
||||
description: "Path to dependency lock file for caching"
|
||||
required: false
|
||||
default: "package-lock.json"
|
||||
cache:
|
||||
description: "Package manager to cache"
|
||||
default: "npm"
|
||||
registry-url:
|
||||
description: "npm registry URL"
|
||||
default: "https://registry.npmjs.org"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Node.js (Corepack bootstrap)
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
with:
|
||||
node-version-file: ${{ inputs.node-version-file }}
|
||||
registry-url: ${{ inputs.registry-url }}
|
||||
# The setup-node action will attempt to create a cache using a version of
|
||||
# npm that may not be compatible with the range specified in package.json.
|
||||
# This can be enabled **after** corepack is installed and the correct npm version is available.
|
||||
package-manager-cache: false
|
||||
- name: Install Corepack
|
||||
working-directory: ${{ github.workspace}}
|
||||
shell: bash
|
||||
run: | #shell
|
||||
node ./scripts/node/lint-runtime.mjs
|
||||
node ./scripts/node/setup-corepack.mjs --force
|
||||
corepack enable
|
||||
- name: Lint Node.js and NPM versions
|
||||
shell: bash
|
||||
run: node ./scripts/node/lint-runtime.mjs
|
||||
- name: Setup Node.js (Monorepo Root)
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
with:
|
||||
node-version-file: ${{ inputs.node-version-file }}
|
||||
cache: ${{ inputs.cache }}
|
||||
cache-dependency-path: ${{ inputs.cache-dependency-path }}
|
||||
registry-url: ${{ inputs.registry-url }}
|
||||
- name: Install monorepo dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'monorepo') }}
|
||||
shell: bash
|
||||
run: | #shell
|
||||
node ./scripts/node/lint-lockfile.mjs
|
||||
corepack npm ci
|
||||
- name: Setup Node.js (Working Directory)
|
||||
if: ${{ contains(inputs.dependencies, 'working-directory') }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
with:
|
||||
node-version-file: ${{ inputs.working-directory }}/${{ inputs.node-version-file }}
|
||||
cache: ${{ inputs.cache }}
|
||||
cache-dependency-path: ${{ inputs.working-directory }}/${{ inputs.cache-dependency-path }}
|
||||
registry-url: ${{ inputs.registry-url }}
|
||||
|
||||
- name: Install working directory dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'working-directory') }}
|
||||
shell: bash
|
||||
run: | # shell
|
||||
corepack install
|
||||
|
||||
echo "node version: $(node --version)"
|
||||
echo "npm version: $(corepack npm --version)"
|
||||
|
||||
node ./scripts/node/lint-lockfile.mjs ${{ inputs.working-directory }}
|
||||
corepack npm ci --prefix ${{ inputs.working-directory }}
|
||||
48
.github/actions/setup/action.yml
vendored
48
.github/actions/setup/action.yml
vendored
@@ -18,19 +18,24 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Cleanup apt
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies,
|
||||
'python') }}
|
||||
shell: bash
|
||||
run: sudo apt-get remove --purge man-db
|
||||
- name: Install apt deps
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies,
|
||||
'python') }}
|
||||
uses: gerlero/apt-install@f4fa5265092af9e750549565d28c99aec7189639
|
||||
with:
|
||||
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
|
||||
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev
|
||||
libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user
|
||||
krb5-admin-server
|
||||
update: true
|
||||
upgrade: false
|
||||
install-recommends: false
|
||||
- name: Make space on disk
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
|
||||
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies,
|
||||
'python') }}
|
||||
shell: bash
|
||||
run: |
|
||||
sudo mkdir -p /tmp/empty/
|
||||
@@ -51,7 +56,8 @@ runs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: uv sync --all-extras --dev --frozen
|
||||
- name: Setup rust (stable)
|
||||
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies, 'rust-nightly') }}
|
||||
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies,
|
||||
'rust-nightly') }}
|
||||
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1
|
||||
with:
|
||||
rustflags: ""
|
||||
@@ -64,30 +70,14 @@ runs:
|
||||
rustflags: ""
|
||||
- name: Setup rust dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'rust') }}
|
||||
uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2
|
||||
uses: taiki-e/install-action@b5fddbb5361bce8a06fb168c9d403a6cc552b084 # v2
|
||||
with:
|
||||
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
|
||||
- name: Setup node (web)
|
||||
- name: Setup node (root, web)
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: "${{ inputs.working-directory }}web/package.json"
|
||||
cache: "npm"
|
||||
cache-dependency-path: "${{ inputs.working-directory }}web/package-lock.json"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Setup node (root)
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
|
||||
with:
|
||||
node-version-file: "${{ inputs.working-directory }}package.json"
|
||||
cache: "npm"
|
||||
cache-dependency-path: "${{ inputs.working-directory }}package-lock.json"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
- name: Install Node deps
|
||||
if: ${{ contains(inputs.dependencies, 'node') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: npm ci
|
||||
working-directory: web
|
||||
- name: Setup go
|
||||
if: ${{ contains(inputs.dependencies, 'go') }}
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5
|
||||
@@ -97,15 +87,17 @@ runs:
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
|
||||
with:
|
||||
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
|
||||
key: docker-images-${{ runner.os }}-${{
|
||||
hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{
|
||||
inputs.postgresql_version }}
|
||||
- name: Setup dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||
docker compose -f .github/actions/setup/compose.yml up -d
|
||||
cd web && npm ci
|
||||
docker compose -f .github/actions/setup/compose.yml up -d --wait
|
||||
corepack npm ci --prefix web
|
||||
- name: Generate config
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
shell: uv run python {0}
|
||||
|
||||
6
.github/actions/setup/compose.yml
vendored
6
.github/actions/setup/compose.yml
vendored
@@ -8,8 +8,14 @@ services:
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||
POSTGRES_DB: authentik
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
ports:
|
||||
- 5432:5432
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} -h 127.0.0.1"]
|
||||
interval: 1s
|
||||
timeout: 5s
|
||||
retries: 60
|
||||
restart: always
|
||||
s3:
|
||||
container_name: s3
|
||||
|
||||
@@ -67,6 +67,16 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
working-directory: web
|
||||
dependencies: "monorepo"
|
||||
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Generate API Clients
|
||||
run: |
|
||||
make gen-client-ts
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
id: push
|
||||
@@ -81,7 +91,8 @@ jobs:
|
||||
${{ steps.ev.outputs.imageBuildArgs }}
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
platforms: linux/${{ inputs.image_arch }}
|
||||
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
|
||||
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames
|
||||
}}:buildcache-${{ inputs.image_arch }}
|
||||
cache-to: ${{ steps.ev.outputs.cacheTo }}
|
||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
||||
id: attest
|
||||
|
||||
2
.github/workflows/_reusable-docker-build.yml
vendored
2
.github/workflows/_reusable-docker-build.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: int128/docker-manifest-create-action@7df7f9e221d927eaadf87db231ddf728047308a4 # v2
|
||||
- uses: int128/docker-manifest-create-action@fa55f72001a6c74b0f4997dca65c70d334905180 # v2
|
||||
id: build
|
||||
with:
|
||||
tags: ${{ matrix.tag }}
|
||||
|
||||
65
.github/workflows/api-ts-publish.yml
vendored
Normal file
65
.github/workflows/api-ts-publish.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: API - Publish Typescript client
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "schema.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
# Required for NPM OIDC trusted publisher
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: generate_token
|
||||
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
working-directory: web
|
||||
- name: Generate API Client
|
||||
run: make gen-client-ts
|
||||
- name: Publish package
|
||||
working-directory: gen-ts-api/
|
||||
run: |
|
||||
npm i
|
||||
npm publish --tag generated
|
||||
- name: Upgrade /web
|
||||
working-directory: web
|
||||
run: |
|
||||
export VERSION=`node -e 'import mod from "./gen-ts-api/package.json" with { type: "json" };console.log(mod.version);'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- name: Upgrade /web/packages/sfe
|
||||
working-directory: web/packages/sfe
|
||||
run: |
|
||||
export VERSION=`node -e 'import mod from "./gen-ts-api/package.json" with { type: "json" };console.log(mod.version);'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
|
||||
id: cpr
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
branch: update-web-api-client
|
||||
commit-message: "web: bump API Client version"
|
||||
title: "web: bump API Client version"
|
||||
body: "web: bump API Client version"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
|
||||
labels: dependencies
|
||||
- uses: peter-evans/enable-pull-request-automerge@a660677d5469627102a1c1e11409dd063606628d # v3
|
||||
with:
|
||||
token: ${{ steps.generate_token.outputs.token }}
|
||||
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||
merge-method: squash
|
||||
26
.github/workflows/ci-api-docs.yml
vendored
26
.github/workflows/ci-api-docs.yml
vendored
@@ -22,25 +22,19 @@ jobs:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- name: Install Dependencies
|
||||
working-directory: website/
|
||||
run: npm ci
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
working-directory: website
|
||||
- name: Lint
|
||||
working-directory: website/
|
||||
run: npm run ${{ matrix.command }}
|
||||
run: corepack npm run ${{ matrix.command }} --prefix website
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
name: Install Dependencies
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
with:
|
||||
path: |
|
||||
@@ -54,7 +48,7 @@ jobs:
|
||||
working-directory: website
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: npm run build -w api
|
||||
run: corepack npm run build -w api
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
|
||||
with:
|
||||
name: api-docs
|
||||
@@ -71,11 +65,9 @@ jobs:
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: website/package-lock.json
|
||||
working-directory: website
|
||||
- name: Deploy Netlify (Production)
|
||||
working-directory: website/api
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
|
||||
9
.github/workflows/ci-aws-cfn.yml
vendored
9
.github/workflows/ci-aws-cfn.yml
vendored
@@ -24,14 +24,9 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: lifecycle/aws/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: lifecycle/aws/package-lock.json
|
||||
- working-directory: lifecycle/aws/
|
||||
run: |
|
||||
npm ci
|
||||
working-directory: lifecycle/aws
|
||||
- name: Check changes have been applied
|
||||
run: |
|
||||
uv run make aws-cfn
|
||||
|
||||
38
.github/workflows/ci-docs.yml
vendored
38
.github/workflows/ci-docs.yml
vendored
@@ -24,46 +24,34 @@ jobs:
|
||||
- prettier-check
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- name: Install dependencies
|
||||
working-directory: website/
|
||||
run: npm ci
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
working-directory: website
|
||||
- name: Lint
|
||||
working-directory: website/
|
||||
run: npm run ${{ matrix.command }}
|
||||
run: corepack npm run ${{ matrix.command }} --prefix website
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
name: Setup Node.js
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
name: Install Dependencies
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
- name: Build Documentation via Docusaurus
|
||||
working-directory: website/
|
||||
run: npm run build
|
||||
run: corepack npm run build --prefix website
|
||||
build-integrations:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NODE_ENV: production
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: website/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
name: Install Dependencies
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
- name: Build Integrations via Docusaurus
|
||||
working-directory: website/
|
||||
run: npm run build -w integrations
|
||||
run: corepack npm run build -w integrations --prefix website
|
||||
build-container:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -104,7 +92,9 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' &&
|
||||
'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max'
|
||||
|| '' }}
|
||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
|
||||
63
.github/workflows/ci-main.yml
vendored
63
.github/workflows/ci-main.yml
vendored
@@ -73,7 +73,8 @@ jobs:
|
||||
- name: generate API clients
|
||||
run: make gen-clients
|
||||
- name: ensure schema is up-to-date
|
||||
run: git diff --exit-code -- schema.yml blueprints/schema.json packages/client-go packages/client-rust packages/client-ts
|
||||
run: git diff --exit-code -- schema.yml blueprints/schema.json
|
||||
packages/client-go packages/client-rust packages/client-ts
|
||||
test-migrations:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -91,7 +92,8 @@ jobs:
|
||||
outputs:
|
||||
seed: ${{ steps.seed.outputs.seed }}
|
||||
test-migrations-from-stable:
|
||||
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
|
||||
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{
|
||||
matrix.run_id }}/5
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: test-make-seed
|
||||
@@ -101,7 +103,7 @@ jobs:
|
||||
psql:
|
||||
- 14-alpine
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
run_id: [ 1, 2, 3, 4, 5 ]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
@@ -109,8 +111,13 @@ jobs:
|
||||
- name: checkout stable
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
|
||||
cp -R .github ..
|
||||
cp -R scripts ..
|
||||
|
||||
mkdir -p ../packages
|
||||
cp -R packages/logger-js ../packages/logger-js
|
||||
|
||||
# Previous stable tag
|
||||
prev_stable=$(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
|
||||
# Current version family based on
|
||||
@@ -118,10 +125,13 @@ jobs:
|
||||
if [[ -n $current_version_family ]]; then
|
||||
prev_stable="version/${current_version_family}"
|
||||
fi
|
||||
|
||||
echo "::notice::Checking out ${prev_stable} as stable version..."
|
||||
git checkout ${prev_stable}
|
||||
rm -rf .github/ scripts/
|
||||
|
||||
rm -rf .github/ scripts/ packages/logger-js/
|
||||
mv ../.github ../scripts .
|
||||
mv ../packages/logger-js ./packages/
|
||||
- name: Setup authentik env (stable)
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -169,7 +179,7 @@ jobs:
|
||||
psql:
|
||||
- 14-alpine
|
||||
- 18-alpine
|
||||
run_id: [1, 2, 3, 4, 5]
|
||||
run_id: [ 1, 2, 3, 4, 5 ]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- name: Setup authentik env
|
||||
@@ -252,19 +262,22 @@ jobs:
|
||||
COMPOSE_PROFILES: ${{ matrix.job.profiles }}
|
||||
run: |
|
||||
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
|
||||
- uses: ./.github/actions/setup-node
|
||||
- id: cache-web
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
if: contains(matrix.job.profiles, 'selenium')
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json',
|
||||
'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true' && contains(matrix.job.profiles, 'selenium')
|
||||
if: steps.cache-web.outputs.cache-hit != 'true' && contains(matrix.job.profiles,
|
||||
'selenium')
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm run build:sfe
|
||||
corepack npm ci
|
||||
corepack npm run build
|
||||
corepack npm run build:sfe
|
||||
- name: run e2e
|
||||
run: |
|
||||
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
||||
@@ -282,10 +295,18 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- name: basic
|
||||
glob: tests/openid_conformance/test_basic.py
|
||||
- name: implicit
|
||||
glob: tests/openid_conformance/test_implicit.py
|
||||
- name: oidc_basic
|
||||
glob: tests/openid_conformance/test_oidc_basic.py
|
||||
- name: oidc_implicit
|
||||
glob: tests/openid_conformance/test_oidc_implicit.py
|
||||
- name: oidc_rp-initiated
|
||||
glob: tests/openid_conformance/test_oidc_rp_initiated.py
|
||||
- name: oidc_frontchannel
|
||||
glob: tests/openid_conformance/test_oidc_frontchannel.py
|
||||
- name: oidc_backchannel
|
||||
glob: tests/openid_conformance/test_oidc_backchannel.py
|
||||
- name: ssf_transmitter
|
||||
glob: tests/openid_conformance/test_ssf_transmitter.py
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- name: Setup authentik env
|
||||
@@ -302,14 +323,14 @@ jobs:
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**',
|
||||
'web/packages/sfe/src/**') }}-b
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm run build:sfe
|
||||
corepack npm ci --prefix web
|
||||
corepack npm run build --prefix web
|
||||
corepack npm run build:sfe --prefix web
|
||||
- name: run conformance
|
||||
run: |
|
||||
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
||||
@@ -375,7 +396,9 @@ jobs:
|
||||
uses: ./.github/workflows/_reusable-docker-build.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }}
|
||||
image_name: ${{ github.repository == 'goauthentik/authentik-internal' &&
|
||||
'ghcr.io/goauthentik/internal-server' ||
|
||||
'ghcr.io/goauthentik/dev-server' }}
|
||||
release: false
|
||||
pr-comment:
|
||||
needs:
|
||||
|
||||
22
.github/workflows/ci-outpost.yml
vendored
22
.github/workflows/ci-outpost.yml
vendored
@@ -114,8 +114,11 @@ jobs:
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type
|
||||
}}:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' &&
|
||||
format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max',
|
||||
matrix.type) || '' }}
|
||||
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
@@ -136,8 +139,8 @@ jobs:
|
||||
- ldap
|
||||
- radius
|
||||
- rac
|
||||
goos: [linux]
|
||||
goarch: [amd64, arm64]
|
||||
goos: [ linux ]
|
||||
goarch: [ amd64, arm64 ]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
@@ -145,16 +148,11 @@ jobs:
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
working-directory: web
|
||||
- name: Build web
|
||||
working-directory: web/
|
||||
run: |
|
||||
npm ci
|
||||
npm run build-proxy
|
||||
run: corepack npm run build-proxy --prefix web
|
||||
- name: Build outpost
|
||||
run: |
|
||||
set -x
|
||||
|
||||
54
.github/workflows/ci-web.yml
vendored
54
.github/workflows/ci-web.yml
vendored
@@ -15,48 +15,30 @@ on:
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
command:
|
||||
- lint
|
||||
- lint:lockfile
|
||||
- tsc
|
||||
- prettier-check
|
||||
project:
|
||||
- web
|
||||
include:
|
||||
- command: tsc
|
||||
project: web
|
||||
- command: lit-analyse
|
||||
project: web
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: ${{ matrix.project }}/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: ${{ matrix.project }}/package-lock.json
|
||||
- working-directory: ${{ matrix.project }}/
|
||||
run: |
|
||||
npm ci
|
||||
working-directory: web
|
||||
- name: Lint
|
||||
working-directory: ${{ matrix.project }}/
|
||||
run: npm run ${{ matrix.command }}
|
||||
run: corepack npm run lint --prefix web
|
||||
- name: Check types
|
||||
run: corepack npm run tsc --prefix web
|
||||
- name: Check formatting
|
||||
run: corepack npm run prettier-check --prefix web
|
||||
- name: Lit analyse
|
||||
run: corepack npm run lit-analyse --prefix web
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
working-directory: web
|
||||
- name: build
|
||||
working-directory: web/
|
||||
run: npm run build
|
||||
run: corepack npm run build
|
||||
ci-web-mark:
|
||||
if: always()
|
||||
needs:
|
||||
@@ -73,13 +55,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
working-directory: web
|
||||
- name: test
|
||||
working-directory: web/
|
||||
run: npm run test || exit 0
|
||||
run: corepack npm run test || exit 0
|
||||
|
||||
15
.github/workflows/packages-npm-publish.yml
vendored
15
.github/workflows/packages-npm-publish.yml
vendored
@@ -3,7 +3,7 @@ name: Packages - Publish NPM packages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- packages/tsconfig/**
|
||||
- packages/eslint-config/**
|
||||
@@ -35,22 +35,19 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: ${{ matrix.package }}/package.json
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
working-directory: ${{ matrix.package }}
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
|
||||
with:
|
||||
files: |
|
||||
${{ matrix.package }}/package.json
|
||||
- name: Install Dependencies
|
||||
run: npm ci
|
||||
- name: Publish package
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
working-directory: ${{ matrix.package }}
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm publish
|
||||
corepack npm ci
|
||||
corepack npm run build
|
||||
corepack npm publish
|
||||
|
||||
28
.github/workflows/release-publish.yml
vendored
28
.github/workflows/release-publish.yml
vendored
@@ -3,7 +3,7 @@ name: Release - On publish
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published, created]
|
||||
types: [ published, created ]
|
||||
|
||||
jobs:
|
||||
build-server:
|
||||
@@ -87,11 +87,9 @@ jobs:
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
working-directory: web
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
- name: Set up Docker Buildx
|
||||
@@ -144,22 +142,16 @@ jobs:
|
||||
- proxy
|
||||
- ldap
|
||||
- radius
|
||||
goos: [linux, darwin]
|
||||
goarch: [amd64, arm64]
|
||||
goos: [ linux, darwin ]
|
||||
goarch: [ amd64, arm64 ]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
|
||||
- uses: ./.github/actions/setup-node
|
||||
with:
|
||||
node-version-file: web/package.json
|
||||
cache: "npm"
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Install web dependencies
|
||||
working-directory: web/
|
||||
run: |
|
||||
npm ci
|
||||
working-directory: web
|
||||
- name: Build web
|
||||
working-directory: web/
|
||||
run: |
|
||||
@@ -175,8 +167,10 @@ jobs:
|
||||
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
|
||||
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{
|
||||
matrix.goarch }}
|
||||
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{
|
||||
matrix.goarch }}
|
||||
tag: ${{ github.ref }}
|
||||
upload-aws-cfn-template:
|
||||
permissions:
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -14,6 +14,8 @@ media
|
||||
# Node
|
||||
|
||||
node_modules
|
||||
corepack.tgz
|
||||
.corepack
|
||||
|
||||
.cspellcache
|
||||
cspell-report.*
|
||||
@@ -229,6 +231,11 @@ source_docs/
|
||||
|
||||
### Golang ###
|
||||
/vendor/
|
||||
server
|
||||
proxy
|
||||
ldap
|
||||
rac
|
||||
radius
|
||||
|
||||
### Docker ###
|
||||
tests/openid_conformance/exports/*.zip
|
||||
|
||||
112
Cargo.lock
generated
112
Cargo.lock
generated
@@ -17,18 +17,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -203,6 +191,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1014,6 +1003,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "evmap"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8"
|
||||
dependencies = [
|
||||
"hashbag",
|
||||
"left-right",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
@@ -1230,6 +1230,21 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generator"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"log",
|
||||
"rustversion",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
@@ -1311,6 +1326,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbag"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
@@ -1868,6 +1889,17 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "left-right"
|
||||
version = "0.11.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
"loom",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
@@ -1939,6 +1971,19 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "loom"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"generator",
|
||||
"scoped-tls",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -1978,21 +2023,22 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "metrics"
|
||||
version = "0.24.3"
|
||||
version = "0.24.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8"
|
||||
checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"portable-atomic",
|
||||
"rapidhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "metrics-exporter-prometheus"
|
||||
version = "0.18.1"
|
||||
version = "0.18.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3589659543c04c7dc5526ec858591015b87cd8746583b51b48ef4353f99dbcda"
|
||||
checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"evmap",
|
||||
"indexmap",
|
||||
"metrics",
|
||||
"metrics-util",
|
||||
@@ -2813,6 +2859,15 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rapidhash"
|
||||
version = "4.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-cpuid"
|
||||
version = "11.6.0"
|
||||
@@ -2871,9 +2926,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -3000,9 +3055,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.39"
|
||||
version = "0.23.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -3105,6 +3160,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scoped-tls"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@@ -4515,6 +4576,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "8.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
|
||||
10
Cargo.toml
10
Cargo.toml
@@ -43,15 +43,15 @@ hyper-unix-socket = "= 0.6.1"
|
||||
hyper-util = "= 0.1.20"
|
||||
ipnet = { version = "= 2.12.0", features = ["serde"] }
|
||||
json-subscriber = "= 0.2.8"
|
||||
metrics = "= 0.24.3"
|
||||
metrics-exporter-prometheus = { version = "= 0.18.1", default-features = false }
|
||||
metrics = "= 0.24.5"
|
||||
metrics-exporter-prometheus = { version = "= 0.18.3", default-features = false }
|
||||
nix = { version = "= 0.31.2", features = ["hostname", "signal"] }
|
||||
notify = "= 8.2.0"
|
||||
pin-project-lite = "= 0.2.17"
|
||||
pyo3 = "= 0.28.3"
|
||||
pyo3-build-config = "= 0.28.3"
|
||||
regex = "= 1.12.3"
|
||||
reqwest = { version = "= 0.13.2", features = [
|
||||
reqwest = { version = "= 0.13.3", features = [
|
||||
"form",
|
||||
"json",
|
||||
"multipart",
|
||||
@@ -66,7 +66,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
|
||||
"query",
|
||||
"rustls",
|
||||
] }
|
||||
rustls = { version = "= 0.23.39", features = ["fips"] }
|
||||
rustls = { version = "= 0.23.40", features = ["fips"] }
|
||||
sentry = { version = "= 0.47.0", default-features = false, features = [
|
||||
"backtrace",
|
||||
"contexts",
|
||||
@@ -113,6 +113,7 @@ tracing-subscriber = { version = "= 0.3.23", features = [
|
||||
] }
|
||||
url = "= 2.5.8"
|
||||
uuid = { version = "= 1.23.1", features = ["serde", "v4"] }
|
||||
which = "= 8.0.2"
|
||||
|
||||
ak-axum = { package = "authentik-axum", version = "2026.5.0-rc1", path = "./packages/ak-axum" }
|
||||
ak-client = { package = "authentik-client", version = "2026.5.0-rc1", path = "./packages/client-rust" }
|
||||
@@ -282,6 +283,7 @@ sqlx = { workspace = true, optional = true }
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
which.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
77
Makefile
77
Makefile
@@ -106,17 +106,15 @@ migrate: ## Run the Authentik Django server's migrations
|
||||
|
||||
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
|
||||
|
||||
aws-cfn:
|
||||
cd lifecycle/aws && npm i && $(UV) run npm run aws-cfn
|
||||
aws-cfn: node-install
|
||||
corepack npm install --prefix lifecycle/aws
|
||||
$(UV) run corepack npm run aws-cfn --prefix lifecycle/aws
|
||||
|
||||
run-server: ## Run the main authentik server process
|
||||
$(UV) run ak server
|
||||
run: ## Run the main authentik server and worker processes
|
||||
$(UV) run ak allinone
|
||||
|
||||
run-worker: ## Run the main authentik worker process
|
||||
$(UV) run ak worker
|
||||
|
||||
run-worker-watch: ## Run the authentik worker, with auto reloading
|
||||
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs --no-meta --notify -- $(UV) run ak worker
|
||||
run-watch: ## Run the authentik server and worker, with auto reloading
|
||||
watchexec --on-busy-update=restart --stop-signal=SIGINT --exts py,rs,go --no-meta --notify -- $(UV) run ak allinone
|
||||
|
||||
core-i18n-extract:
|
||||
$(UV) run ak makemessages \
|
||||
@@ -128,7 +126,7 @@ core-i18n-extract:
|
||||
--ignore website \
|
||||
-l en
|
||||
|
||||
install: node-install docs-install core-install ## Install all requires dependencies for `node`, `docs` and `core`
|
||||
install: node-install web-install core-install ## Install all requires dependencies for `node`, `web` and `core`
|
||||
|
||||
dev-drop-db:
|
||||
$(eval pg_user := $(shell $(UV) run python -m authentik.lib.config postgresql.user 2>/dev/null))
|
||||
@@ -232,38 +230,46 @@ gen-dev-config: ## Generate a local development config file
|
||||
#########################
|
||||
|
||||
node-install: ## Install the necessary libraries to build Node.js packages
|
||||
npm ci
|
||||
npm ci --prefix web
|
||||
node ./scripts/node/setup-corepack.mjs
|
||||
node ./scripts/node/lint-runtime.mjs
|
||||
|
||||
node ./scripts/node/lint-runtime.mjs
|
||||
|
||||
#########################
|
||||
## Web
|
||||
#########################
|
||||
|
||||
web-build: node-install ## Build the Authentik UI
|
||||
npm run --prefix web build
|
||||
web-install: ## Install the necessary libraries to build the Authentik UI
|
||||
node ./scripts/node/lint-runtime.mjs web
|
||||
|
||||
corepack npm ci
|
||||
corepack npm ci --prefix web
|
||||
|
||||
web-build: ## Build the Authentik UI
|
||||
corepack npm run --prefix web build
|
||||
|
||||
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
|
||||
|
||||
web-test: ## Run tests for the Authentik UI
|
||||
npm run --prefix web test
|
||||
corepack npm run --prefix web test
|
||||
|
||||
web-watch: ## Build and watch the Authentik UI for changes, updating automatically
|
||||
npm run --prefix web watch
|
||||
corepack npm run --prefix web watch
|
||||
web-storybook-watch: ## Build and run the storybook documentation server
|
||||
npm run --prefix web storybook
|
||||
corepack npm run --prefix web storybook
|
||||
|
||||
web-lint-fix:
|
||||
npm run --prefix web prettier
|
||||
corepack npm run --prefix web prettier
|
||||
|
||||
web-lint:
|
||||
npm run --prefix web lint
|
||||
npm run --prefix web lit-analyse
|
||||
corepack npm run --prefix web lint
|
||||
corepack npm run --prefix web lit-analyse
|
||||
|
||||
web-check-compile:
|
||||
npm run --prefix web tsc
|
||||
corepack npm run --prefix web tsc
|
||||
|
||||
web-i18n-extract:
|
||||
npm run --prefix web extract-locales
|
||||
corepack npm run --prefix web extract-locales
|
||||
|
||||
#########################
|
||||
## Docs
|
||||
@@ -271,35 +277,40 @@ web-i18n-extract:
|
||||
|
||||
docs: docs-lint-fix docs-build ## Automatically fix formatting issues in the Authentik docs source code, lint the code, and compile it
|
||||
|
||||
docs-install:
|
||||
npm ci --prefix website
|
||||
docs-install: node-install ## Install the necessary libraries to build the Authentik documentation
|
||||
node ./scripts/node/lint-runtime.mjs
|
||||
|
||||
corepack npm ci
|
||||
|
||||
corepack npm ci --prefix website
|
||||
|
||||
docs-lint-fix: lint-spellcheck
|
||||
npm run --prefix website prettier
|
||||
corepack npm run --prefix website prettier
|
||||
|
||||
docs-build:
|
||||
npm run --prefix website build
|
||||
node ./scripts/node/lint-runtime.mjs website
|
||||
corepack npm run --prefix website build
|
||||
|
||||
docs-watch: ## Build and watch the topics documentation
|
||||
npm run --prefix website start
|
||||
corepack npm run --prefix website start
|
||||
|
||||
integrations: docs-lint-fix integrations-build ## Fix formatting issues in the integrations source code, lint the code, and compile it
|
||||
|
||||
integrations-build:
|
||||
npm run --prefix website -w integrations build
|
||||
corepack npm run --prefix website -w integrations build
|
||||
|
||||
integrations-watch: ## Build and watch the Integrations documentation
|
||||
npm run --prefix website -w integrations start
|
||||
corepack npm run --prefix website -w integrations start
|
||||
|
||||
docs-api-build:
|
||||
npm run --prefix website -w api build
|
||||
corepack npm run --prefix website -w api build
|
||||
|
||||
docs-api-watch: ## Build and watch the API documentation
|
||||
npm run --prefix website -w api generate
|
||||
npm run --prefix website -w api start
|
||||
corepack npm run --prefix website -w api generate
|
||||
corepack npm run --prefix website -w api start
|
||||
|
||||
docs-api-clean: ## Clean generated API documentation
|
||||
npm run --prefix website -w api build:api:clean
|
||||
corepack npm run --prefix website -w api build:api:clean
|
||||
|
||||
#########################
|
||||
## Docker
|
||||
|
||||
@@ -1,31 +1,73 @@
|
||||
"""authentik API Modelviewset tests"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from authentik.admin.api.version_history import VersionHistoryViewSet
|
||||
from authentik.api.v3.urls import router
|
||||
from authentik.core.tests.utils import RequestFactory, create_test_admin_user
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.tenants.api.domains import DomainViewSet
|
||||
from authentik.tenants.api.tenants import TenantViewSet
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
|
||||
class TestModelViewSets(TestCase):
|
||||
"""Test Viewset"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = create_test_admin_user()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
|
||||
|
||||
def viewset_tester_factory(test_viewset: type[ModelViewSet], full=True) -> dict[str, Callable]:
|
||||
"""Test Viewset"""
|
||||
|
||||
def tester(self: TestModelViewSets):
|
||||
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
|
||||
def test_attrs(self: TestModelViewSets) -> None:
|
||||
"""Test attributes we require on all viewsets"""
|
||||
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
|
||||
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
|
||||
filterset_class = getattr(test_viewset, "filterset_class", None)
|
||||
if not filterset_class:
|
||||
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
|
||||
|
||||
return tester
|
||||
def test_ordering(self: TestModelViewSets) -> None:
|
||||
"""Test that all ordering fields are correct"""
|
||||
view = test_viewset.as_view({"get": "list"})
|
||||
for ordering_field in test_viewset.ordering:
|
||||
with self.subTest(ordering_field):
|
||||
req = self.factory.get(
|
||||
f"/?{urlencode({'ordering': ordering_field}, doseq=True)}", user=self.user
|
||||
)
|
||||
req.tenant = get_current_tenant()
|
||||
res = view(req)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_search(self: TestModelViewSets) -> None:
|
||||
"""Test that search fields are correct"""
|
||||
view = test_viewset.as_view({"get": "list"})
|
||||
req = self.factory.get(
|
||||
f"/?{urlencode({'search': generate_id()}, doseq=True)}", user=self.user
|
||||
)
|
||||
req.tenant = get_current_tenant()
|
||||
res = view(req)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
cases = {
|
||||
"attrs": test_attrs,
|
||||
}
|
||||
if full:
|
||||
cases["ordering"] = test_ordering
|
||||
cases["search"] = test_search
|
||||
return cases
|
||||
|
||||
|
||||
for _, viewset, _ in router.registry:
|
||||
if not issubclass(viewset, ModelViewSet | ReadOnlyModelViewSet):
|
||||
continue
|
||||
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))
|
||||
full = viewset not in [VersionHistoryViewSet, DomainViewSet, TenantViewSet]
|
||||
for test, case in viewset_tester_factory(viewset, full=full).items():
|
||||
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}_{test}", case)
|
||||
|
||||
@@ -126,7 +126,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
||||
|
||||
def check_blueprint_perms(blueprint: Blueprint, user: User, explicit_action: str | None = None):
|
||||
"""Check for individual permissions for each model in a blueprint"""
|
||||
for entry in blueprint.entries:
|
||||
for entry in blueprint.iter_entries():
|
||||
full_model = entry.get_model(blueprint)
|
||||
app, __, model = full_model.partition(".")
|
||||
perms = [
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"""Test blueprints v1"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.tests.utils import load_fixture
|
||||
@@ -42,3 +45,45 @@ class TestBlueprintsV1Conditions(TransactionTestCase):
|
||||
# Ensure objects do not exist
|
||||
self.assertFalse(Flow.objects.filter(slug=flow_slug1))
|
||||
self.assertFalse(Flow.objects.filter(slug=flow_slug2))
|
||||
|
||||
def test_enterprise_license_context_unlicensed(self):
|
||||
"""Test enterprise license context defaults to a false boolean when unlicensed."""
|
||||
license_key = LicenseKey("test", 0, "Test license", 0, 0)
|
||||
|
||||
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
|
||||
importer = Importer.from_string("""
|
||||
version: 1
|
||||
entries:
|
||||
- identifiers:
|
||||
name: enterprise-test
|
||||
slug: enterprise-test
|
||||
model: authentik_flows.flow
|
||||
conditions:
|
||||
- !Context goauthentik.io/enterprise/licensed
|
||||
attrs:
|
||||
designation: stage_configuration
|
||||
title: foo
|
||||
""")
|
||||
|
||||
self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], False)
|
||||
|
||||
def test_enterprise_license_context_licensed(self):
|
||||
"""Test enterprise license context defaults to a true boolean when licensed."""
|
||||
license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
|
||||
|
||||
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
|
||||
importer = Importer.from_string("""
|
||||
version: 1
|
||||
entries:
|
||||
- identifiers:
|
||||
name: enterprise-test
|
||||
slug: enterprise-test
|
||||
model: authentik_flows.flow
|
||||
conditions:
|
||||
- !Context goauthentik.io/enterprise/licensed
|
||||
attrs:
|
||||
designation: stage_configuration
|
||||
title: foo
|
||||
""")
|
||||
|
||||
self.assertIs(importer.blueprint.context["goauthentik.io/enterprise/licensed"], True)
|
||||
|
||||
@@ -146,9 +146,7 @@ class Importer:
|
||||
try:
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
|
||||
context["goauthentik.io/enterprise/licensed"] = (
|
||||
LicenseKey.get_total().status().is_valid,
|
||||
)
|
||||
context["goauthentik.io/enterprise/licensed"] = LicenseKey.get_total().status().is_valid
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
return context
|
||||
|
||||
@@ -64,6 +64,7 @@ class BrandSerializer(ModelSerializer):
|
||||
"flow_unenrollment",
|
||||
"flow_user_settings",
|
||||
"flow_device_code",
|
||||
"flow_lockdown",
|
||||
"default_application",
|
||||
"web_certificate",
|
||||
"client_certificates",
|
||||
@@ -117,6 +118,7 @@ class CurrentBrandSerializer(PassiveSerializer):
|
||||
flow_unenrollment = CharField(source="flow_unenrollment.slug", required=False)
|
||||
flow_user_settings = CharField(source="flow_user_settings.slug", required=False)
|
||||
flow_device_code = CharField(source="flow_device_code.slug", required=False)
|
||||
flow_lockdown = CharField(source="flow_lockdown.slug", required=False)
|
||||
|
||||
default_locale = CharField(read_only=True)
|
||||
flags = SerializerMethodField()
|
||||
@@ -154,6 +156,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
|
||||
"flow_unenrollment",
|
||||
"flow_user_settings",
|
||||
"flow_device_code",
|
||||
"flow_lockdown",
|
||||
"web_certificate",
|
||||
"client_certificates",
|
||||
]
|
||||
|
||||
25
authentik/brands/migrations/0012_brand_flow_lockdown.py
Normal file
25
authentik/brands/migrations/0012_brand_flow_lockdown.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-14 02:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_brands", "0011_alter_brand_branding_default_flow_background_and_more"),
|
||||
("authentik_flows", "0031_alter_flow_layout"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="brand",
|
||||
name="flow_lockdown",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="brand_lockdown",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -58,6 +58,9 @@ class Brand(SerializerModel):
|
||||
flow_device_code = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_device_code"
|
||||
)
|
||||
flow_lockdown = models.ForeignKey(
|
||||
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_lockdown"
|
||||
)
|
||||
|
||||
default_application = models.ForeignKey(
|
||||
"authentik_core.Application",
|
||||
|
||||
@@ -20,11 +20,16 @@ class TestBrands(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.default_flags = {}
|
||||
for flag in Flag.available(visibility="public"):
|
||||
self.default_flags[flag().key] = flag.get()
|
||||
Brand.objects.all().delete()
|
||||
|
||||
@property
|
||||
def default_flags(self) -> dict[str, object]:
|
||||
"""Get current public flags.
|
||||
|
||||
Some tests define temporary Flag subclasses, so this can't be cached in setUp.
|
||||
"""
|
||||
return {flag().key: flag.get() for flag in Flag.available(visibility="public")}
|
||||
|
||||
def test_current_brand(self):
|
||||
"""Test Current brand API"""
|
||||
brand = create_test_brand()
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Shared SAML LogoutResponse parser"""
|
||||
|
||||
from defusedxml.lxml import fromstring
|
||||
from lxml.etree import _Element # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.constants import NS_SAML_PROTOCOL, SAML_STATUS_SUCCESS
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class LogoutResponseParser:
|
||||
"""Parse and validate SAML LogoutResponse messages"""
|
||||
|
||||
_root: _Element
|
||||
|
||||
def __init__(self, raw_response: str):
|
||||
self._raw_response = raw_response
|
||||
|
||||
def parse(self):
|
||||
"""Decode and parse the LogoutResponse XML."""
|
||||
# decode_base64_and_inflate handles both deflate-compressed (Redirect binding)
|
||||
# and plain base64 (POST binding) responses
|
||||
response_xml = decode_base64_and_inflate(self._raw_response)
|
||||
self._root = fromstring(response_xml.encode())
|
||||
|
||||
def verify_status(self) -> bool:
|
||||
"""Check LogoutResponse status. Returns True if status is Success."""
|
||||
status = self._root.find(f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
if status is None:
|
||||
return True
|
||||
status_code = status.find(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
if status_code is None:
|
||||
return True
|
||||
status_value = status_code.attrib.get("Value", "")
|
||||
if status_value != SAML_STATUS_SUCCESS:
|
||||
LOGGER.warning(
|
||||
"LogoutResponse status is not Success",
|
||||
status=status_value,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -47,7 +47,8 @@ class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
|
||||
search_fields = [
|
||||
"pbm_uuid",
|
||||
"name",
|
||||
"app",
|
||||
"app__name",
|
||||
"app__slug",
|
||||
"attributes",
|
||||
]
|
||||
filterset_fields = [
|
||||
|
||||
@@ -32,19 +32,19 @@ from authentik.rbac.decorators import permission_required
|
||||
class UserAgentDeviceDict(TypedDict):
|
||||
"""User agent device"""
|
||||
|
||||
brand: str
|
||||
brand: str | None = None
|
||||
family: str
|
||||
model: str
|
||||
model: str | None = None
|
||||
|
||||
|
||||
class UserAgentOSDict(TypedDict):
|
||||
"""User agent os"""
|
||||
|
||||
family: str
|
||||
major: str
|
||||
minor: str
|
||||
patch: str
|
||||
patch_minor: str
|
||||
major: str | None = None
|
||||
minor: str | None = None
|
||||
patch: str | None = None
|
||||
patch_minor: str | None = None
|
||||
|
||||
|
||||
class UserAgentBrowserDict(TypedDict):
|
||||
|
||||
@@ -563,6 +563,9 @@ class UsersFilter(FilterSet):
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
ConditionalInheritance(
|
||||
"authentik.enterprise.stages.account_lockdown.api.UserAccountLockdownMixin"
|
||||
),
|
||||
ConditionalInheritance("authentik.enterprise.reports.api.reports.ExportMixin"),
|
||||
UsedByMixin,
|
||||
ModelViewSet,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""authentik core signals"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.core.cache import cache
|
||||
@@ -59,7 +58,7 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
|
||||
layer = get_channel_layer()
|
||||
device_cookie = request.COOKIES.get("authentik_device")
|
||||
if device_cookie:
|
||||
async_to_sync(layer.group_send)(
|
||||
layer.group_send_blocking(
|
||||
build_device_group(device_cookie),
|
||||
{"type": "event.session.authenticated"},
|
||||
)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
{% block head %}
|
||||
<style data-id="static-styles">
|
||||
:root {
|
||||
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url }}");
|
||||
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url|iriencode|safe }}");
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from django.db.models import BooleanField as ModelBooleanField
|
||||
from django.db.models import Case, Q, Value, When
|
||||
from django.db.models import Exists, OuterRef, Q, Subquery
|
||||
from django_filters.rest_framework import BooleanFilter, FilterSet
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.decorators import action
|
||||
@@ -14,7 +13,7 @@ from rest_framework.viewsets import GenericViewSet
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin
|
||||
from authentik.enterprise.lifecycle.api.reviews import ReviewSerializer
|
||||
from authentik.enterprise.lifecycle.models import LifecycleIteration, ReviewState
|
||||
from authentik.enterprise.lifecycle.models import LifecycleIteration, LifecycleRule, ReviewState
|
||||
from authentik.enterprise.lifecycle.utils import (
|
||||
ContentTypeField,
|
||||
ReviewerGroupSerializer,
|
||||
@@ -26,20 +25,25 @@ from authentik.enterprise.lifecycle.utils import (
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
|
||||
|
||||
class RelatedRuleSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
reviewer_groups = ReviewerGroupSerializer(many=True, read_only=True)
|
||||
min_reviewers = IntegerField(read_only=True)
|
||||
reviewers = ReviewerUserSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = LifecycleRule
|
||||
fields = ["id", "name", "reviewer_groups", "min_reviewers", "reviewers"]
|
||||
|
||||
|
||||
class LifecycleIterationSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
content_type = ContentTypeField()
|
||||
object_verbose = SerializerMethodField()
|
||||
rule = RelatedRuleSerializer(read_only=True)
|
||||
object_admin_url = SerializerMethodField(read_only=True)
|
||||
grace_period_end = SerializerMethodField(read_only=True)
|
||||
reviews = ReviewSerializer(many=True, read_only=True, source="review_set.all")
|
||||
user_can_review = SerializerMethodField(read_only=True)
|
||||
|
||||
reviewer_groups = ReviewerGroupSerializer(
|
||||
many=True, read_only=True, source="rule.reviewer_groups"
|
||||
)
|
||||
min_reviewers = IntegerField(read_only=True, source="rule.min_reviewers")
|
||||
reviewers = ReviewerUserSerializer(many=True, read_only=True, source="rule.reviewers")
|
||||
|
||||
next_review_date = SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -55,10 +59,8 @@ class LifecycleIterationSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
"grace_period_end",
|
||||
"next_review_date",
|
||||
"reviews",
|
||||
"rule",
|
||||
"user_can_review",
|
||||
"reviewer_groups",
|
||||
"min_reviewers",
|
||||
"reviewers",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -88,43 +90,55 @@ class IterationViewSet(EnterpriseRequiredMixin, CreateModelMixin, GenericViewSet
|
||||
queryset = LifecycleIteration.objects.all()
|
||||
serializer_class = LifecycleIterationSerializer
|
||||
ordering = ["-opened_on"]
|
||||
ordering_fields = ["state", "content_type__model", "opened_on", "grace_period_end"]
|
||||
ordering_fields = [
|
||||
"state",
|
||||
"content_type__model",
|
||||
"rule__name",
|
||||
"opened_on",
|
||||
"grace_period_end",
|
||||
]
|
||||
filterset_class = LifecycleIterationFilterSet
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
return self.queryset.annotate(
|
||||
user_is_reviewer=Case(
|
||||
When(
|
||||
Q(rule__reviewers=user)
|
||||
| Q(rule__reviewer_groups__in=user.groups.all().with_ancestors()),
|
||||
then=Value(True),
|
||||
),
|
||||
default=Value(False),
|
||||
output_field=ModelBooleanField(),
|
||||
user_is_reviewer=Exists(
|
||||
LifecycleRule.objects.filter(
|
||||
pk=OuterRef("rule_id"),
|
||||
).filter(
|
||||
Q(reviewers=user) | Q(reviewer_groups__in=user.groups.all().with_ancestors())
|
||||
)
|
||||
)
|
||||
).distinct()
|
||||
)
|
||||
|
||||
@extend_schema(
|
||||
operation_id="lifecycle_iterations_list_latest",
|
||||
responses={200: LifecycleIterationSerializer(many=True)},
|
||||
)
|
||||
@action(
|
||||
detail=False,
|
||||
pagination_class=None,
|
||||
methods=["get"],
|
||||
url_path=r"latest/(?P<content_type>[^/]+)/(?P<object_id>[^/]+)",
|
||||
)
|
||||
def latest_iteration(self, request: Request, content_type: str, object_id: str) -> Response:
|
||||
def latest_iterations(self, request: Request, content_type: str, object_id: str) -> Response:
|
||||
ct = parse_content_type(content_type)
|
||||
try:
|
||||
obj = (
|
||||
self.get_queryset()
|
||||
.filter(
|
||||
content_type__app_label=ct["app_label"],
|
||||
content_type__model=ct["model"],
|
||||
object_id=object_id,
|
||||
)
|
||||
.latest("opened_on")
|
||||
latest_ids_subquery = (
|
||||
LifecycleIteration.objects.filter(
|
||||
rule=OuterRef("rule"),
|
||||
content_type__app_label=ct["app_label"],
|
||||
content_type__model=ct["model"],
|
||||
object_id=object_id,
|
||||
)
|
||||
except LifecycleIteration.DoesNotExist:
|
||||
return Response(status=404)
|
||||
serializer = self.get_serializer(obj)
|
||||
.order_by("-opened_on")
|
||||
.values("id")[:1]
|
||||
)
|
||||
latest_per_rule = LifecycleIteration.objects.filter(
|
||||
content_type__app_label=ct["app_label"],
|
||||
content_type__model=ct["model"],
|
||||
object_id=object_id,
|
||||
).filter(id=Subquery(latest_ids_subquery))
|
||||
serializer = self.get_serializer(latest_per_rule, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(
|
||||
|
||||
@@ -84,23 +84,6 @@ class LifecycleRuleSerializer(EnterpriseRequiredMixin, ModelSerializer):
|
||||
raise ValidationError(
|
||||
{"grace_period": _("Grace period must be shorter than the interval.")}
|
||||
)
|
||||
if "content_type" in attrs or "object_id" in attrs:
|
||||
content_type = attrs.get("content_type", getattr(self.instance, "content_type", None))
|
||||
object_id = attrs.get("object_id", getattr(self.instance, "object_id", None))
|
||||
if content_type is not None and object_id is None:
|
||||
existing = LifecycleRule.objects.filter(
|
||||
content_type=content_type, object_id__isnull=True
|
||||
)
|
||||
if self.instance:
|
||||
existing = existing.exclude(pk=self.instance.pk)
|
||||
if existing.exists():
|
||||
raise ValidationError(
|
||||
{
|
||||
"content_type": _(
|
||||
"Only one type-wide rule for each object type is allowed."
|
||||
)
|
||||
}
|
||||
)
|
||||
return attrs
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-05 11:27
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_lifecycle", "0002_alter_lifecycleiteration_opened_on"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="lifecyclerule",
|
||||
name="uniq_lifecycle_rule_ct_null_object",
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="lifecyclerule",
|
||||
unique_together=set(),
|
||||
),
|
||||
]
|
||||
@@ -56,14 +56,6 @@ class LifecycleRule(SerializerModel):
|
||||
|
||||
class Meta:
|
||||
indexes = [models.Index(fields=["content_type"])]
|
||||
unique_together = [["content_type", "object_id"]]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["content_type"],
|
||||
condition=Q(object_id__isnull=True),
|
||||
name="uniq_lifecycle_rule_ct_null_object",
|
||||
)
|
||||
]
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
@@ -82,12 +74,6 @@ class LifecycleRule(SerializerModel):
|
||||
qs = self.content_type.get_all_objects_for_this_type()
|
||||
if self.object_id:
|
||||
qs = qs.filter(pk=self.object_id)
|
||||
else:
|
||||
qs = qs.exclude(
|
||||
pk__in=LifecycleRule.objects.filter(
|
||||
content_type=self.content_type, object_id__isnull=False
|
||||
).values_list(Cast("object_id", output_field=self._get_pk_field()), flat=True)
|
||||
)
|
||||
return qs
|
||||
|
||||
def _get_stale_iterations(self) -> QuerySet[LifecycleIteration]:
|
||||
@@ -107,8 +93,7 @@ class LifecycleRule(SerializerModel):
|
||||
|
||||
def _get_newly_due_objects(self) -> QuerySet:
|
||||
recent_iteration_ids = LifecycleIteration.objects.filter(
|
||||
content_type=self.content_type,
|
||||
object_id__isnull=False,
|
||||
rule=self,
|
||||
opened_on__gte=start_of_day(
|
||||
timezone.now() + timedelta(days=1) - timedelta_from_string(self.interval)
|
||||
),
|
||||
@@ -214,9 +199,15 @@ class LifecycleIteration(SerializerModel, ManagedModel):
|
||||
}
|
||||
|
||||
def initialize(self):
|
||||
if (self.content_type.app_label, self.content_type.model) == ("authentik_core", "group"):
|
||||
object_label = self.object.name
|
||||
elif (self.content_type.app_label, self.content_type.model) == ("authentik_rbac", "role"):
|
||||
object_label = self.object.name
|
||||
else:
|
||||
object_label = str(self.object)
|
||||
event = Event.new(
|
||||
EventAction.REVIEW_INITIATED,
|
||||
message=_(f"Access review is due for {self.content_type.name} {str(self.object)}"),
|
||||
message=_(f"Access review is due for {self.content_type.name.lower()} {object_label}"),
|
||||
**self._get_event_args(),
|
||||
)
|
||||
event.save()
|
||||
|
||||
@@ -3,6 +3,7 @@ from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from authentik.enterprise.lifecycle.models import LifecycleRule, ReviewState
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
|
||||
|
||||
@receiver(post_save, sender=LifecycleRule)
|
||||
@@ -11,7 +12,9 @@ def post_rule_save(sender, instance: LifecycleRule, created: bool, **_):
|
||||
|
||||
apply_lifecycle_rule.send_with_options(
|
||||
args=(instance.id,),
|
||||
rel_obj=instance,
|
||||
rel_obj=Schedule.objects.get(
|
||||
actor_name="authentik.enterprise.lifecycle.tasks.apply_lifecycle_rules"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,14 +4,17 @@ from dramatiq import actor
|
||||
from authentik.core.models import User
|
||||
from authentik.enterprise.lifecycle.models import LifecycleRule
|
||||
from authentik.events.models import Event, Notification, NotificationTransport
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
|
||||
|
||||
@actor(description=_("Dispatch tasks to validate lifecycle rules."))
|
||||
@actor(description=_("Dispatch tasks to apply lifecycle rules."))
|
||||
def apply_lifecycle_rules():
|
||||
for rule in LifecycleRule.objects.all():
|
||||
apply_lifecycle_rule.send_with_options(
|
||||
args=(rule.id,),
|
||||
rel_obj=rule,
|
||||
rel_obj=Schedule.objects.get(
|
||||
actor_name="authentik.enterprise.lifecycle.tasks.apply_lifecycle_rules"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
@@ -19,6 +20,11 @@ class TestLifecycleRuleAPI(APITestCase):
|
||||
self.content_type = ContentType.objects.get_for_model(Application)
|
||||
self.reviewer_group = Group.objects.create(name=generate_id())
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
config = apps.get_app_config("authentik_tasks_schedules")
|
||||
config._on_startup_callback(None)
|
||||
|
||||
def test_list_rules(self):
|
||||
rule = LifecycleRule.objects.create(
|
||||
name=generate_id(),
|
||||
@@ -190,6 +196,11 @@ class TestIterationAPI(APITestCase):
|
||||
self.reviewer_group = Group.objects.create(name=generate_id())
|
||||
self.reviewer_group.users.add(self.user)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
config = apps.get_app_config("authentik_tasks_schedules")
|
||||
config._on_startup_callback(None)
|
||||
|
||||
def test_open_iterations(self):
|
||||
rule = LifecycleRule.objects.create(
|
||||
name=generate_id(),
|
||||
@@ -231,7 +242,7 @@ class TestIterationAPI(APITestCase):
|
||||
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:lifecycleiteration-latest-iteration",
|
||||
"authentik_api:lifecycleiteration-latest-iterations",
|
||||
kwargs={
|
||||
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
|
||||
"object_id": str(self.app.pk),
|
||||
@@ -239,19 +250,20 @@ class TestIterationAPI(APITestCase):
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["object_id"], str(self.app.pk))
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["object_id"], str(self.app.pk))
|
||||
|
||||
def test_latest_iteration_not_found(self):
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:lifecycleiteration-latest-iteration",
|
||||
"authentik_api:lifecycleiteration-latest-iterations",
|
||||
kwargs={
|
||||
"content_type": f"{self.content_type.app_label}.{self.content_type.model}",
|
||||
"object_id": "00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(response.data, [])
|
||||
|
||||
def test_iteration_includes_user_can_review(self):
|
||||
rule = LifecycleRule.objects.create(
|
||||
@@ -279,6 +291,11 @@ class TestReviewAPI(APITestCase):
|
||||
self.reviewer_group = Group.objects.create(name=generate_id())
|
||||
self.reviewer_group.users.add(self.user)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
config = apps.get_app_config("authentik_tasks_schedules")
|
||||
config._on_startup_callback(None)
|
||||
|
||||
def test_create_review(self):
|
||||
rule = LifecycleRule.objects.create(
|
||||
name=generate_id(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import datetime as dt
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.utils import timezone
|
||||
@@ -29,6 +30,11 @@ class TestLifecycleModels(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
config = apps.get_app_config("authentik_tasks_schedules")
|
||||
config._on_startup_callback(None)
|
||||
|
||||
def _get_request(self):
|
||||
return self.factory.get("/")
|
||||
|
||||
@@ -438,31 +444,6 @@ class TestLifecycleModels(TestCase):
|
||||
self.assertIn(app_one, objects)
|
||||
self.assertIn(app_two, objects)
|
||||
|
||||
def test_rule_type_excludes_objects_with_specific_rules(self):
|
||||
app_with_rule = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
app_without_rule = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
content_type = ContentType.objects.get_for_model(Application)
|
||||
|
||||
# Create a specific rule for app_with_rule
|
||||
LifecycleRule.objects.create(
|
||||
name=generate_id(),
|
||||
content_type=content_type,
|
||||
object_id=str(app_with_rule.pk),
|
||||
interval="days=30",
|
||||
)
|
||||
|
||||
# Create a type-level rule
|
||||
type_rule = LifecycleRule.objects.create(
|
||||
name=generate_id(),
|
||||
content_type=content_type,
|
||||
object_id=None,
|
||||
interval="days=60",
|
||||
)
|
||||
|
||||
objects = list(type_rule.get_objects())
|
||||
self.assertNotIn(app_with_rule, objects)
|
||||
self.assertIn(app_without_rule, objects)
|
||||
|
||||
def test_rule_type_apply_creates_iterations_for_all_objects(self):
|
||||
app_one = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
app_two = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
@@ -669,6 +650,73 @@ class TestLifecycleModels(TestCase):
|
||||
self.assertIn(explicit_reviewer, reviewers)
|
||||
self.assertIn(group_member, reviewers)
|
||||
|
||||
def test_multiple_rules_same_object_create_separate_iterations(self):
|
||||
"""Two rules targeting the same object each create their own iteration."""
|
||||
obj = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
rule_one = self._create_rule_for_object(obj, interval="days=30", grace_period="days=10")
|
||||
rule_two = self._create_rule_for_object(obj, interval="days=60", grace_period="days=20")
|
||||
|
||||
iterations = LifecycleIteration.objects.filter(
|
||||
content_type=content_type, object_id=str(obj.pk)
|
||||
)
|
||||
self.assertEqual(iterations.count(), 2)
|
||||
|
||||
iter_one = iterations.get(rule=rule_one)
|
||||
iter_two = iterations.get(rule=rule_two)
|
||||
self.assertEqual(iter_one.state, ReviewState.PENDING)
|
||||
self.assertEqual(iter_two.state, ReviewState.PENDING)
|
||||
self.assertNotEqual(iter_one.pk, iter_two.pk)
|
||||
|
||||
def test_multiple_rules_same_object_reviewed_independently(self):
|
||||
"""Reviewing one rule's iteration does not affect the other rule's iteration."""
|
||||
obj = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
reviewer = create_test_user()
|
||||
|
||||
rule_one = self._create_rule_for_object(obj, min_reviewers=1)
|
||||
rule_two = self._create_rule_for_object(obj, min_reviewers=1)
|
||||
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.users.add(reviewer)
|
||||
rule_one.reviewer_groups.add(group)
|
||||
rule_two.reviewer_groups.add(group)
|
||||
|
||||
iter_one = LifecycleIteration.objects.get(
|
||||
content_type=content_type, object_id=str(obj.pk), rule=rule_one
|
||||
)
|
||||
iter_two = LifecycleIteration.objects.get(
|
||||
content_type=content_type, object_id=str(obj.pk), rule=rule_two
|
||||
)
|
||||
|
||||
request = self._get_request()
|
||||
|
||||
# Review only rule_one's iteration
|
||||
Review.objects.create(iteration=iter_one, reviewer=reviewer)
|
||||
iter_one.on_review(request)
|
||||
|
||||
iter_one.refresh_from_db()
|
||||
iter_two.refresh_from_db()
|
||||
self.assertEqual(iter_one.state, ReviewState.REVIEWED)
|
||||
self.assertEqual(iter_two.state, ReviewState.PENDING)
|
||||
|
||||
def test_type_rule_and_object_rule_both_create_iterations(self):
|
||||
"""A type-level rule and an object-level rule both create iterations for the same object."""
|
||||
obj = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
|
||||
object_rule = self._create_rule_for_object(obj, interval="days=30")
|
||||
type_rule = self._create_rule_for_type(Application, interval="days=60")
|
||||
|
||||
iterations = LifecycleIteration.objects.filter(
|
||||
content_type=content_type, object_id=str(obj.pk)
|
||||
)
|
||||
self.assertEqual(iterations.count(), 2)
|
||||
self.assertTrue(iterations.filter(rule=object_rule).exists())
|
||||
self.assertTrue(iterations.filter(rule=type_rule).exists())
|
||||
|
||||
|
||||
class TestLifecycleDateBoundaries(TestCase):
|
||||
"""Verify that start_of_day normalization ensures correct overdue/due
|
||||
@@ -679,6 +727,11 @@ class TestLifecycleDateBoundaries(TestCase):
|
||||
ensures that the boundary is always at midnight, so millisecond variations
|
||||
in task execution time do not affect results."""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
config = apps.get_app_config("authentik_tasks_schedules")
|
||||
config._on_startup_callback(None)
|
||||
|
||||
def _create_rule_and_iteration(self, grace_period="days=1", interval="days=365"):
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
content_type = ContentType.objects.get_for_model(Application)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Generated by Django 5.2.12 on 2026-04-04 16:58
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.contrib.postgres.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
@@ -40,4 +41,109 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="stream",
|
||||
name="events_requested",
|
||||
field=django.contrib.postgres.fields.ArrayField(
|
||||
base_field=models.TextField(
|
||||
choices=[
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
|
||||
"Caep Session Revoked",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change",
|
||||
"Caep Token Claims Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/credential-change",
|
||||
"Caep Credential Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change",
|
||||
"Caep Assurance Level Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change",
|
||||
"Caep Device Compliance Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/session-established",
|
||||
"Caep Session Established",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/session-presented",
|
||||
"Caep Session Presented",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/risk-level-change",
|
||||
"Caep Risk Level Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/ssf/event-type/verification",
|
||||
"Set Verification",
|
||||
),
|
||||
]
|
||||
),
|
||||
default=list,
|
||||
size=None,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="stream",
|
||||
name="status",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("enabled", "Enabled"),
|
||||
("paused", "Paused"),
|
||||
("disabled", "Disabled"),
|
||||
("disabled_deleted", "Disabled Deleted"),
|
||||
],
|
||||
default="enabled",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="streamevent",
|
||||
name="type",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
|
||||
"Caep Session Revoked",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change",
|
||||
"Caep Token Claims Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/credential-change",
|
||||
"Caep Credential Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change",
|
||||
"Caep Assurance Level Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change",
|
||||
"Caep Device Compliance Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/session-established",
|
||||
"Caep Session Established",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/session-presented",
|
||||
"Caep Session Presented",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/caep/event-type/risk-level-change",
|
||||
"Caep Risk Level Change",
|
||||
),
|
||||
(
|
||||
"https://schemas.openid.net/secevent/ssf/event-type/verification",
|
||||
"Set Verification",
|
||||
),
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -24,8 +24,31 @@ class EventTypes(models.TextChoices):
|
||||
"""SSF Event types supported by authentik"""
|
||||
|
||||
CAEP_SESSION_REVOKED = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
|
||||
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.1"""
|
||||
CAEP_TOKEN_CLAIMS_CHANGE = (
|
||||
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change"
|
||||
)
|
||||
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.2"""
|
||||
CAEP_CREDENTIAL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/credential-change"
|
||||
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.3"""
|
||||
CAEP_ASSURANCE_LEVEL_CHANGE = (
|
||||
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change"
|
||||
)
|
||||
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.4"""
|
||||
CAEP_DEVICE_COMPLIANCE_CHANGE = (
|
||||
"https://schemas.openid.net/secevent/caep/event-type/device-compliance-change"
|
||||
)
|
||||
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.5"""
|
||||
CAEP_SESSION_ESTABLISHED = (
|
||||
"https://schemas.openid.net/secevent/caep/event-type/session-established"
|
||||
)
|
||||
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.6"""
|
||||
CAEP_SESSION_PRESENTED = "https://schemas.openid.net/secevent/caep/event-type/session-presented"
|
||||
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.7"""
|
||||
CAEP_RISK_LEVEL_CHANGE = "https://schemas.openid.net/secevent/caep/event-type/risk-level-change"
|
||||
"""https://openid.net/specs/openid-caep-1_0-final.html#section-3.8"""
|
||||
SET_VERIFICATION = "https://schemas.openid.net/secevent/ssf/event-type/verification"
|
||||
"""https://openid.net/specs/openid-sharedsignals-framework-1_0.html#section-8.1.4.1"""
|
||||
|
||||
|
||||
class DeliveryMethods(models.TextChoices):
|
||||
@@ -46,10 +69,12 @@ class SSFEventStatus(models.TextChoices):
|
||||
|
||||
|
||||
class StreamStatus(models.TextChoices):
|
||||
"""SSF Stream status"""
|
||||
|
||||
ENABLED = "enabled"
|
||||
PAUSED = "paused"
|
||||
DISABLED = "disabled"
|
||||
DISABLED_DELETED = "disabled_deleted"
|
||||
|
||||
|
||||
class SSFProvider(TasksModel, BackchannelProvider):
|
||||
|
||||
@@ -108,13 +108,13 @@ def send_ssf_event(stream_uuid: UUID, event_data: dict[str, Any]):
|
||||
event.save()
|
||||
self.info("Event successfully sent", status=response.status_code)
|
||||
# Cleanup, if we were the last pending message for this stream and it has been deleted
|
||||
# (status=StreamStatus.DISABLED), then we can delete the stream
|
||||
# (status=StreamStatus.DISABLED_DELETED), then we can delete the stream
|
||||
if (
|
||||
not StreamEvent.objects.filter(
|
||||
stream=stream,
|
||||
status__in=[SSFEventStatus.PENDING_FAILED, SSFEventStatus.PENDING_NEW],
|
||||
).exists()
|
||||
and stream.status == StreamStatus.DISABLED
|
||||
and stream.status == StreamStatus.DISABLED_DELETED
|
||||
):
|
||||
LOGGER.info(
|
||||
"Deleting inactive stream as all pending messages were sent.", stream=stream
|
||||
|
||||
@@ -62,7 +62,7 @@ class TestSSFAuth(APITestCase):
|
||||
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
|
||||
self.assertEqual(
|
||||
event.payload["events"],
|
||||
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}},
|
||||
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {}},
|
||||
)
|
||||
|
||||
def test_stream_add_oidc(self):
|
||||
@@ -115,7 +115,7 @@ class TestSSFAuth(APITestCase):
|
||||
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
|
||||
self.assertEqual(
|
||||
event.payload["events"],
|
||||
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}},
|
||||
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {}},
|
||||
)
|
||||
|
||||
def test_token_invalid(self):
|
||||
|
||||
@@ -54,7 +54,7 @@ class TestStream(APITestCase):
|
||||
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
|
||||
self.assertEqual(
|
||||
event.payload["events"],
|
||||
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {"state": None}},
|
||||
{"https://schemas.openid.net/secevent/ssf/event-type/verification": {}},
|
||||
)
|
||||
|
||||
def test_stream_add_poll(self):
|
||||
@@ -96,7 +96,7 @@ class TestStream(APITestCase):
|
||||
)
|
||||
self.assertEqual(res.status_code, 204)
|
||||
stream.refresh_from_db()
|
||||
self.assertEqual(stream.status, StreamStatus.DISABLED)
|
||||
self.assertEqual(stream.status, StreamStatus.DISABLED_DELETED)
|
||||
|
||||
def test_stream_get(self):
|
||||
"""get stream"""
|
||||
@@ -225,3 +225,26 @@ class TestStream(APITestCase):
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}",
|
||||
)
|
||||
self.assertEqual(res.status_code, 404)
|
||||
|
||||
def test_stream_status_update(self):
|
||||
stream = Stream.objects.create(provider=self.provider)
|
||||
res = self.client.post(
|
||||
reverse(
|
||||
"authentik_providers_ssf:stream-status",
|
||||
kwargs={"application_slug": self.application.slug},
|
||||
),
|
||||
data={
|
||||
"stream_id": str(stream.pk),
|
||||
"status": StreamStatus.DISABLED,
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.provider.token.key}",
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
stream.refresh_from_db()
|
||||
self.assertJSONEqual(
|
||||
res.content,
|
||||
{
|
||||
"stream_id": str(stream.pk),
|
||||
"status": str(stream.status),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -33,7 +33,7 @@ class TestTasks(APITestCase):
|
||||
)
|
||||
event_data = stream.prepare_event_payload(
|
||||
EventTypes.SET_VERIFICATION,
|
||||
{"state": None},
|
||||
{},
|
||||
sub_id={"format": "opaque", "id": str(stream.uuid)},
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
@@ -46,7 +46,7 @@ class TestTasks(APITestCase):
|
||||
)
|
||||
jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False})
|
||||
self.assertEqual(jwt["header"]["typ"], "secevent+jwt")
|
||||
self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"])
|
||||
self.assertEqual(jwt["payload"]["events"][EventTypes.SET_VERIFICATION], {})
|
||||
|
||||
def test_push_auth(self):
|
||||
auth = generate_id()
|
||||
@@ -58,7 +58,7 @@ class TestTasks(APITestCase):
|
||||
)
|
||||
event_data = stream.prepare_event_payload(
|
||||
EventTypes.SET_VERIFICATION,
|
||||
{"state": None},
|
||||
{},
|
||||
sub_id={"format": "opaque", "id": str(stream.uuid)},
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
@@ -72,7 +72,7 @@ class TestTasks(APITestCase):
|
||||
)
|
||||
jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False})
|
||||
self.assertEqual(jwt["header"]["typ"], "secevent+jwt")
|
||||
self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"])
|
||||
self.assertEqual(jwt["payload"]["events"][EventTypes.SET_VERIFICATION], {})
|
||||
|
||||
def test_push_stream_disable(self):
|
||||
auth = generate_id()
|
||||
@@ -81,11 +81,11 @@ class TestTasks(APITestCase):
|
||||
delivery_method=DeliveryMethods.RFC_PUSH,
|
||||
endpoint_url="http://localhost/ssf-push",
|
||||
authorization_header=auth,
|
||||
status=StreamStatus.DISABLED,
|
||||
status=StreamStatus.DISABLED_DELETED,
|
||||
)
|
||||
event_data = stream.prepare_event_payload(
|
||||
EventTypes.SET_VERIFICATION,
|
||||
{"state": None},
|
||||
{},
|
||||
sub_id={"format": "opaque", "id": str(stream.uuid)},
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
@@ -95,7 +95,7 @@ class TestTasks(APITestCase):
|
||||
).get_result(block=True, timeout=1)
|
||||
jwt = decode_complete(mocker.request_history[0].body, options={"verify_signature": False})
|
||||
self.assertEqual(jwt["header"]["typ"], "secevent+jwt")
|
||||
self.assertIsNone(jwt["payload"]["events"][EventTypes.SET_VERIFICATION]["state"])
|
||||
self.assertEqual(jwt["payload"]["events"][EventTypes.SET_VERIFICATION], {})
|
||||
self.assertFalse(Stream.objects.filter(pk=stream.pk).exists())
|
||||
|
||||
def test_push_error(self):
|
||||
@@ -106,7 +106,7 @@ class TestTasks(APITestCase):
|
||||
)
|
||||
event_data = stream.prepare_event_payload(
|
||||
EventTypes.SET_VERIFICATION,
|
||||
{"state": None},
|
||||
{},
|
||||
sub_id={"format": "opaque", "id": str(stream.uuid)},
|
||||
)
|
||||
with Mocker() as mocker:
|
||||
|
||||
@@ -24,10 +24,10 @@ class SSFView(APIView):
|
||||
|
||||
|
||||
class SSFStreamView(SSFView):
|
||||
def get_object(self, any_status=False) -> Stream:
|
||||
streams = Stream.objects.filter(provider=self.provider)
|
||||
if not any_status:
|
||||
streams = streams.filter(status__in=[StreamStatus.ENABLED, StreamStatus.PAUSED])
|
||||
def get_object(self) -> Stream:
|
||||
streams = Stream.objects.filter(provider=self.provider).exclude(
|
||||
status=StreamStatus.DISABLED_DELETED
|
||||
)
|
||||
if "stream_id" in self.request.query_params:
|
||||
streams = streams.filter(pk=self.request.query_params["stream_id"])
|
||||
if "stream_id" in self.request.data:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from django.http import HttpRequest
|
||||
from django.http import Http404, HttpRequest
|
||||
from django.urls import reverse
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.fields import CharField, ChoiceField, ListField, SerializerMethodField
|
||||
@@ -106,7 +106,11 @@ class StreamResponseSerializer(PassiveSerializer):
|
||||
}
|
||||
|
||||
def get_events_supported(self, instance: Stream) -> list[str]:
|
||||
return [x.value for x in EventTypes]
|
||||
return [
|
||||
EventTypes.CAEP_SESSION_REVOKED,
|
||||
EventTypes.CAEP_CREDENTIAL_CHANGE,
|
||||
EventTypes.SET_VERIFICATION,
|
||||
]
|
||||
|
||||
|
||||
class StreamView(SSFStreamView):
|
||||
@@ -128,10 +132,9 @@ class StreamView(SSFStreamView):
|
||||
LOGGER.info("Sending verification event", stream=instance)
|
||||
send_ssf_events(
|
||||
EventTypes.SET_VERIFICATION,
|
||||
{
|
||||
"state": None,
|
||||
},
|
||||
{},
|
||||
stream_filter={"pk": instance.uuid},
|
||||
request=request,
|
||||
sub_id={"format": "opaque", "id": str(instance.uuid)},
|
||||
)
|
||||
response = StreamResponseSerializer(instance=instance, context={"request": request}).data
|
||||
@@ -159,7 +162,9 @@ class StreamView(SSFStreamView):
|
||||
|
||||
def delete(self, request: Request, *args, **kwargs) -> Response:
|
||||
stream = self.get_object()
|
||||
stream.status = StreamStatus.DISABLED
|
||||
if stream.status == StreamStatus.DISABLED_DELETED:
|
||||
raise Http404
|
||||
stream.status = StreamStatus.DISABLED_DELETED
|
||||
stream.save()
|
||||
return Response(status=204)
|
||||
|
||||
@@ -175,6 +180,7 @@ class StreamVerifyView(SSFStreamView):
|
||||
"state": state,
|
||||
},
|
||||
stream_filter={"pk": stream.uuid},
|
||||
request=request,
|
||||
sub_id={"format": "opaque", "id": str(stream.uuid)},
|
||||
)
|
||||
return Response(status=204)
|
||||
@@ -182,8 +188,25 @@ class StreamVerifyView(SSFStreamView):
|
||||
|
||||
class StreamStatusView(SSFStreamView):
|
||||
|
||||
class StreamStatusSerializer(PassiveSerializer):
|
||||
stream_id = CharField()
|
||||
status = ChoiceField(choices=StreamStatus.choices)
|
||||
|
||||
def get(self, request: Request, *args, **kwargs):
|
||||
stream = self.get_object(any_status=True)
|
||||
stream = self.get_object()
|
||||
return Response(
|
||||
{
|
||||
"stream_id": str(stream.pk),
|
||||
"status": str(stream.status),
|
||||
}
|
||||
)
|
||||
|
||||
def post(self, request: Request, *args, **kwargs):
|
||||
stream = self.get_object()
|
||||
serializer = self.StreamStatusSerializer(stream, data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
stream.status = serializer.validated_data["status"]
|
||||
stream.save()
|
||||
return Response(
|
||||
{
|
||||
"stream_id": str(stream.pk),
|
||||
|
||||
@@ -14,6 +14,7 @@ TENANT_APPS = [
|
||||
"authentik.enterprise.providers.ssf",
|
||||
"authentik.enterprise.providers.ws_federation",
|
||||
"authentik.enterprise.reports",
|
||||
"authentik.enterprise.stages.account_lockdown",
|
||||
"authentik.enterprise.stages.authenticator_endpoint_gdtc",
|
||||
"authentik.enterprise.stages.mtls",
|
||||
"authentik.enterprise.stages.source",
|
||||
|
||||
141
authentik/enterprise/stages/account_lockdown/api.py
Normal file
141
authentik/enterprise/stages/account_lockdown/api.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Account Lockdown Stage API Views"""
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import OpenApiExample, OpenApiResponse, extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import PrimaryKeyRelatedField
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.validation import validate
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import LinkSerializer, PassiveSerializer
|
||||
from authentik.core.models import (
|
||||
User,
|
||||
)
|
||||
from authentik.enterprise.api import EnterpriseRequiredMixin, enterprise_action
|
||||
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
|
||||
from authentik.enterprise.stages.account_lockdown.stage import (
|
||||
can_lock_user,
|
||||
get_lockdown_target_users,
|
||||
)
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class AccountLockdownStageSerializer(EnterpriseRequiredMixin, StageSerializer):
|
||||
"""AccountLockdownStage Serializer"""
|
||||
|
||||
class Meta:
|
||||
model = AccountLockdownStage
|
||||
fields = StageSerializer.Meta.fields + [
|
||||
"deactivate_user",
|
||||
"set_unusable_password",
|
||||
"delete_sessions",
|
||||
"revoke_tokens",
|
||||
"self_service_completion_flow",
|
||||
]
|
||||
|
||||
|
||||
class AccountLockdownStageViewSet(UsedByMixin, ModelViewSet):
|
||||
"""AccountLockdownStage Viewset"""
|
||||
|
||||
queryset = AccountLockdownStage.objects.all()
|
||||
serializer_class = AccountLockdownStageSerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
||||
|
||||
class UserAccountLockdownSerializer(PassiveSerializer):
|
||||
"""Choose the target account before starting the lockdown flow."""
|
||||
|
||||
user = PrimaryKeyRelatedField(
|
||||
queryset=get_lockdown_target_users(),
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text=_("User to lock. If omitted, locks the current user (self-service)."),
|
||||
)
|
||||
|
||||
|
||||
class UserAccountLockdownMixin:
|
||||
"""Enterprise account-lockdown API actions for UserViewSet."""
|
||||
|
||||
def _create_lockdown_flow_url(self, request: Request, user: User) -> str:
|
||||
"""Create a flow URL for account lockdown.
|
||||
|
||||
The request body selects the target before the flow starts. The API
|
||||
pre-plans the lockdown flow with the target as the pending user, so the
|
||||
account lockdown stage can use the normal flow context.
|
||||
"""
|
||||
flow = request._request.brand.flow_lockdown
|
||||
if flow is None:
|
||||
raise ValidationError({"non_field_errors": [_("No lockdown flow configured.")]})
|
||||
planner = FlowPlanner(flow)
|
||||
planner.use_cache = False
|
||||
try:
|
||||
plan = planner.plan(request._request, {PLAN_CONTEXT_PENDING_USER: user})
|
||||
except EmptyFlowException, FlowNonApplicableException:
|
||||
raise ValidationError(
|
||||
{"non_field_errors": [_("Lockdown flow is not applicable.")]}
|
||||
) from None
|
||||
return plan.to_redirect(request._request, flow).url
|
||||
|
||||
@extend_schema(
|
||||
description=_("Choose the target account, then return a flow link."),
|
||||
request=UserAccountLockdownSerializer,
|
||||
responses={
|
||||
"200": OpenApiResponse(
|
||||
response=LinkSerializer,
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Lockdown flow URL",
|
||||
value={
|
||||
"link": "https://example.invalid/if/flow/default-account-lockdown/",
|
||||
},
|
||||
response_only=True,
|
||||
status_codes=["200"],
|
||||
)
|
||||
],
|
||||
),
|
||||
"400": OpenApiResponse(
|
||||
description=_("No lockdown flow configured or the flow is not applicable")
|
||||
),
|
||||
"403": OpenApiResponse(
|
||||
description=_("Permission denied (when targeting another user)")
|
||||
),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
permission_classes=[IsAuthenticated],
|
||||
url_path="account_lockdown",
|
||||
)
|
||||
@validate(UserAccountLockdownSerializer)
|
||||
@enterprise_action
|
||||
def account_lockdown(self, request: Request, body: UserAccountLockdownSerializer) -> Response:
|
||||
"""Trigger account lockdown for a user.
|
||||
|
||||
If no user is specified, locks the current user (self-service).
|
||||
When targeting another user, admin permissions are required.
|
||||
|
||||
Returns a flow link for the frontend to follow. The flow is pre-planned
|
||||
with the target user as pending user for the lockdown stage.
|
||||
"""
|
||||
user = body.validated_data.get("user") or request.user
|
||||
|
||||
if not can_lock_user(request.user, user):
|
||||
LOGGER.debug("Permission denied for account lockdown", user=request.user)
|
||||
self.permission_denied(request)
|
||||
|
||||
flow_url = self._create_lockdown_flow_url(request, user)
|
||||
LOGGER.debug("Returning lockdown flow URL", flow_url=flow_url, user=user.username)
|
||||
return Response({"link": flow_url})
|
||||
12
authentik/enterprise/stages/account_lockdown/apps.py
Normal file
12
authentik/enterprise/stages/account_lockdown/apps.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""authentik account lockdown stage app config"""
|
||||
|
||||
from authentik.enterprise.apps import EnterpriseConfig
|
||||
|
||||
|
||||
class AuthentikEnterpriseStageAccountLockdownConfig(EnterpriseConfig):
|
||||
"""authentik account lockdown stage config"""
|
||||
|
||||
name = "authentik.enterprise.stages.account_lockdown"
|
||||
label = "authentik_stages_account_lockdown"
|
||||
verbose_name = "authentik Enterprise.Stages.Account Lockdown"
|
||||
default = True
|
||||
@@ -0,0 +1,74 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-19 21:56
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0031_alter_flow_layout"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AccountLockdownStage",
|
||||
fields=[
|
||||
(
|
||||
"stage_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="authentik_flows.stage",
|
||||
),
|
||||
),
|
||||
(
|
||||
"deactivate_user",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Deactivate the user account (set is_active to False)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"set_unusable_password",
|
||||
models.BooleanField(
|
||||
default=True, help_text="Set an unusable password for the user"
|
||||
),
|
||||
),
|
||||
(
|
||||
"delete_sessions",
|
||||
models.BooleanField(
|
||||
default=True, help_text="Delete all active sessions for the user"
|
||||
),
|
||||
),
|
||||
(
|
||||
"revoke_tokens",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Revoke all tokens for the user (API, app password, recovery, verification, OAuth)",
|
||||
),
|
||||
),
|
||||
(
|
||||
"self_service_completion_flow",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
help_text="Flow to redirect users to after self-service lockdown. This flow should not require authentication since the user's session is deleted.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="account_lockdown_stages",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Account Lockdown Stage",
|
||||
"verbose_name_plural": "Account Lockdown Stages",
|
||||
},
|
||||
bases=("authentik_flows.stage",),
|
||||
),
|
||||
]
|
||||
62
authentik/enterprise/stages/account_lockdown/models.py
Normal file
62
authentik/enterprise/stages/account_lockdown/models.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Account lockdown stage models"""
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
|
||||
from authentik.flows.models import Stage
|
||||
|
||||
|
||||
class AccountLockdownStage(Stage):
|
||||
"""Lock down a target user account."""
|
||||
|
||||
deactivate_user = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_("Deactivate the user account (set is_active to False)"),
|
||||
)
|
||||
set_unusable_password = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_("Set an unusable password for the user"),
|
||||
)
|
||||
delete_sessions = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_("Delete all active sessions for the user"),
|
||||
)
|
||||
revoke_tokens = models.BooleanField(
|
||||
default=True,
|
||||
help_text=_(
|
||||
"Revoke all tokens for the user (API, app password, recovery, verification, OAuth)"
|
||||
),
|
||||
)
|
||||
self_service_completion_flow = models.ForeignKey(
|
||||
"authentik_flows.Flow",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="account_lockdown_stages",
|
||||
help_text=_(
|
||||
"Flow to redirect users to after self-service lockdown. "
|
||||
"This flow should not require authentication since the user's session is deleted."
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def serializer(self) -> type[BaseSerializer]:
|
||||
from authentik.enterprise.stages.account_lockdown.api import AccountLockdownStageSerializer
|
||||
|
||||
return AccountLockdownStageSerializer
|
||||
|
||||
@property
|
||||
def view(self) -> type[View]:
|
||||
from authentik.enterprise.stages.account_lockdown.stage import AccountLockdownStageView
|
||||
|
||||
return AccountLockdownStageView
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
return "ak-stage-account-lockdown-form"
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Account Lockdown Stage")
|
||||
verbose_name_plural = _("Account Lockdown Stages")
|
||||
345
authentik/enterprise/stages/account_lockdown/stage.py
Normal file
345
authentik/enterprise/stages/account_lockdown/stage.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""Account lockdown stage logic"""
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.db.models import Model, QuerySet
|
||||
from django.db.models.query_utils import Q
|
||||
from django.db.transaction import atomic
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from dramatiq.actor import Actor
|
||||
from dramatiq.composition import group
|
||||
from dramatiq.results.errors import ResultTimeout
|
||||
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
ExpiringModel,
|
||||
Session,
|
||||
Token,
|
||||
User,
|
||||
UserTypes,
|
||||
)
|
||||
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.stage import StageView
|
||||
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 class_to_path
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
PLAN_CONTEXT_LOCKDOWN_REASON = "lockdown_reason"
|
||||
LOCKDOWN_EVENT_ACTION_ID = "account_lockdown"
|
||||
|
||||
TARGET_REQUIRED_MESSAGE = _("No target user specified for account lockdown")
|
||||
PERMISSION_DENIED_MESSAGE = _("You do not have permission to lock down this account.")
|
||||
ACCOUNT_LOCKDOWN_FAILED_MESSAGE = _("Account lockdown failed for this account.")
|
||||
SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE = _(
|
||||
"Self-service account lockdown requires a completion flow."
|
||||
)
|
||||
|
||||
|
||||
def get_lockdown_target_users() -> QuerySet[User]:
|
||||
"""Return users that can be targeted by account lockdown."""
|
||||
return User.objects.exclude_anonymous().exclude(type=UserTypes.INTERNAL_SERVICE_ACCOUNT)
|
||||
|
||||
|
||||
def _get_model_field(model: type[Model], field_name: str):
|
||||
"""Get a model field by name, if present."""
|
||||
try:
|
||||
return model._meta.get_field(field_name)
|
||||
except FieldDoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def _has_user_field(model: type[Model]) -> bool:
|
||||
"""Check if a model has a direct user foreign key."""
|
||||
field = _get_model_field(model, "user")
|
||||
return bool(field and getattr(field, "remote_field", None) and field.remote_field.model is User)
|
||||
|
||||
|
||||
def _has_authenticated_session_field(model: type[Model]) -> bool:
|
||||
"""Check if a model is linked to an authenticated session."""
|
||||
field = _get_model_field(model, "session")
|
||||
return bool(
|
||||
field
|
||||
and getattr(field, "remote_field", None)
|
||||
and field.remote_field.model is AuthenticatedSession
|
||||
)
|
||||
|
||||
|
||||
def _has_provider_field(model: type[Model]) -> bool:
|
||||
"""Check if a model is linked to a provider."""
|
||||
return _get_model_field(model, "provider") is not None
|
||||
|
||||
|
||||
def get_lockdown_token_models() -> tuple[type[Model], ...]:
|
||||
"""Return token, grant, and provider session models removed by account lockdown."""
|
||||
token_models: list[type[Model]] = []
|
||||
for model in apps.get_models():
|
||||
if model._meta.abstract or not issubclass(model, ExpiringModel):
|
||||
continue
|
||||
if model is Token:
|
||||
token_models.append(model)
|
||||
elif _has_user_field(model) and (
|
||||
_has_provider_field(model) or _has_authenticated_session_field(model)
|
||||
):
|
||||
token_models.append(model)
|
||||
elif _has_authenticated_session_field(model):
|
||||
token_models.append(model)
|
||||
return tuple(token_models)
|
||||
|
||||
|
||||
def get_lockdown_token_queryset(model: type[Model], user: User) -> QuerySet:
|
||||
"""Return account lockdown artifacts for a model and user."""
|
||||
manager = model.objects.including_expired()
|
||||
if _has_user_field(model):
|
||||
return manager.filter(user=user)
|
||||
return manager.filter(session__user=user)
|
||||
|
||||
|
||||
def can_lock_user(actor, user: User) -> bool:
|
||||
"""Check whether the actor may lock the target user."""
|
||||
if not actor.is_authenticated:
|
||||
return False
|
||||
if user.pk == actor.pk:
|
||||
return True
|
||||
return actor.has_perm("authentik_core.change_user", user)
|
||||
|
||||
|
||||
def get_outgoing_sync_tasks() -> tuple[tuple[type[OutgoingSyncProvider], Actor], ...]:
|
||||
"""Return outgoing sync provider types and their direct sync tasks."""
|
||||
from authentik.enterprise.providers.google_workspace.models import GoogleWorkspaceProvider
|
||||
from authentik.enterprise.providers.google_workspace.tasks import google_workspace_sync_direct
|
||||
from authentik.enterprise.providers.microsoft_entra.models import MicrosoftEntraProvider
|
||||
from authentik.enterprise.providers.microsoft_entra.tasks import microsoft_entra_sync_direct
|
||||
from authentik.providers.scim.models import SCIMProvider
|
||||
from authentik.providers.scim.tasks import scim_sync_direct
|
||||
|
||||
return (
|
||||
(SCIMProvider, scim_sync_direct),
|
||||
(GoogleWorkspaceProvider, google_workspace_sync_direct),
|
||||
(MicrosoftEntraProvider, microsoft_entra_sync_direct),
|
||||
)
|
||||
|
||||
|
||||
class AccountLockdownStageView(StageView):
|
||||
"""Execute account lockdown actions on the target user."""
|
||||
|
||||
def is_self_service(self, request: HttpRequest, user: User) -> bool:
|
||||
"""Check whether the currently authenticated user is locking their own account."""
|
||||
return request.user.is_authenticated and user.pk == request.user.pk
|
||||
|
||||
def get_reason(self) -> str:
|
||||
"""Get the lockdown reason from the plan context.
|
||||
|
||||
Priority:
|
||||
1. prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
|
||||
2. PLAN_CONTEXT_LOCKDOWN_REASON (explicitly set)
|
||||
3. Empty string as fallback
|
||||
"""
|
||||
prompt_data = self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {})
|
||||
if PLAN_CONTEXT_LOCKDOWN_REASON in prompt_data:
|
||||
return prompt_data[PLAN_CONTEXT_LOCKDOWN_REASON]
|
||||
return self.executor.plan.context.get(PLAN_CONTEXT_LOCKDOWN_REASON, "")
|
||||
|
||||
def _apply_lockdown_actions(self, stage: AccountLockdownStage, user: User) -> None:
|
||||
"""Apply the configured account changes to the target user."""
|
||||
if stage.deactivate_user:
|
||||
user.is_active = False
|
||||
if stage.set_unusable_password:
|
||||
user.set_unusable_password()
|
||||
if stage.deactivate_user:
|
||||
with sync_outgoing_inhibit_dispatch():
|
||||
user.save()
|
||||
return
|
||||
user.save()
|
||||
|
||||
def _sync_deactivated_user_to_outgoing_providers(self, user: User) -> None:
|
||||
"""Synchronize a deactivated user to outgoing sync providers."""
|
||||
messages = []
|
||||
wait_timeout = 0
|
||||
model = class_to_path(User)
|
||||
provider_filter = Q(backchannel_application__isnull=False) | Q(application__isnull=False)
|
||||
|
||||
for provider_model, task_sync_direct in get_outgoing_sync_tasks():
|
||||
for provider in provider_model.objects.filter(provider_filter):
|
||||
time_limit = int(
|
||||
timedelta_from_string(provider.sync_page_timeout).total_seconds() * 1000
|
||||
)
|
||||
messages.append(
|
||||
task_sync_direct.message_with_options(
|
||||
args=(model, user.pk, provider.pk),
|
||||
rel_obj=provider,
|
||||
time_limit=time_limit,
|
||||
uid=f"{provider.name}:user:{user.pk}:direct",
|
||||
)
|
||||
)
|
||||
wait_timeout += time_limit
|
||||
|
||||
if not messages:
|
||||
return
|
||||
try:
|
||||
group(messages).run().wait(timeout=wait_timeout)
|
||||
except ResultTimeout:
|
||||
self.logger.warning(
|
||||
"Timed out waiting for outgoing sync tasks; tasks remain queued",
|
||||
user=user.username,
|
||||
timeout=wait_timeout,
|
||||
)
|
||||
|
||||
def _get_lockdown_artifact_querysets(
|
||||
self, stage: AccountLockdownStage, user: User
|
||||
) -> tuple[QuerySet, ...]:
|
||||
"""Return the configured sessions and tokens targeted by lockdown."""
|
||||
querysets: list[QuerySet] = []
|
||||
if stage.delete_sessions:
|
||||
querysets.append(Session.objects.filter(authenticatedsession__user=user))
|
||||
if stage.revoke_tokens:
|
||||
querysets.extend(
|
||||
get_lockdown_token_queryset(model, user) for model in get_lockdown_token_models()
|
||||
)
|
||||
return tuple(querysets)
|
||||
|
||||
def _delete_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> None:
|
||||
"""Delete sessions and tokens selected by the lockdown configuration."""
|
||||
for queryset in self._get_lockdown_artifact_querysets(stage, user):
|
||||
queryset.delete()
|
||||
|
||||
def _has_lockdown_artifacts(self, stage: AccountLockdownStage, user: User) -> bool:
|
||||
"""Check whether there are still sessions or tokens to remove."""
|
||||
return any(
|
||||
queryset.exists() for queryset in self._get_lockdown_artifact_querysets(stage, user)
|
||||
)
|
||||
|
||||
def _emit_lockdown_event(self, request: HttpRequest, user: User, reason: str) -> None:
|
||||
"""Emit the audit event for a completed lockdown."""
|
||||
# Emit the audit event after the transaction commits. If event creation
|
||||
# fails here, dispatch() would otherwise treat the whole lockdown as
|
||||
# failed even though the account changes have already been committed.
|
||||
try:
|
||||
Event.new(
|
||||
EventAction.USER_WRITE,
|
||||
action_id=LOCKDOWN_EVENT_ACTION_ID,
|
||||
reason=reason,
|
||||
affected_user=user.username,
|
||||
).from_http(request)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Event emission should not make the lockdown itself fail.
|
||||
self.logger.warning(
|
||||
"Failed to emit account lockdown event",
|
||||
user=user.username,
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
def _lockdown_user(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
stage: AccountLockdownStage,
|
||||
user: User,
|
||||
reason: str,
|
||||
) -> None:
|
||||
"""Execute lockdown actions on a single user."""
|
||||
with atomic():
|
||||
user = User.objects.get(pk=user.pk)
|
||||
self._apply_lockdown_actions(stage, user)
|
||||
self._delete_lockdown_artifacts(stage, user)
|
||||
|
||||
# These additional checks/deletes are done to prevent a timing attack that creates tokens
|
||||
# with a compromised token that is simultaneously being deleted.
|
||||
while self._has_lockdown_artifacts(stage, user):
|
||||
with atomic():
|
||||
self._delete_lockdown_artifacts(stage, user)
|
||||
|
||||
if stage.deactivate_user:
|
||||
try:
|
||||
self._sync_deactivated_user_to_outgoing_providers(user)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Local lockdown has already committed. Provider sync failures
|
||||
# must not reopen access or mark the lockdown itself as failed.
|
||||
self.logger.warning(
|
||||
"Failed to sync account lockdown deactivation to outgoing providers",
|
||||
user=user.username,
|
||||
exc=exc,
|
||||
)
|
||||
self._emit_lockdown_event(request, user, reason)
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Execute account lockdown actions."""
|
||||
self.request = request
|
||||
stage: AccountLockdownStage = self.executor.current_stage
|
||||
|
||||
pending_user = self.get_pending_user()
|
||||
if not pending_user.is_authenticated:
|
||||
self.logger.warning("No target user found for account lockdown")
|
||||
return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
|
||||
user = get_lockdown_target_users().filter(pk=pending_user.pk).first()
|
||||
if user is None:
|
||||
self.logger.warning("Target user is not eligible for account lockdown")
|
||||
return self.executor.stage_invalid(TARGET_REQUIRED_MESSAGE)
|
||||
if not can_lock_user(request.user, user):
|
||||
self.logger.warning(
|
||||
"Permission denied for account lockdown",
|
||||
actor=getattr(request.user, "username", None),
|
||||
target=user.username,
|
||||
)
|
||||
return self.executor.stage_invalid(PERMISSION_DENIED_MESSAGE)
|
||||
|
||||
reason = self.get_reason()
|
||||
self_service = self.is_self_service(request, user)
|
||||
if self_service and stage.delete_sessions and not stage.self_service_completion_flow:
|
||||
self.logger.warning("No completion flow configured for self-service account lockdown")
|
||||
return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)
|
||||
|
||||
self.logger.info(
|
||||
"Executing account lockdown",
|
||||
user=user.username,
|
||||
reason=reason,
|
||||
self_service=self_service,
|
||||
deactivate_user=stage.deactivate_user,
|
||||
set_unusable_password=stage.set_unusable_password,
|
||||
delete_sessions=stage.delete_sessions,
|
||||
revoke_tokens=stage.revoke_tokens,
|
||||
)
|
||||
|
||||
try:
|
||||
self._lockdown_user(request, stage, user, reason)
|
||||
self.logger.info("Account lockdown completed", user=user.username)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Convert unexpected lockdown errors to a flow-stage failure instead
|
||||
# of leaking an exception through the flow executor.
|
||||
self.logger.warning("Account lockdown failed", user=user.username, exc=exc)
|
||||
return self.executor.stage_invalid(ACCOUNT_LOCKDOWN_FAILED_MESSAGE)
|
||||
|
||||
if self_service:
|
||||
if stage.delete_sessions:
|
||||
return self._self_service_completion_response(request)
|
||||
return self.executor.stage_ok()
|
||||
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def _self_service_completion_response(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Redirect to completion flow after self-service lockdown.
|
||||
|
||||
Since all sessions are deleted, the user cannot continue in the flow.
|
||||
Redirect them to an unauthenticated completion flow that shows the
|
||||
lockdown message.
|
||||
|
||||
We use a direct HTTP redirect instead of a challenge because the
|
||||
flow executor's challenge handling may try to access the session
|
||||
which we just deleted.
|
||||
"""
|
||||
stage: AccountLockdownStage = self.executor.current_stage
|
||||
completion_flow = stage.self_service_completion_flow
|
||||
if completion_flow:
|
||||
# Flush the current request's session to prevent Django's session
|
||||
# middleware from trying to save a deleted session
|
||||
if hasattr(request, "session"):
|
||||
request.session.flush()
|
||||
redirect_to = reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={"flow_slug": completion_flow.slug},
|
||||
)
|
||||
return HttpResponseRedirect(redirect_to)
|
||||
return self.executor.stage_invalid(SELF_SERVICE_COMPLETION_FLOW_REQUIRED_MESSAGE)
|
||||
148
authentik/enterprise/stages/account_lockdown/tests/test_api.py
Normal file
148
authentik/enterprise/stages/account_lockdown/tests/test_api.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Test Users Account Lockdown API"""
|
||||
|
||||
from json import loads
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import (
|
||||
create_test_brand,
|
||||
create_test_flow,
|
||||
create_test_user,
|
||||
)
|
||||
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
# Patch for enterprise license check
|
||||
patch_license = patch(
|
||||
"authentik.enterprise.models.LicenseUsageStatus.is_valid",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
|
||||
@patch_license
|
||||
class AccountLockdownAPITestCase(APITestCase):
|
||||
"""Shared helpers for account lockdown API tests."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.lockdown_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
|
||||
self.lockdown_stage = AccountLockdownStage.objects.create(name=generate_id())
|
||||
FlowStageBinding.objects.create(
|
||||
target=self.lockdown_flow,
|
||||
stage=self.lockdown_stage,
|
||||
order=0,
|
||||
)
|
||||
self.brand = create_test_brand()
|
||||
self.brand.flow_lockdown = self.lockdown_flow
|
||||
self.brand.save()
|
||||
|
||||
def create_user_with_email(self):
|
||||
"""Create a regular user with a unique email address."""
|
||||
user = create_test_user()
|
||||
user.email = f"{generate_id()}@test.com"
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def assert_redirect_targets(self, response, user):
|
||||
"""Assert that a response contains a pre-planned lockdown flow link for a user."""
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content)
|
||||
self.assertIn(self.lockdown_flow.slug, body["link"])
|
||||
self.assertEqual(urlparse(body["link"]).query, "")
|
||||
plan = self.client.session[SESSION_KEY_PLAN]
|
||||
self.assertEqual(plan.context[PLAN_CONTEXT_PENDING_USER].pk, user.pk)
|
||||
|
||||
def assert_no_flow_configured(self, response):
|
||||
"""Assert that the API reports a missing lockdown flow."""
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content)
|
||||
self.assertIn("No lockdown flow configured", body["non_field_errors"][0])
|
||||
|
||||
|
||||
@patch_license
|
||||
class TestUsersAccountLockdownAPI(AccountLockdownAPITestCase):
|
||||
"""Test Users Account Lockdown API"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.actor = create_test_user()
|
||||
self.user = self.create_user_with_email()
|
||||
|
||||
def test_account_lockdown_with_change_user_returns_redirect(self):
|
||||
"""Test that account lockdown allows users with change_user permission."""
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
|
||||
self.client.force_login(self.actor)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-account-lockdown"),
|
||||
data={"user": self.user.pk},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assert_redirect_targets(response, self.user)
|
||||
|
||||
def test_account_lockdown_no_flow_configured(self):
|
||||
"""Test account lockdown when no flow is configured"""
|
||||
self.brand.flow_lockdown = None
|
||||
self.brand.save()
|
||||
self.actor.assign_perms_to_managed_role("authentik_core.change_user", self.user)
|
||||
self.client.force_login(self.actor)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-account-lockdown"),
|
||||
data={"user": self.user.pk},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assert_no_flow_configured(response)
|
||||
|
||||
def test_account_lockdown_unauthenticated(self):
|
||||
"""Test account lockdown requires authentication"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-account-lockdown"),
|
||||
data={"user": self.user.pk},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_account_lockdown_without_change_user_denied(self):
|
||||
"""Test account lockdown denies users without change_user permission."""
|
||||
self.client.force_login(self.actor)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-account-lockdown"),
|
||||
data={"user": self.user.pk},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_account_lockdown_self_returns_redirect(self):
|
||||
"""Test successful self-service account lockdown returns a direct redirect."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-account-lockdown"),
|
||||
data={},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assert_redirect_targets(response, self.user)
|
||||
|
||||
def test_account_lockdown_self_target_without_change_user_returns_redirect(self):
|
||||
"""Test self-service does not require change_user permission."""
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-account-lockdown"),
|
||||
data={"user": self.user.pk},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assert_redirect_targets(response, self.user)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Tests for the packaged account-lockdown blueprint."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from authentik.blueprints.models import BlueprintInstance
|
||||
from authentik.blueprints.v1.importer import Importer
|
||||
from authentik.blueprints.v1.tasks import blueprints_find, check_blueprint_v1_file
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.flows.models import Flow
|
||||
|
||||
BLUEPRINT_PATH = "example/flow-default-account-lockdown.yaml"
|
||||
|
||||
|
||||
class TestAccountLockdownBlueprint(TransactionTestCase):
|
||||
"""Test the packaged account-lockdown blueprint behavior."""
|
||||
|
||||
def test_blueprint_is_not_auto_instantiated(self):
|
||||
"""Test the packaged blueprint is opt-in and skipped by discovery."""
|
||||
BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).delete()
|
||||
blueprint = next(item for item in blueprints_find() if item.path == BLUEPRINT_PATH)
|
||||
|
||||
check_blueprint_v1_file(blueprint)
|
||||
|
||||
self.assertFalse(BlueprintInstance.objects.filter(path=BLUEPRINT_PATH).exists())
|
||||
|
||||
def test_blueprint_requires_licensed_context(self):
|
||||
"""Test manual import only creates flows when enterprise is licensed."""
|
||||
content = BlueprintInstance(path=BLUEPRINT_PATH).retrieve()
|
||||
license_key = LicenseKey("test", 253402300799, "Test license", 1000, 1000)
|
||||
|
||||
with patch("authentik.enterprise.license.LicenseKey.get_total", return_value=license_key):
|
||||
importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": False})
|
||||
valid, logs = importer.validate()
|
||||
self.assertTrue(valid, logs)
|
||||
self.assertTrue(importer.apply())
|
||||
self.assertFalse(Flow.objects.filter(slug="default-account-lockdown").exists())
|
||||
self.assertFalse(Flow.objects.filter(slug="default-account-lockdown-complete").exists())
|
||||
|
||||
importer = Importer.from_string(content, {"goauthentik.io/enterprise/licensed": True})
|
||||
valid, logs = importer.validate()
|
||||
self.assertTrue(valid, logs)
|
||||
self.assertTrue(importer.apply())
|
||||
self.assertTrue(Flow.objects.filter(slug="default-account-lockdown").exists())
|
||||
self.assertTrue(Flow.objects.filter(slug="default-account-lockdown-complete").exists())
|
||||
627
authentik/enterprise/stages/account_lockdown/tests/test_stage.py
Normal file
627
authentik/enterprise/stages/account_lockdown/tests/test_stage.py
Normal file
@@ -0,0 +1,627 @@
|
||||
"""Account lockdown stage tests"""
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from threading import Event as ThreadEvent
|
||||
from threading import Thread
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.db import connection
|
||||
from django.http import HttpResponse
|
||||
from django.test import TransactionTestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from dramatiq.results.errors import ResultTimeout
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, Session, Token, TokenIntents
|
||||
from authentik.core.tests.utils import (
|
||||
RequestFactory,
|
||||
create_test_admin_user,
|
||||
create_test_cert,
|
||||
create_test_flow,
|
||||
create_test_user,
|
||||
)
|
||||
from authentik.enterprise.stages.account_lockdown.models import AccountLockdownStage
|
||||
from authentik.enterprise.stages.account_lockdown.stage import (
|
||||
LOCKDOWN_EVENT_ACTION_ID,
|
||||
PLAN_CONTEXT_LOCKDOWN_REASON,
|
||||
AccountLockdownStageView,
|
||||
can_lock_user,
|
||||
)
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.markers import StageMarker
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.providers.oauth2.id_token import IDToken
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
DeviceToken,
|
||||
OAuth2Provider,
|
||||
RedirectURI,
|
||||
RedirectURIMatchingMode,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.saml.models import SAMLProvider, SAMLSession
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
patch_enterprise_enabled = patch(
|
||||
"authentik.enterprise.apps.AuthentikEnterpriseConfig.check_enabled",
|
||||
return_value=True,
|
||||
)
|
||||
|
||||
|
||||
class AccountLockdownStageTestMixin:
|
||||
"""Shared setup helpers for account lockdown stage tests."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.patch_enterprise_enabled = patch_enterprise_enabled.start()
|
||||
cls.patch_event_dispatch = patch("authentik.events.tasks.event_trigger_dispatch.send")
|
||||
cls.patch_event_dispatch.start()
|
||||
super().setUpClass()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.patch_event_dispatch.stop()
|
||||
patch_enterprise_enabled.stop()
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = create_test_admin_user()
|
||||
self.target_user = create_test_admin_user()
|
||||
self.flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
|
||||
self.stage = AccountLockdownStage.objects.create(
|
||||
name="lockdown",
|
||||
)
|
||||
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0)
|
||||
self.request_factory = RequestFactory()
|
||||
|
||||
def make_stage_view(self, plan: FlowPlan):
|
||||
def _stage_ok():
|
||||
return HttpResponse(status=204)
|
||||
|
||||
def _stage_invalid(_error_message=None):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
return AccountLockdownStageView(
|
||||
SimpleNamespace(
|
||||
plan=plan,
|
||||
current_stage=self.stage,
|
||||
current_binding=self.binding,
|
||||
flow=self.flow,
|
||||
stage_ok=_stage_ok,
|
||||
stage_invalid=_stage_invalid,
|
||||
)
|
||||
)
|
||||
|
||||
def make_request(self, *, user=None, query=None):
|
||||
return self.request_factory.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
query_params=query or {},
|
||||
user=user,
|
||||
)
|
||||
|
||||
def get_lockdown_event(self):
|
||||
"""Return the account-lockdown user-write event."""
|
||||
return Event.objects.filter(
|
||||
action=EventAction.USER_WRITE,
|
||||
context__action_id=LOCKDOWN_EVENT_ACTION_ID,
|
||||
).first()
|
||||
|
||||
|
||||
class TestAccountLockdownStage(AccountLockdownStageTestMixin, FlowTestCase):
|
||||
"""Account lockdown stage tests"""
|
||||
|
||||
def test_lockdown_no_target(self):
|
||||
"""Test lockdown stage with no pending user fails"""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
|
||||
response = view.dispatch(self.make_request())
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_lockdown_with_pending_user(self):
|
||||
"""Test lockdown stage with a pending target user."""
|
||||
self.target_user.is_active = True
|
||||
self.target_user.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Security incident"
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
|
||||
view = self.make_stage_view(plan)
|
||||
request = self.make_request(user=self.user)
|
||||
|
||||
self.assertTrue(can_lock_user(request.user, self.target_user))
|
||||
response = view.dispatch(request)
|
||||
|
||||
self.target_user.refresh_from_db()
|
||||
self.assertFalse(self.target_user.is_active)
|
||||
self.assertFalse(self.target_user.has_usable_password())
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
# Check event was created
|
||||
event = self.get_lockdown_event()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event.context["action_id"], LOCKDOWN_EVENT_ACTION_ID)
|
||||
self.assertEqual(event.context["reason"], "Security incident")
|
||||
self.assertEqual(event.context["affected_user"], self.target_user.username)
|
||||
|
||||
def test_lockdown_with_pending_user_reason(self):
|
||||
"""Test lockdown stage with a pending target and explicit reason."""
|
||||
self.target_user.is_active = True
|
||||
self.target_user.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_LOCKDOWN_REASON] = "Compromised account"
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
|
||||
view = self.make_stage_view(plan)
|
||||
request = self.make_request(user=self.user)
|
||||
|
||||
self.assertTrue(can_lock_user(request.user, self.target_user))
|
||||
response = view.dispatch(request)
|
||||
|
||||
self.target_user.refresh_from_db()
|
||||
self.assertFalse(self.target_user.is_active)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
def test_lockdown_reason_from_prompt(self):
|
||||
"""Test lockdown stage reads the reason from prompt data."""
|
||||
self.target_user.is_active = True
|
||||
self.target_user.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PROMPT] = {
|
||||
PLAN_CONTEXT_LOCKDOWN_REASON: "User requested lockdown",
|
||||
}
|
||||
view = self.make_stage_view(plan)
|
||||
request = self.make_request(user=self.user)
|
||||
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
|
||||
|
||||
event = self.get_lockdown_event()
|
||||
self.assertIsNotNone(event)
|
||||
self.assertEqual(event.context["reason"], "User requested lockdown")
|
||||
|
||||
def test_lockdown_event_failure_does_not_fail_self_service(self):
|
||||
"""Test lockdown still succeeds when event emission fails."""
|
||||
self.stage.delete_sessions = False
|
||||
self.stage.save()
|
||||
|
||||
self.target_user.is_active = True
|
||||
self.target_user.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
|
||||
view = self.make_stage_view(plan)
|
||||
request = self.make_request(user=self.target_user)
|
||||
|
||||
original_event_new = Event.new
|
||||
|
||||
def _event_new_side_effect(action, *args, **kwargs):
|
||||
if (
|
||||
action == EventAction.USER_WRITE
|
||||
and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
|
||||
):
|
||||
raise RuntimeError("simulated event failure")
|
||||
return original_event_new(action, *args, **kwargs)
|
||||
|
||||
with patch(
|
||||
"authentik.enterprise.stages.account_lockdown.stage.Event.new",
|
||||
side_effect=_event_new_side_effect,
|
||||
):
|
||||
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
|
||||
|
||||
self.target_user.refresh_from_db()
|
||||
self.assertFalse(self.target_user.is_active)
|
||||
|
||||
def test_dispatch_records_success_when_event_emission_fails(self):
|
||||
"""Test dispatch still completes if event emission fails."""
|
||||
self.stage.delete_sessions = False
|
||||
self.stage.save()
|
||||
|
||||
self.target_user.is_active = True
|
||||
self.target_user.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
|
||||
view = self.make_stage_view(plan)
|
||||
request = self.make_request(
|
||||
user=self.target_user,
|
||||
)
|
||||
|
||||
original_event_new = Event.new
|
||||
|
||||
def _event_new_side_effect(action, *args, **kwargs):
|
||||
if (
|
||||
action == EventAction.USER_WRITE
|
||||
and kwargs.get("action_id") == LOCKDOWN_EVENT_ACTION_ID
|
||||
):
|
||||
raise RuntimeError("simulated event failure")
|
||||
return original_event_new(action, *args, **kwargs)
|
||||
|
||||
with patch(
|
||||
"authentik.enterprise.stages.account_lockdown.stage.Event.new",
|
||||
side_effect=_event_new_side_effect,
|
||||
):
|
||||
response = view.dispatch(request)
|
||||
|
||||
self.target_user.refresh_from_db()
|
||||
self.assertFalse(self.target_user.is_active)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
def test_lockdown_self_service_redirects_to_completion_flow(self):
|
||||
"""Test self-service lockdown redirects to completion flow when sessions are deleted."""
|
||||
completion_flow = create_test_flow(FlowDesignation.STAGE_CONFIGURATION)
|
||||
self.stage.self_service_completion_flow = completion_flow
|
||||
self.stage.save()
|
||||
|
||||
self.target_user.is_active = True
|
||||
self.target_user.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
request = self.make_request(user=self.target_user)
|
||||
view._lockdown_user(request, self.stage, self.target_user, view.get_reason())
|
||||
response = view._self_service_completion_response(request)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(
|
||||
response.url,
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": completion_flow.slug}),
|
||||
)
|
||||
|
||||
def test_lockdown_self_service_requires_completion_flow(self):
|
||||
"""Test self-service lockdown fails before deleting sessions without a completion flow."""
|
||||
self.stage.self_service_completion_flow = None
|
||||
self.stage.save()
|
||||
|
||||
self.target_user.is_active = True
|
||||
self.target_user.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
|
||||
view = self.make_stage_view(plan)
|
||||
request = self.make_request(user=self.target_user)
|
||||
|
||||
response = view.dispatch(request)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.target_user.refresh_from_db()
|
||||
self.assertTrue(self.target_user.is_active)
|
||||
|
||||
def test_lockdown_denies_other_user_without_permission(self):
|
||||
"""Test lockdown stage rejects non-self requests without change_user permission."""
|
||||
actor = create_test_user()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PENDING_USER] = self.target_user
|
||||
view = self.make_stage_view(plan)
|
||||
request = self.make_request(user=actor)
|
||||
|
||||
self.assertFalse(can_lock_user(request.user, self.target_user))
|
||||
response = view.dispatch(request)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_lockdown_revokes_tokens(self):
|
||||
"""Test lockdown stage revokes tokens"""
|
||||
Token.objects.create(
|
||||
user=self.target_user,
|
||||
identifier="test-token",
|
||||
intent=TokenIntents.INTENT_API,
|
||||
key=generate_id(),
|
||||
expiring=False,
|
||||
)
|
||||
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
|
||||
|
||||
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)
|
||||
|
||||
def test_lockdown_revokes_provider_tokens(self):
|
||||
"""Test lockdown stage revokes provider tokens and sessions."""
|
||||
oauth_provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris=[
|
||||
RedirectURI(RedirectURIMatchingMode.STRICT, "http://testserver/callback")
|
||||
],
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
saml_provider = SAMLProvider.objects.create(
|
||||
name=generate_id(),
|
||||
authorization_flow=create_test_flow(),
|
||||
acs_url="https://sp.example.com/acs",
|
||||
issuer_override="https://idp.example.com",
|
||||
)
|
||||
session = Session.objects.create(
|
||||
session_key=generate_id(),
|
||||
expires=timezone.now() + timezone.timedelta(hours=1),
|
||||
last_ip="127.0.0.1",
|
||||
)
|
||||
auth_session = AuthenticatedSession.objects.create(
|
||||
session=session,
|
||||
user=self.target_user,
|
||||
)
|
||||
grant_kwargs = {
|
||||
"provider": oauth_provider,
|
||||
"user": self.target_user,
|
||||
"auth_time": timezone.now(),
|
||||
"_scope": "openid profile",
|
||||
"expiring": False,
|
||||
}
|
||||
token_kwargs = grant_kwargs | {"_id_token": json.dumps(asdict(IDToken("foo", "bar")))}
|
||||
AuthorizationCode.objects.create(
|
||||
code=generate_id(),
|
||||
session=auth_session,
|
||||
**grant_kwargs,
|
||||
)
|
||||
AccessToken.objects.create(
|
||||
token=generate_id(),
|
||||
session=auth_session,
|
||||
**token_kwargs,
|
||||
)
|
||||
RefreshToken.objects.create(
|
||||
token=generate_id(),
|
||||
session=auth_session,
|
||||
**token_kwargs,
|
||||
)
|
||||
DeviceToken.objects.create(
|
||||
provider=oauth_provider,
|
||||
user=self.target_user,
|
||||
session=auth_session,
|
||||
_scope="openid profile",
|
||||
expiring=False,
|
||||
)
|
||||
SAMLSession.objects.create(
|
||||
provider=saml_provider,
|
||||
user=self.target_user,
|
||||
session=auth_session,
|
||||
session_index=generate_id(),
|
||||
name_id=self.target_user.email,
|
||||
expires=timezone.now() + timezone.timedelta(hours=1),
|
||||
expiring=True,
|
||||
)
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
|
||||
|
||||
self.assertEqual(AuthorizationCode.objects.filter(user=self.target_user).count(), 0)
|
||||
self.assertEqual(AccessToken.objects.filter(user=self.target_user).count(), 0)
|
||||
self.assertEqual(RefreshToken.objects.filter(user=self.target_user).count(), 0)
|
||||
self.assertEqual(DeviceToken.objects.filter(user=self.target_user).count(), 0)
|
||||
self.assertEqual(SAMLSession.objects.filter(user=self.target_user).count(), 0)
|
||||
|
||||
def test_lockdown_selective_actions(self):
|
||||
"""Test lockdown stage with selective actions"""
|
||||
self.stage.deactivate_user = True
|
||||
self.stage.set_unusable_password = False
|
||||
self.stage.delete_sessions = False
|
||||
self.stage.revoke_tokens = False
|
||||
self.stage.save()
|
||||
|
||||
self.target_user.is_active = True
|
||||
self.target_user.set_password("testpassword")
|
||||
self.target_user.save()
|
||||
|
||||
Token.objects.create(
|
||||
user=self.target_user,
|
||||
identifier="test-token",
|
||||
intent=TokenIntents.INTENT_API,
|
||||
key=generate_id(),
|
||||
expiring=False,
|
||||
)
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
|
||||
|
||||
self.target_user.refresh_from_db()
|
||||
# User should be deactivated
|
||||
self.assertFalse(self.target_user.is_active)
|
||||
# Password should still be usable
|
||||
self.assertTrue(self.target_user.has_usable_password())
|
||||
# Token should still exist
|
||||
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 1)
|
||||
|
||||
def test_lockdown_no_actions(self):
|
||||
"""Test lockdown stage with all actions disabled"""
|
||||
self.stage.deactivate_user = False
|
||||
self.stage.set_unusable_password = False
|
||||
self.stage.delete_sessions = False
|
||||
self.stage.revoke_tokens = False
|
||||
self.stage.save()
|
||||
|
||||
self.target_user.is_active = True
|
||||
self.target_user.set_password("testpassword")
|
||||
self.target_user.save()
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
|
||||
|
||||
self.target_user.refresh_from_db()
|
||||
# User should still be active
|
||||
self.assertTrue(self.target_user.is_active)
|
||||
# Password should still be usable
|
||||
self.assertTrue(self.target_user.has_usable_password())
|
||||
# Event should still be created
|
||||
event = self.get_lockdown_event()
|
||||
self.assertIsNotNone(event)
|
||||
|
||||
def test_lockdown_deactivation_inhibits_signal_dispatch_until_after_commit(self):
|
||||
"""Test lockdown queues explicit outgoing syncs after the deactivation transaction."""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"authentik.enterprise.stages.account_lockdown.stage.sync_outgoing_inhibit_dispatch"
|
||||
) as inhibit,
|
||||
patch.object(view, "_sync_deactivated_user_to_outgoing_providers") as sync_outgoing,
|
||||
):
|
||||
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
|
||||
|
||||
inhibit.assert_called_once()
|
||||
sync_outgoing.assert_called_once()
|
||||
synced_user = sync_outgoing.call_args.args[0]
|
||||
self.assertEqual(synced_user.pk, self.target_user.pk)
|
||||
self.assertFalse(synced_user.is_active)
|
||||
|
||||
def test_lockdown_waits_for_direct_outgoing_provider_syncs(self):
|
||||
"""Test direct outgoing sync tasks are enqueued and waited on."""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
|
||||
task_sync_direct = MagicMock()
|
||||
task_sync_direct.message_with_options.return_value = "direct-message"
|
||||
provider_model = SimpleNamespace(
|
||||
objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
|
||||
)
|
||||
task_group = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
|
||||
return_value=((provider_model, task_sync_direct),),
|
||||
),
|
||||
patch(
|
||||
"authentik.enterprise.stages.account_lockdown.stage.group",
|
||||
return_value=task_group,
|
||||
) as task_group_cls,
|
||||
):
|
||||
view._sync_deactivated_user_to_outgoing_providers(self.target_user)
|
||||
|
||||
task_sync_direct.message_with_options.assert_called_once_with(
|
||||
args=(class_to_path(type(self.target_user)), self.target_user.pk, provider.pk),
|
||||
rel_obj=provider,
|
||||
time_limit=5000,
|
||||
uid=f"{provider.name}:user:{self.target_user.pk}:direct",
|
||||
)
|
||||
task_group_cls.assert_called_once_with(["direct-message"])
|
||||
task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
|
||||
|
||||
def test_lockdown_outgoing_provider_sync_timeout_leaves_tasks_running(self):
|
||||
"""Test timeout while waiting for direct outgoing syncs does not fail lockdown."""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
provider = SimpleNamespace(name="outgoing", pk=1, sync_page_timeout="seconds=5")
|
||||
task_sync_direct = MagicMock()
|
||||
task_sync_direct.message_with_options.return_value = "direct-message"
|
||||
provider_model = SimpleNamespace(
|
||||
objects=SimpleNamespace(filter=MagicMock(return_value=[provider]))
|
||||
)
|
||||
task_group = MagicMock()
|
||||
task_group.run.return_value.wait.side_effect = ResultTimeout("timed out")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"authentik.enterprise.stages.account_lockdown.stage.get_outgoing_sync_tasks",
|
||||
return_value=((provider_model, task_sync_direct),),
|
||||
),
|
||||
patch(
|
||||
"authentik.enterprise.stages.account_lockdown.stage.group",
|
||||
return_value=task_group,
|
||||
),
|
||||
):
|
||||
view._sync_deactivated_user_to_outgoing_providers(self.target_user)
|
||||
|
||||
task_group.run.assert_called_once_with()
|
||||
task_group.run.return_value.wait.assert_called_once_with(timeout=5000)
|
||||
|
||||
def test_lockdown_outgoing_provider_sync_failure_does_not_fail_lockdown(self):
|
||||
"""Test completed local lockdown still emits an event if outgoing sync fails."""
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
|
||||
with patch.object(
|
||||
view,
|
||||
"_sync_deactivated_user_to_outgoing_providers",
|
||||
side_effect=ValueError("sync failed"),
|
||||
):
|
||||
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
|
||||
|
||||
self.target_user.refresh_from_db()
|
||||
self.assertFalse(self.target_user.is_active)
|
||||
event = self.get_lockdown_event()
|
||||
self.assertIsNotNone(event)
|
||||
|
||||
|
||||
class TestAccountLockdownStageConcurrency(AccountLockdownStageTestMixin, TransactionTestCase):
|
||||
"""Account lockdown concurrency tests."""
|
||||
|
||||
def test_lockdown_retries_when_another_transaction_recreates_a_token(self):
|
||||
"""Lockdown should remove a token recreated before the retry check runs."""
|
||||
Token.objects.create(
|
||||
user=self.target_user,
|
||||
identifier=f"initial-token-{generate_id()}",
|
||||
intent=TokenIntents.INTENT_API,
|
||||
key=generate_id(),
|
||||
expiring=False,
|
||||
)
|
||||
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
view = self.make_stage_view(plan)
|
||||
original_has_artifacts = view._has_lockdown_artifacts
|
||||
target_user = self.target_user
|
||||
thread_ready = ThreadEvent()
|
||||
start_create = ThreadEvent()
|
||||
thread_done = ThreadEvent()
|
||||
thread_errors = []
|
||||
|
||||
class TokenCreatorThread(Thread):
|
||||
__test__ = False
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
thread_ready.set()
|
||||
if not start_create.wait(timeout=5):
|
||||
thread_errors.append("timed out waiting to recreate token")
|
||||
return
|
||||
Token.objects.create(
|
||||
user=target_user,
|
||||
identifier=f"concurrent-token-{generate_id()}",
|
||||
intent=TokenIntents.INTENT_API,
|
||||
key=generate_id(),
|
||||
expiring=False,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
thread_errors.append(exc)
|
||||
finally:
|
||||
thread_done.set()
|
||||
connection.close()
|
||||
|
||||
def has_artifacts_after_concurrent_create(stage, user):
|
||||
if not start_create.is_set():
|
||||
start_create.set()
|
||||
self.assertTrue(
|
||||
thread_done.wait(timeout=30),
|
||||
(
|
||||
"Concurrent token creation did not complete "
|
||||
f"before retry check: {thread_errors}"
|
||||
),
|
||||
)
|
||||
return original_has_artifacts(stage, user)
|
||||
|
||||
creator = TokenCreatorThread()
|
||||
with patch.object(
|
||||
view, "_has_lockdown_artifacts", side_effect=has_artifacts_after_concurrent_create
|
||||
):
|
||||
creator.start()
|
||||
self.assertTrue(
|
||||
thread_ready.wait(timeout=5),
|
||||
"Concurrent token creation thread did not start",
|
||||
)
|
||||
view._lockdown_user(self.make_request(user=self.user), self.stage, self.target_user, "")
|
||||
creator.join()
|
||||
|
||||
self.assertEqual(thread_errors, [])
|
||||
self.assertEqual(Token.objects.filter(user=self.target_user).count(), 0)
|
||||
5
authentik/enterprise/stages/account_lockdown/urls.py
Normal file
5
authentik/enterprise/stages/account_lockdown/urls.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""API URLs"""
|
||||
|
||||
from authentik.enterprise.stages.account_lockdown.api import AccountLockdownStageViewSet
|
||||
|
||||
api_urlpatterns = [("stages/account_lockdown", AccountLockdownStageViewSet)]
|
||||
@@ -8,7 +8,6 @@ from inspect import currentframe
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
@@ -410,7 +409,7 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
)
|
||||
notification.save()
|
||||
layer = get_channel_layer()
|
||||
async_to_sync(layer.group_send)(
|
||||
layer.group_send_blocking(
|
||||
build_user_group(notification.user),
|
||||
{
|
||||
"type": "event.notification",
|
||||
|
||||
@@ -29,6 +29,7 @@ class RefreshOtherFlowsAfterAuthentication(Flag[bool], key="flows_refresh_others
|
||||
default = False
|
||||
visibility = "public"
|
||||
description = _("Refresh other tabs after successful authentication.")
|
||||
deprecated = True
|
||||
|
||||
|
||||
class ContinuousLogin(Flag[bool], key="flows_continuous_login"):
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
background-image: url("{{ flow_background_url }}");
|
||||
background-image: url("{{ flow_background_url|iriencode|safe }}");
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
|
||||
<style data-id="flow-css">
|
||||
:root {
|
||||
--ak-global--background-image: url("{{ flow_background_url }}");
|
||||
--ak-global--background-image: url("{{ flow_background_url|iriencode|safe }}");
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""stage view tests"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.tests.utils import RequestFactory as AuthentikRequestFactory
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.flows.models import FlowStageBinding
|
||||
from authentik.flows.models import Flow, FlowStageBinding
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
@@ -42,6 +44,46 @@ class TestViews(TestCase):
|
||||
"/static/dist/assets/images/flow_background.jpg",
|
||||
)
|
||||
|
||||
def test_flow_interface_css_background_preserves_presigned_url_query(self):
|
||||
"""Test flow CSS keeps signed URL query separators intact."""
|
||||
flow = create_test_flow()
|
||||
background_url = (
|
||||
"https://s3.ca-central-1.amazonaws.com/example/media/public/background.png"
|
||||
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential"
|
||||
"&X-Amz-Signature=signature"
|
||||
)
|
||||
|
||||
with patch.object(Flow, "background_url", return_value=background_url):
|
||||
response = self.client.get(
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response,
|
||||
f'--ak-global--background-image: url("{background_url}");',
|
||||
html=False,
|
||||
)
|
||||
|
||||
def test_flow_sfe_css_background_preserves_presigned_url_query(self):
|
||||
"""Test SFE flow CSS keeps signed URL query separators intact."""
|
||||
flow = create_test_flow()
|
||||
background_url = (
|
||||
"https://s3.ca-central-1.amazonaws.com/example/media/public/background.png"
|
||||
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential"
|
||||
"&X-Amz-Signature=signature"
|
||||
)
|
||||
|
||||
with patch.object(Flow, "background_url", return_value=background_url):
|
||||
response = self.client.get(
|
||||
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) + "?sfe"
|
||||
)
|
||||
|
||||
self.assertContains(
|
||||
response,
|
||||
f'background-image: url("{background_url}");',
|
||||
html=False,
|
||||
)
|
||||
|
||||
|
||||
def view_tester_factory(view_class: type[StageView]) -> Callable:
|
||||
"""Test a form"""
|
||||
|
||||
@@ -53,6 +53,16 @@ class TestEndSessionView(OAuthTestCase):
|
||||
self.brand.flow_invalidation = self.invalidation_flow
|
||||
self.brand.save()
|
||||
|
||||
def _id_token_hint(self, host: str) -> str:
|
||||
"""Issue a valid id_token_hint for the test provider under the given host."""
|
||||
return self.provider.encode(
|
||||
{
|
||||
"iss": f"http://{host}/application/o/{self.app.slug}/",
|
||||
"aud": self.provider.client_id,
|
||||
"sub": str(self.user.pk),
|
||||
}
|
||||
)
|
||||
|
||||
def test_post_logout_redirect_uri_strict_match(self):
|
||||
"""Test strict URI matching redirects to flow"""
|
||||
self.client.force_login(self.user)
|
||||
@@ -61,7 +71,10 @@ class TestEndSessionView(OAuthTestCase):
|
||||
"authentik_providers_oauth2:end-session",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
{"post_logout_redirect_uri": "http://testserver/logout"},
|
||||
{
|
||||
"post_logout_redirect_uri": "http://testserver/logout",
|
||||
"id_token_hint": self._id_token_hint(self.brand.domain),
|
||||
},
|
||||
HTTP_HOST=self.brand.domain,
|
||||
)
|
||||
# Should redirect to the invalidation flow
|
||||
@@ -69,7 +82,12 @@ class TestEndSessionView(OAuthTestCase):
|
||||
self.assertIn(self.invalidation_flow.slug, response.url)
|
||||
|
||||
def test_post_logout_redirect_uri_strict_no_match(self):
|
||||
"""Test strict URI not matching still proceeds with flow (no redirect URI in context)"""
|
||||
"""Test strict URI not matching returns an error and does not start logout flow.
|
||||
|
||||
Required by OIDC RP-Initiated Logout 1.0: on an unregistered
|
||||
post_logout_redirect_uri, the OP MUST NOT redirect and MUST NOT proceed with
|
||||
logout that targets the RP.
|
||||
"""
|
||||
self.client.force_login(self.user)
|
||||
invalid_uri = "http://testserver/other"
|
||||
response = self.client.get(
|
||||
@@ -77,12 +95,14 @@ class TestEndSessionView(OAuthTestCase):
|
||||
"authentik_providers_oauth2:end-session",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
{"post_logout_redirect_uri": invalid_uri},
|
||||
{
|
||||
"post_logout_redirect_uri": invalid_uri,
|
||||
"id_token_hint": self._id_token_hint(self.brand.domain),
|
||||
},
|
||||
HTTP_HOST=self.brand.domain,
|
||||
)
|
||||
# Should still redirect to flow, but invalid URI should not be in response
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertNotIn(invalid_uri, response.url)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertNotIn(invalid_uri, response.content.decode())
|
||||
|
||||
def test_post_logout_redirect_uri_regex_match(self):
|
||||
"""Test regex URI matching redirects to flow"""
|
||||
@@ -92,7 +112,10 @@ class TestEndSessionView(OAuthTestCase):
|
||||
"authentik_providers_oauth2:end-session",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
{"post_logout_redirect_uri": "https://app.example.com/logout"},
|
||||
{
|
||||
"post_logout_redirect_uri": "https://app.example.com/logout",
|
||||
"id_token_hint": self._id_token_hint(self.brand.domain),
|
||||
},
|
||||
HTTP_HOST=self.brand.domain,
|
||||
)
|
||||
# Should redirect to the invalidation flow
|
||||
@@ -100,7 +123,7 @@ class TestEndSessionView(OAuthTestCase):
|
||||
self.assertIn(self.invalidation_flow.slug, response.url)
|
||||
|
||||
def test_post_logout_redirect_uri_regex_no_match(self):
|
||||
"""Test regex URI not matching"""
|
||||
"""Test regex URI not matching returns an error and does not start logout flow."""
|
||||
self.client.force_login(self.user)
|
||||
invalid_uri = "https://malicious.com/logout"
|
||||
response = self.client.get(
|
||||
@@ -108,12 +131,14 @@ class TestEndSessionView(OAuthTestCase):
|
||||
"authentik_providers_oauth2:end-session",
|
||||
kwargs={"application_slug": self.app.slug},
|
||||
),
|
||||
{"post_logout_redirect_uri": invalid_uri},
|
||||
{
|
||||
"post_logout_redirect_uri": invalid_uri,
|
||||
"id_token_hint": self._id_token_hint(self.brand.domain),
|
||||
},
|
||||
HTTP_HOST=self.brand.domain,
|
||||
)
|
||||
# Should still proceed to flow, but invalid URI should not be in response
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertNotIn(invalid_uri, response.url)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertNotIn(invalid_uri, response.content.decode())
|
||||
|
||||
def test_state_parameter_appended_to_uri(self):
|
||||
"""Test state parameter is appended to validated redirect URI"""
|
||||
@@ -123,6 +148,7 @@ class TestEndSessionView(OAuthTestCase):
|
||||
{
|
||||
"post_logout_redirect_uri": "http://testserver/logout",
|
||||
"state": "test-state-123",
|
||||
"id_token_hint": self._id_token_hint("testserver"),
|
||||
},
|
||||
)
|
||||
request.user = self.user
|
||||
@@ -132,6 +158,7 @@ class TestEndSessionView(OAuthTestCase):
|
||||
view.request = request
|
||||
view.kwargs = {"application_slug": self.app.slug}
|
||||
view.resolve_provider_application()
|
||||
view.validate()
|
||||
|
||||
self.assertIn("state=test-state-123", view.post_logout_redirect_uri)
|
||||
|
||||
@@ -146,6 +173,7 @@ class TestEndSessionView(OAuthTestCase):
|
||||
{
|
||||
"post_logout_redirect_uri": "http://testserver/logout",
|
||||
"state": "xyz789",
|
||||
"id_token_hint": self._id_token_hint(self.brand.domain),
|
||||
},
|
||||
HTTP_HOST=self.brand.domain,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,8 @@ from urllib.parse import quote, urlparse
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from jwt import PyJWTError
|
||||
from jwt import decode as jwt_decode
|
||||
|
||||
from authentik.common.oauth.constants import (
|
||||
FORBIDDEN_URI_SCHEMES,
|
||||
@@ -21,11 +23,14 @@ from authentik.flows.planner import (
|
||||
from authentik.flows.stage import SessionEndStage
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.iframe_logout import IframeLogoutStageView
|
||||
from authentik.providers.oauth2.errors import TokenError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
JWTAlgorithms,
|
||||
OAuth2LogoutMethod,
|
||||
OAuth2Provider,
|
||||
RedirectURIMatchingMode,
|
||||
)
|
||||
from authentik.providers.oauth2.tasks import send_backchannel_logout_request
|
||||
@@ -47,21 +52,45 @@ class EndSessionView(PolicyAccessView):
|
||||
if not self.flow:
|
||||
raise Http404
|
||||
|
||||
def validate(self):
|
||||
# Parse end session parameters
|
||||
query_dict = self.request.POST if self.request.method == "POST" else self.request.GET
|
||||
state = query_dict.get("state")
|
||||
request_redirect_uri = query_dict.get("post_logout_redirect_uri")
|
||||
id_token_hint = query_dict.get("id_token_hint")
|
||||
self.post_logout_redirect_uri = None
|
||||
|
||||
# OIDC Certification: Verify id_token_hint. If invalid or missing, throw an error
|
||||
if id_token_hint:
|
||||
# Load a fresh provider instance that's not part of the flow
|
||||
# since it'll have the cryptography Certificate that can't be pickled
|
||||
provider = OAuth2Provider.objects.get(pk=self.provider.pk)
|
||||
key, alg = provider.jwt_key
|
||||
if alg != JWTAlgorithms.HS256:
|
||||
key = provider.signing_key.public_key
|
||||
try:
|
||||
jwt_decode(
|
||||
id_token_hint,
|
||||
key,
|
||||
algorithms=[alg],
|
||||
audience=provider.client_id,
|
||||
issuer=provider.get_issuer(self.request),
|
||||
# ID Tokens are short-lived; a logout request arriving
|
||||
# after expiry is still legitimate and must succeed.
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
except PyJWTError:
|
||||
raise TokenError("invalid_request").with_cause(
|
||||
"id_token_hint_decode_failed"
|
||||
) from None
|
||||
|
||||
# Validate post_logout_redirect_uri against registered URIs
|
||||
if request_redirect_uri:
|
||||
# OIDC Certification: id_token_hint required with post_logout_redirect_uri
|
||||
if not id_token_hint:
|
||||
raise TokenError("invalid_request").with_cause("id_token_hint_missing")
|
||||
if urlparse(request_redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
|
||||
raise RequestValidationError(
|
||||
bad_request_message(
|
||||
self.request,
|
||||
"Forbidden URI scheme in post_logout_redirect_uri",
|
||||
)
|
||||
)
|
||||
raise TokenError("invalid_request").with_cause("post_logout_redirect_uri")
|
||||
for allowed in self.provider.post_logout_redirect_uris:
|
||||
if allowed.matching_mode == RedirectURIMatchingMode.STRICT:
|
||||
if request_redirect_uri == allowed.url:
|
||||
@@ -71,6 +100,10 @@ class EndSessionView(PolicyAccessView):
|
||||
if fullmatch(allowed.url, request_redirect_uri):
|
||||
self.post_logout_redirect_uri = request_redirect_uri
|
||||
break
|
||||
# OIDC Certification: OP MUST NOT perform post-logout redirection
|
||||
# if the supplied URI does not exactly match a registered one
|
||||
if self.post_logout_redirect_uri is None:
|
||||
raise TokenError("invalid_request").with_cause("invalid_post_logout_redirect_uri")
|
||||
|
||||
# Append state to the redirect URI if both are present
|
||||
if self.post_logout_redirect_uri and state:
|
||||
@@ -91,50 +124,43 @@ class EndSessionView(PolicyAccessView):
|
||||
"<html><body>Logout successful</body></html>", content_type="text/html", status=200
|
||||
)
|
||||
|
||||
# Otherwise, continue with normal policy checks
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Dispatch the flow planner for the invalidation flow"""
|
||||
try:
|
||||
self.validate()
|
||||
except TokenError as exc:
|
||||
return bad_request_message(
|
||||
self.request,
|
||||
exc.description,
|
||||
)
|
||||
planner = FlowPlanner(self.flow)
|
||||
planner.allow_empty_flows = True
|
||||
|
||||
# Build flow context with logout parameters
|
||||
context = {
|
||||
PLAN_CONTEXT_APPLICATION: self.application,
|
||||
}
|
||||
|
||||
# Get session info for logout notifications and token invalidation
|
||||
auth_session = AuthenticatedSession.from_request(request, request.user)
|
||||
|
||||
# Add validated redirect URI (with state appended) to context if available
|
||||
if self.post_logout_redirect_uri:
|
||||
context[PLAN_CONTEXT_POST_LOGOUT_REDIRECT_URI] = self.post_logout_redirect_uri
|
||||
# Invalidate tokens for this provider/session (RP-initiated logout:
|
||||
# user stays logged into authentik, only this provider's tokens are revoked)
|
||||
if request.user.is_authenticated and auth_session:
|
||||
AccessToken.objects.filter(
|
||||
user=request.user,
|
||||
provider=self.provider,
|
||||
session=auth_session,
|
||||
).delete()
|
||||
|
||||
session_key = (
|
||||
auth_session.session.session_key if auth_session and auth_session.session else None
|
||||
)
|
||||
|
||||
# Handle frontchannel logout
|
||||
frontchannel_logout_url = None
|
||||
if self.provider.logout_method == OAuth2LogoutMethod.FRONTCHANNEL:
|
||||
frontchannel_logout_url = build_frontchannel_logout_url(
|
||||
self.provider, request, session_key
|
||||
)
|
||||
|
||||
# Handle backchannel logout
|
||||
if (
|
||||
self.provider.logout_method == OAuth2LogoutMethod.BACKCHANNEL
|
||||
and self.provider.logout_uri
|
||||
):
|
||||
# Find access token to get iss and sub for the logout token
|
||||
access_token = AccessToken.objects.filter(
|
||||
user=request.user,
|
||||
provider=self.provider,
|
||||
@@ -163,9 +189,16 @@ class EndSessionView(PolicyAccessView):
|
||||
}
|
||||
]
|
||||
|
||||
access_tokens = AccessToken.objects.filter(
|
||||
user=request.user,
|
||||
provider=self.provider,
|
||||
)
|
||||
if auth_session:
|
||||
access_tokens = access_tokens.filter(session=auth_session)
|
||||
access_tokens.delete()
|
||||
|
||||
plan = planner.plan(request, context)
|
||||
|
||||
# Inject iframe logout stage if frontchannel logout is configured
|
||||
if frontchannel_logout_url:
|
||||
plan.insert_stage(in_memory_stage(IframeLogoutStageView))
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""RAC Signals"""
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.cache import cache
|
||||
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||
@@ -18,7 +17,7 @@ from authentik.providers.rac.models import ConnectionToken, Endpoint
|
||||
@receiver(pre_delete, sender=AuthenticatedSession)
|
||||
def user_session_deleted(sender, instance: AuthenticatedSession, **_):
|
||||
layer = get_channel_layer()
|
||||
async_to_sync(layer.group_send)(
|
||||
layer.group_send_blocking(
|
||||
build_rac_client_group_session(instance.session.session_key),
|
||||
{"type": "event.disconnect", "reason": "session_logout"},
|
||||
)
|
||||
@@ -28,7 +27,7 @@ def user_session_deleted(sender, instance: AuthenticatedSession, **_):
|
||||
def pre_delete_connection_token_disconnect(sender, instance: ConnectionToken, **_):
|
||||
"""Disconnect session when connection token is deleted"""
|
||||
layer = get_channel_layer()
|
||||
async_to_sync(layer.group_send)(
|
||||
layer.group_send_blocking(
|
||||
build_rac_client_group_token(instance.token),
|
||||
{"type": "event.disconnect", "reason": "token_delete"},
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Common SAML Exceptions"""
|
||||
"""authentik SAML IDP Exceptions"""
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
@@ -19,8 +19,8 @@ from authentik.common.saml.constants import (
|
||||
RSA_SHA512,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
)
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Shared SAML LogoutRequest parser"""
|
||||
"""LogoutRequest parser"""
|
||||
|
||||
from base64 import b64decode
|
||||
from dataclasses import dataclass
|
||||
@@ -6,29 +6,41 @@ from dataclasses import dataclass
|
||||
from defusedxml import ElementTree
|
||||
|
||||
from authentik.common.saml.constants import NS_SAML_ASSERTION, NS_SAML_PROTOCOL
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.authn_request_parser import ERROR_CANNOT_DECODE_REQUEST
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LogoutRequest:
|
||||
"""Parsed SAML LogoutRequest"""
|
||||
"""Logout Request"""
|
||||
|
||||
id: str | None = None
|
||||
|
||||
issuer: str | None = None
|
||||
|
||||
name_id: str | None = None
|
||||
|
||||
name_id_format: str | None = None
|
||||
|
||||
session_index: str | None = None
|
||||
|
||||
relay_state: str | None = None
|
||||
|
||||
|
||||
class LogoutRequestParser:
|
||||
"""Parse incoming SAML LogoutRequest messages"""
|
||||
"""LogoutRequest Parser"""
|
||||
|
||||
provider: SAMLProvider
|
||||
|
||||
def __init__(self, provider: SAMLProvider):
|
||||
self.provider = provider
|
||||
|
||||
def _parse_xml(self, decoded_xml: str | bytes, relay_state: str | None = None) -> LogoutRequest:
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
request = LogoutRequest(
|
||||
id=root.attrib.get("ID"),
|
||||
id=root.attrib["ID"],
|
||||
)
|
||||
# Try both namespaces for Issuer
|
||||
issuers = root.findall(f"{{{NS_SAML_PROTOCOL}}}Issuer")
|
||||
@@ -43,6 +55,7 @@ class LogoutRequestParser:
|
||||
name_ids = root.findall(f"{{{NS_SAML_PROTOCOL}}}NameID")
|
||||
if len(name_ids) > 0:
|
||||
request.name_id = name_ids[0].text
|
||||
# Extract NameID Format if present
|
||||
if "Format" in name_ids[0].attrib:
|
||||
request.name_id_format = name_ids[0].attrib["Format"]
|
||||
|
||||
@@ -57,17 +70,22 @@ class LogoutRequestParser:
|
||||
return request
|
||||
|
||||
def parse(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
|
||||
"""Parse a POST-binding LogoutRequest (base64 encoded)."""
|
||||
"""Validate and parse raw request with enveloped signautre."""
|
||||
try:
|
||||
decoded_xml = b64decode(saml_request.encode())
|
||||
except UnicodeDecodeError:
|
||||
raise CannotHandleAssertion("Cannot decode SAML request") from None
|
||||
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
|
||||
def parse_detached(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
|
||||
"""Parse a Redirect-binding LogoutRequest (deflate + base64 encoded)."""
|
||||
def parse_detached(
|
||||
self,
|
||||
saml_request: str,
|
||||
relay_state: str | None = None,
|
||||
) -> LogoutRequest:
|
||||
"""Validate and parse raw request with detached signature"""
|
||||
try:
|
||||
decoded_xml = decode_base64_and_inflate(saml_request)
|
||||
except UnicodeDecodeError:
|
||||
raise CannotHandleAssertion("Cannot decode SAML request") from None
|
||||
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
|
||||
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
@@ -15,8 +15,8 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_PROTOCOL,
|
||||
SIGN_ALGORITHM_TRANSFORM_MAP,
|
||||
)
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequest
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
|
||||
@@ -5,10 +5,10 @@ from django.contrib.auth import get_user_model
|
||||
from dramatiq.actor import actor
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequest
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -8,10 +8,10 @@ from authentik.common.saml.constants import (
|
||||
RSA_SHA256,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
)
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
|
||||
|
||||
class TestLogoutIntegration(TestCase):
|
||||
@@ -46,7 +46,7 @@ class TestLogoutIntegration(TestCase):
|
||||
)
|
||||
|
||||
# Create parser for validation
|
||||
self.parser = LogoutRequestParser()
|
||||
self.parser = LogoutRequestParser(self.provider)
|
||||
|
||||
def test_post_binding_roundtrip(self):
|
||||
"""Test that a POST-encoded request can be parsed correctly"""
|
||||
@@ -100,7 +100,7 @@ class TestLogoutIntegration(TestCase):
|
||||
encoded = processor.encode_post()
|
||||
|
||||
# Create parser with verification enabled
|
||||
parser = LogoutRequestParser()
|
||||
parser = LogoutRequestParser(self.provider)
|
||||
|
||||
# Parse it - this would validate signature if verification is enabled
|
||||
parsed = parser.parse(encoded)
|
||||
|
||||
@@ -4,9 +4,9 @@ from django.test import TestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_TRANSIENT
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
GET_LOGOUT_REQUEST = (
|
||||
@@ -51,7 +51,7 @@ class TestLogoutRequest(TestCase):
|
||||
|
||||
def test_static_get(self):
|
||||
"""Test static LogoutRequest"""
|
||||
request = LogoutRequestParser().parse_detached(GET_LOGOUT_REQUEST)
|
||||
request = LogoutRequestParser(self.provider).parse_detached(GET_LOGOUT_REQUEST)
|
||||
self.assertEqual(request.id, "id-2ea1b01f69363ac95e3da4a15409b9d8ec525944")
|
||||
self.assertEqual(request.issuer, "saml-test-sp")
|
||||
# The GET request has an empty NameID element with transient format
|
||||
@@ -60,7 +60,7 @@ class TestLogoutRequest(TestCase):
|
||||
|
||||
def test_static_post(self):
|
||||
"""Test static LogoutRequest"""
|
||||
request = LogoutRequestParser().parse(POST_LOGOUT_REQUEST)
|
||||
request = LogoutRequestParser(self.provider).parse(POST_LOGOUT_REQUEST)
|
||||
self.assertEqual(request.id, "id-b8f4fd51ed4106f1e782b95d51d9ad3f385e5816")
|
||||
self.assertEqual(request.issuer, "saml-test-sp")
|
||||
# The POST request has an empty NameID element with transient format
|
||||
|
||||
@@ -9,11 +9,11 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_PROTOCOL,
|
||||
NS_SIGNATURE,
|
||||
)
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequest
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_brand, create_test_cert, create_test_flow
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLLogoutMethods, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.providers.saml.views.flows import (
|
||||
|
||||
@@ -7,8 +7,6 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.core.models import Application, AuthenticatedSession
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import Flow, in_memory_stage
|
||||
@@ -18,6 +16,7 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.iframe_logout import IframeLogoutStageView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import (
|
||||
SAMLBindings,
|
||||
SAMLLogoutMethods,
|
||||
@@ -25,6 +24,7 @@ from authentik.providers.saml.models import (
|
||||
SAMLSession,
|
||||
)
|
||||
from authentik.providers.saml.native_logout import NativeLogoutStageView
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.providers.saml.tasks import send_saml_logout_response
|
||||
from authentik.providers.saml.utils.encoding import nice64
|
||||
@@ -251,7 +251,7 @@ class SPInitiatedSLOBindingRedirectView(SPInitiatedSLOView):
|
||||
return bad_request_message(self.request, "The SAML request payload is missing.")
|
||||
|
||||
try:
|
||||
logout_request = LogoutRequestParser().parse_detached(
|
||||
logout_request = LogoutRequestParser(self.provider).parse_detached(
|
||||
self.request.GET[REQUEST_KEY_SAML_REQUEST],
|
||||
relay_state=self.request.GET.get(REQUEST_KEY_RELAY_STATE, None),
|
||||
)
|
||||
@@ -295,7 +295,7 @@ class SPInitiatedSLOBindingPOSTView(SPInitiatedSLOView):
|
||||
return bad_request_message(self.request, "The SAML request payload is missing.")
|
||||
|
||||
try:
|
||||
logout_request = LogoutRequestParser().parse(
|
||||
logout_request = LogoutRequestParser(self.provider).parse(
|
||||
payload[REQUEST_KEY_SAML_REQUEST],
|
||||
relay_state=payload.get(REQUEST_KEY_RELAY_STATE, None),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,6 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
@@ -17,6 +16,7 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO,
|
||||
from authentik.flows.views.executor import SESSION_KEY_POST
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
|
||||
from authentik.providers.saml.views.flows import (
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0056_user_roles"), # must run before group field is removed
|
||||
("authentik_rbac", "0009_remove_initialpermissions_mode"),
|
||||
]
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ SPECTACULAR_SETTINGS = {
|
||||
},
|
||||
"ENUM_NAME_OVERRIDES": {
|
||||
"AppEnum": "authentik.lib.api.Apps",
|
||||
"AuthenticationEnum": "authentik.flows.models.FlowAuthenticationRequirement",
|
||||
"ConsentModeEnum": "authentik.stages.consent.models.ConsentMode",
|
||||
"CountryCodeEnum": "django_countries.countries",
|
||||
"DeviceClassesEnum": "authentik.stages.authenticator_validate.models.DeviceClasses",
|
||||
@@ -186,6 +187,7 @@ SPECTACULAR_SETTINGS = {
|
||||
"PolicyEngineMode": "authentik.policies.models.PolicyEngineMode",
|
||||
"PromptTypeEnum": "authentik.stages.prompt.models.FieldTypes",
|
||||
"ProxyMode": "authentik.providers.proxy.models.ProxyMode",
|
||||
"RedirectURITypeEnum": "authentik.providers.oauth2.models.RedirectURIType",
|
||||
"SAMLBindingsEnum": "authentik.providers.saml.models.SAMLBindings",
|
||||
"SAMLLogoutMethods": "authentik.providers.saml.models.SAMLLogoutMethods",
|
||||
"SAMLNameIDPolicyEnum": "authentik.sources.saml.models.SAMLNameIDPolicy",
|
||||
|
||||
@@ -43,7 +43,6 @@ class SAMLSourceSerializer(SourceSerializer):
|
||||
"force_authn",
|
||||
"name_id_policy",
|
||||
"binding_type",
|
||||
"slo_binding",
|
||||
"verification_kp",
|
||||
"signing_kp",
|
||||
"digest_algorithm",
|
||||
@@ -52,8 +51,6 @@ class SAMLSourceSerializer(SourceSerializer):
|
||||
"encryption_kp",
|
||||
"signed_assertion",
|
||||
"signed_response",
|
||||
"sign_authn_request",
|
||||
"sign_logout_request",
|
||||
]
|
||||
|
||||
|
||||
@@ -81,7 +78,6 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
"force_authn",
|
||||
"name_id_policy",
|
||||
"binding_type",
|
||||
"slo_binding",
|
||||
"verification_kp",
|
||||
"signing_kp",
|
||||
"digest_algorithm",
|
||||
@@ -89,8 +85,6 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
"temporary_user_delete_after",
|
||||
"signed_assertion",
|
||||
"signed_response",
|
||||
"sign_authn_request",
|
||||
"sign_logout_request",
|
||||
]
|
||||
search_fields = ["name", "slug"]
|
||||
ordering = ["name"]
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-09 22:34
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
|
||||
("authentik_sources_saml", "0021_samlsource_signed_assertion_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="sign_authn_request",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.",
|
||||
verbose_name="Sign AuthnRequest",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="sign_logout_request",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.",
|
||||
verbose_name="Sign LogoutRequest",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="slo_binding",
|
||||
field=models.CharField(
|
||||
choices=[("REDIRECT", "Redirect Binding"), ("POST", "POST Binding")],
|
||||
default="REDIRECT",
|
||||
help_text="Binding type for Single Logout requests to the IdP.",
|
||||
max_length=100,
|
||||
verbose_name="SLO Binding",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SAMLSourceSession",
|
||||
fields=[
|
||||
(
|
||||
"saml_session_id",
|
||||
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
|
||||
),
|
||||
(
|
||||
"session_index",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="SAML SessionIndex from the IdP's AuthnStatement",
|
||||
),
|
||||
),
|
||||
("name_id", models.TextField(help_text="SAML NameID value for this session")),
|
||||
(
|
||||
"name_id_format",
|
||||
models.TextField(blank=True, default="", help_text="SAML NameID format"),
|
||||
),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"session",
|
||||
models.ForeignKey(
|
||||
help_text="Link to the user's authenticated session",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_sources_saml.samlsource",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="User",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SAML Source Session",
|
||||
"verbose_name_plural": "SAML Source Sessions",
|
||||
"indexes": [
|
||||
models.Index(fields=["source", "user"], name="authentik_s_source__abd088_idx"),
|
||||
models.Index(fields=["session"], name="authentik_s_session_054d2d_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
"""saml sp models"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
@@ -37,11 +36,9 @@ from authentik.common.saml.constants import (
|
||||
SHA512,
|
||||
)
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
GroupSourceConnection,
|
||||
PropertyMapping,
|
||||
Source,
|
||||
User,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||
@@ -81,13 +78,6 @@ class SAMLNameIDPolicy(models.TextChoices):
|
||||
UNSPECIFIED = SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
|
||||
|
||||
class SAMLSLOBindingTypes(models.TextChoices):
|
||||
"""SAML SLO Binding types"""
|
||||
|
||||
REDIRECT = "REDIRECT", _("Redirect Binding")
|
||||
POST = "POST", _("POST Binding")
|
||||
|
||||
|
||||
class SAMLSource(Source):
|
||||
"""Authenticate using an external SAML Identity Provider."""
|
||||
|
||||
@@ -144,28 +134,6 @@ class SAMLSource(Source):
|
||||
choices=SAMLBindingTypes.choices,
|
||||
default=SAMLBindingTypes.REDIRECT,
|
||||
)
|
||||
slo_binding = models.CharField(
|
||||
max_length=100,
|
||||
choices=SAMLSLOBindingTypes.choices,
|
||||
default=SAMLSLOBindingTypes.REDIRECT,
|
||||
verbose_name=_("SLO Binding"),
|
||||
help_text=_("Binding type for Single Logout requests to the IdP."),
|
||||
)
|
||||
|
||||
sign_authn_request = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Sign AuthnRequest"),
|
||||
help_text=_(
|
||||
"Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set."
|
||||
),
|
||||
)
|
||||
sign_logout_request = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Sign LogoutRequest"),
|
||||
help_text=_(
|
||||
"Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set."
|
||||
),
|
||||
)
|
||||
|
||||
temporary_user_delete_after = models.TextField(
|
||||
default="days=1",
|
||||
@@ -387,39 +355,3 @@ class GroupSAMLSourceConnection(GroupSourceConnection):
|
||||
class Meta:
|
||||
verbose_name = _("Group SAML Source Connection")
|
||||
verbose_name_plural = _("Group SAML Source Connections")
|
||||
|
||||
|
||||
class SAMLSourceSession(models.Model):
|
||||
"""Track active SAML source sessions for Single Logout support"""
|
||||
|
||||
saml_session_id = models.UUIDField(default=uuid4, primary_key=True)
|
||||
source = models.ForeignKey(SAMLSource, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
|
||||
session = models.ForeignKey(
|
||||
AuthenticatedSession,
|
||||
on_delete=models.CASCADE,
|
||||
help_text=_("Link to the user's authenticated session"),
|
||||
)
|
||||
session_index = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
help_text=_("SAML SessionIndex from the IdP's AuthnStatement"),
|
||||
)
|
||||
name_id = models.TextField(help_text=_("SAML NameID value for this session"))
|
||||
name_id_format = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
help_text=_("SAML NameID format"),
|
||||
)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("SAML Source Session")
|
||||
verbose_name_plural = _("SAML Source Sessions")
|
||||
indexes = [
|
||||
models.Index(fields=["source", "user"]),
|
||||
models.Index(fields=["session"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"SAML Source Session for source {self.source_id} and user {self.user_id}"
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
"""SAML Source LogoutRequest Processor"""
|
||||
|
||||
import base64
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import xmlsec
|
||||
from django.http import HttpRequest
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element, _Element
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SIGN_ALGORITHM_TRANSFORM_MAP,
|
||||
)
|
||||
from authentik.lib.xml import remove_xml_newlines
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
|
||||
class LogoutRequestProcessor:
|
||||
"""Generate SAML LogoutRequest messages for SP-initiated logout"""
|
||||
|
||||
source: SAMLSource
|
||||
http_request: HttpRequest
|
||||
destination: str
|
||||
name_id: str
|
||||
name_id_format: str
|
||||
session_index: str
|
||||
relay_state: str | None
|
||||
|
||||
_issue_instant: str
|
||||
_request_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: SAMLSource,
|
||||
http_request: HttpRequest,
|
||||
destination: str,
|
||||
name_id: str,
|
||||
name_id_format: str = SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index: str = "",
|
||||
relay_state: str | None = None,
|
||||
):
|
||||
self.source = source
|
||||
self.http_request = http_request
|
||||
self.destination = destination
|
||||
self.name_id = name_id
|
||||
self.name_id_format = name_id_format
|
||||
self.session_index = session_index
|
||||
self.relay_state = relay_state
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._request_id = get_random_id()
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.source.get_issuer(self.http_request)
|
||||
return issuer
|
||||
|
||||
def get_name_id(self) -> Element:
|
||||
"""Get NameID element"""
|
||||
name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
|
||||
name_id.attrib["Format"] = self.name_id_format
|
||||
name_id.text = self.name_id
|
||||
return name_id
|
||||
|
||||
def build(self) -> Element:
|
||||
"""Build a SAML LogoutRequest as etree Element"""
|
||||
logout_request = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutRequest", nsmap=NS_MAP)
|
||||
logout_request.attrib["ID"] = self._request_id
|
||||
logout_request.attrib["Version"] = "2.0"
|
||||
logout_request.attrib["IssueInstant"] = self._issue_instant
|
||||
logout_request.attrib["Destination"] = self.destination
|
||||
|
||||
logout_request.append(self.get_issuer())
|
||||
logout_request.append(self.get_name_id())
|
||||
|
||||
if self.session_index:
|
||||
session_index_element = Element(f"{{{NS_SAML_PROTOCOL}}}SessionIndex")
|
||||
session_index_element.text = self.session_index
|
||||
logout_request.append(session_index_element)
|
||||
|
||||
return logout_request
|
||||
|
||||
def encode_post(self) -> str:
|
||||
"""Encode LogoutRequest for POST binding"""
|
||||
logout_request = self.build()
|
||||
if self.source.signing_kp and self.source.sign_logout_request:
|
||||
self._sign_logout_request(logout_request)
|
||||
return base64.b64encode(etree.tostring(logout_request)).decode()
|
||||
|
||||
def encode_redirect(self) -> str:
|
||||
"""Encode LogoutRequest for Redirect binding"""
|
||||
logout_request = self.build()
|
||||
xml_str = etree.tostring(logout_request, encoding="UTF-8", xml_declaration=True)
|
||||
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
|
||||
|
||||
def get_redirect_url(self) -> str:
|
||||
"""Build complete logout URL for redirect binding with signature if needed"""
|
||||
encoded_request = self.encode_redirect()
|
||||
params = {
|
||||
"SAMLRequest": encoded_request,
|
||||
}
|
||||
|
||||
if self.relay_state:
|
||||
params["RelayState"] = self.relay_state
|
||||
|
||||
if self.source.signing_kp and self.source.sign_logout_request:
|
||||
sig_alg = self.source.signature_algorithm
|
||||
params["SigAlg"] = sig_alg
|
||||
|
||||
query_string = self._build_signable_query_string(params)
|
||||
signature = self._sign_query_string(query_string)
|
||||
params["Signature"] = base64.b64encode(signature).decode()
|
||||
|
||||
separator = "&" if "?" in self.destination else "?"
|
||||
return f"{self.destination}{separator}{urlencode(params)}"
|
||||
|
||||
def get_post_form_data(self) -> dict:
|
||||
"""Get form data for POST binding"""
|
||||
return {
|
||||
"SAMLRequest": self.encode_post(),
|
||||
"RelayState": self.relay_state or "",
|
||||
}
|
||||
|
||||
def _sign_logout_request(self, logout_request: _Element):
|
||||
"""Sign the LogoutRequest element"""
|
||||
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
signature = xmlsec.template.create(
|
||||
logout_request,
|
||||
xmlsec.constants.TransformExclC14N,
|
||||
signature_algorithm_transform,
|
||||
ns=xmlsec.constants.DSigNs,
|
||||
)
|
||||
|
||||
issuer = logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
if issuer is not None:
|
||||
issuer.addnext(signature)
|
||||
else:
|
||||
logout_request.insert(0, signature)
|
||||
|
||||
self._sign(logout_request)
|
||||
|
||||
def _sign(self, element: _Element):
|
||||
"""Sign an XML element based on the source's configured signing settings"""
|
||||
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
|
||||
self.source.digest_algorithm, xmlsec.constants.TransformSha1
|
||||
)
|
||||
xmlsec.tree.add_ids(element, ["ID"])
|
||||
signature_node = xmlsec.tree.find_node(element, xmlsec.constants.NodeSignature)
|
||||
ref = xmlsec.template.add_reference(
|
||||
signature_node,
|
||||
digest_algorithm_transform,
|
||||
uri="#" + element.attrib["ID"],
|
||||
)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
|
||||
key_info = xmlsec.template.ensure_key_info(signature_node)
|
||||
xmlsec.template.add_x509_data(key_info)
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
|
||||
key = xmlsec.Key.from_memory(
|
||||
self.source.signing_kp.key_data,
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
None,
|
||||
)
|
||||
key.load_cert_from_memory(
|
||||
self.source.signing_kp.certificate_data,
|
||||
xmlsec.constants.KeyDataFormatCertPem,
|
||||
)
|
||||
ctx.key = key
|
||||
ctx.sign(remove_xml_newlines(element, signature_node))
|
||||
|
||||
def _build_signable_query_string(self, params: dict) -> str:
|
||||
"""Build query string for signing (order matters per SAML spec)"""
|
||||
ordered = []
|
||||
if "SAMLRequest" in params:
|
||||
ordered.append(f"SAMLRequest={quote(params['SAMLRequest'], safe='')}")
|
||||
if "RelayState" in params:
|
||||
ordered.append(f"RelayState={quote(params['RelayState'], safe='')}")
|
||||
if "SigAlg" in params:
|
||||
ordered.append(f"SigAlg={quote(params['SigAlg'], safe='')}")
|
||||
return "&".join(ordered)
|
||||
|
||||
def _sign_query_string(self, query_string: str) -> bytes:
|
||||
"""Sign the query string for redirect binding"""
|
||||
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha256
|
||||
)
|
||||
|
||||
key = xmlsec.Key.from_memory(
|
||||
self.source.signing_kp.key_data,
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
None,
|
||||
)
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
ctx.key = key
|
||||
|
||||
return ctx.sign_binary(query_string.encode("utf-8"), signature_algorithm_transform)
|
||||
@@ -1,96 +0,0 @@
|
||||
"""SAML Source LogoutResponse Builder"""
|
||||
|
||||
import base64
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http import HttpRequest
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
SAML_STATUS_SUCCESS,
|
||||
)
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
|
||||
class LogoutResponseBuilder:
|
||||
"""Build SAML LogoutResponse messages for IdP-initiated logout"""
|
||||
|
||||
source: SAMLSource
|
||||
http_request: HttpRequest
|
||||
destination: str
|
||||
in_response_to: str
|
||||
|
||||
_issue_instant: str
|
||||
_response_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: SAMLSource,
|
||||
http_request: HttpRequest,
|
||||
destination: str,
|
||||
in_response_to: str,
|
||||
):
|
||||
self.source = source
|
||||
self.http_request = http_request
|
||||
self.destination = destination
|
||||
self.in_response_to = in_response_to
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._response_id = get_random_id()
|
||||
|
||||
def build(self) -> Element:
|
||||
"""Build a SAML LogoutResponse as etree Element"""
|
||||
response = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutResponse", nsmap=NS_MAP)
|
||||
response.attrib["ID"] = self._response_id
|
||||
response.attrib["Version"] = "2.0"
|
||||
response.attrib["IssueInstant"] = self._issue_instant
|
||||
response.attrib["Destination"] = self.destination
|
||||
response.attrib["InResponseTo"] = self.in_response_to
|
||||
|
||||
# Issuer
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.source.get_issuer(self.http_request)
|
||||
response.append(issuer)
|
||||
|
||||
# Status
|
||||
status = Element(f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
status_code = Element(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
status_code.attrib["Value"] = SAML_STATUS_SUCCESS
|
||||
status.append(status_code)
|
||||
response.append(status)
|
||||
|
||||
return response
|
||||
|
||||
def encode_post(self) -> str:
|
||||
"""Encode LogoutResponse for POST binding"""
|
||||
response = self.build()
|
||||
return base64.b64encode(etree.tostring(response)).decode()
|
||||
|
||||
def encode_redirect(self) -> str:
|
||||
"""Encode LogoutResponse for Redirect binding"""
|
||||
response = self.build()
|
||||
xml_str = etree.tostring(response, encoding="UTF-8", xml_declaration=True)
|
||||
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
|
||||
|
||||
def get_redirect_url(self, relay_state: str | None = None) -> str:
|
||||
"""Build complete URL for redirect binding"""
|
||||
encoded = self.encode_redirect()
|
||||
params = {"SAMLResponse": encoded}
|
||||
if relay_state:
|
||||
params["RelayState"] = relay_state
|
||||
separator = "&" if "?" in self.destination else "?"
|
||||
return f"{self.destination}{separator}{urlencode(params)}"
|
||||
|
||||
def get_post_form_data(self, relay_state: str | None = None) -> dict:
|
||||
"""Get form data for POST binding"""
|
||||
data = {"SAMLResponse": self.encode_post()}
|
||||
if relay_state:
|
||||
data["RelayState"] = relay_state
|
||||
return data
|
||||
@@ -8,7 +8,6 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_METADATA,
|
||||
NS_SIGNATURE,
|
||||
SAML_BINDING_POST,
|
||||
SAML_BINDING_REDIRECT,
|
||||
)
|
||||
from authentik.providers.saml.utils.encoding import strip_pem_header
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
@@ -76,19 +75,6 @@ class MetadataProcessor:
|
||||
if encryption_descriptor is not None:
|
||||
sp_sso_descriptor.append(encryption_descriptor)
|
||||
|
||||
if self.source.slo_url:
|
||||
slo_location = self.source.build_full_url(self.http_request, view="slo")
|
||||
|
||||
slo_redirect = SubElement(
|
||||
sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService"
|
||||
)
|
||||
slo_redirect.attrib["Binding"] = SAML_BINDING_REDIRECT
|
||||
slo_redirect.attrib["Location"] = slo_location
|
||||
|
||||
slo_post = SubElement(sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService")
|
||||
slo_post.attrib["Binding"] = SAML_BINDING_POST
|
||||
slo_post.attrib["Location"] = slo_location
|
||||
|
||||
sp_sso_descriptor.append(self.get_name_id_format())
|
||||
|
||||
assertion_consumer_service = SubElement(
|
||||
|
||||
@@ -72,11 +72,7 @@ class RequestProcessor:
|
||||
# Create issuer object
|
||||
auth_n_request.append(self.get_issuer())
|
||||
|
||||
if (
|
||||
self.source.signing_kp
|
||||
and self.source.sign_authn_request
|
||||
and self.source.binding_type != SAMLBindingTypes.REDIRECT
|
||||
):
|
||||
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
@@ -97,11 +93,7 @@ class RequestProcessor:
|
||||
(used for POST Bindings)"""
|
||||
auth_n_request = self.get_auth_n()
|
||||
|
||||
if (
|
||||
self.source.signing_kp
|
||||
and self.source.sign_authn_request
|
||||
and self.source.binding_type != SAMLBindingTypes.REDIRECT
|
||||
):
|
||||
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
|
||||
xmlsec.tree.add_ids(auth_n_request, ["ID"])
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
@@ -149,7 +141,7 @@ class RequestProcessor:
|
||||
if self.relay_state != "":
|
||||
response_dict["RelayState"] = self.relay_state
|
||||
|
||||
if self.source.signing_kp and self.source.sign_authn_request:
|
||||
if self.source.signing_kp:
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
|
||||
@@ -42,9 +42,12 @@ from authentik.sources.saml.exceptions import (
|
||||
MissingSAMLResponse,
|
||||
UnsupportedNameIDFormat,
|
||||
)
|
||||
from authentik.sources.saml.models import SAMLSource, UserSAMLSourceConnection
|
||||
from authentik.sources.saml.models import (
|
||||
GroupSAMLSourceConnection,
|
||||
SAMLSource,
|
||||
UserSAMLSourceConnection,
|
||||
)
|
||||
from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID
|
||||
from authentik.sources.saml.stages import PLAN_CONTEXT_SAML_SESSION_DATA, SAMLSourceFlowManager
|
||||
|
||||
LOGGER = get_logger()
|
||||
if TYPE_CHECKING:
|
||||
@@ -237,7 +240,6 @@ class ResponseProcessor:
|
||||
UserSAMLSourceConnection.objects.create(
|
||||
source=self._source, user=user, identifier=name_id.text
|
||||
)
|
||||
session_index = self._get_session_index()
|
||||
return SAMLSourceFlowManager(
|
||||
source=self._source,
|
||||
request=self._http_request,
|
||||
@@ -247,25 +249,9 @@ class ResponseProcessor:
|
||||
"assertion": self.get_assertion(),
|
||||
"name_id": name_id,
|
||||
},
|
||||
policy_context={
|
||||
PLAN_CONTEXT_SAML_SESSION_DATA: {
|
||||
"session_index": session_index or "",
|
||||
"name_id": name_id.text,
|
||||
"name_id_format": name_id.attrib.get("Format", ""),
|
||||
},
|
||||
},
|
||||
policy_context={},
|
||||
)
|
||||
|
||||
def _get_session_index(self) -> str | None:
|
||||
"""Get SessionIndex from AuthnStatement element"""
|
||||
assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
||||
if assertion is None:
|
||||
return None
|
||||
authn_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
||||
if authn_statement is None:
|
||||
return None
|
||||
return authn_statement.attrib.get("SessionIndex")
|
||||
|
||||
def get_assertion(self) -> Element | None:
|
||||
"""Get assertion element, if we have a signed assertion"""
|
||||
if self._assertion is not None:
|
||||
@@ -321,7 +307,6 @@ class ResponseProcessor:
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
|
||||
return self._handle_name_id_transient()
|
||||
|
||||
session_index = self._get_session_index()
|
||||
return SAMLSourceFlowManager(
|
||||
source=self._source,
|
||||
request=self._http_request,
|
||||
@@ -333,10 +318,12 @@ class ResponseProcessor:
|
||||
},
|
||||
policy_context={
|
||||
"saml_response": etree.tostring(self._root),
|
||||
PLAN_CONTEXT_SAML_SESSION_DATA: {
|
||||
"session_index": session_index or "",
|
||||
"name_id": name_id.text,
|
||||
"name_id_format": name_id.attrib.get("Format", ""),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SAMLSourceFlowManager(SourceFlowManager):
|
||||
"""Source flow manager for SAML Sources"""
|
||||
|
||||
user_connection_type = UserSAMLSourceConnection
|
||||
group_connection_type = GroupSAMLSourceConnection
|
||||
|
||||
@@ -3,40 +3,12 @@
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, AuthenticatedSession, User
|
||||
from authentik.flows.challenge import PLAN_CONTEXT_ATTRS, PLAN_CONTEXT_TITLE, PLAN_CONTEXT_URL
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.stage import RedirectStage, SessionEndStage
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
from authentik.providers.saml.native_logout import NativeLogoutStageView
|
||||
from authentik.sources.saml.models import SAMLSLOBindingTypes, SAMLSourceSession
|
||||
from authentik.sources.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.sources.saml.views import PLAN_CONTEXT_SAML_RELAY_STATE, AutosubmitStageView
|
||||
from authentik.stages.user_logout.models import UserLogoutStage
|
||||
from authentik.stages.user_logout.stage import flow_pre_user_logout
|
||||
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, User
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
# Stages that redirect the user away from authentik. Source SLO stages must be
|
||||
# inserted before these so they have a chance to execute.
|
||||
TERMINAL_STAGE_VIEWS = {SessionEndStage, NativeLogoutStageView}
|
||||
|
||||
|
||||
def _insert_before_terminal_stage(plan, stage):
|
||||
"""Insert a stage before any terminal stage (SessionEndStage, NativeLogoutStageView)
|
||||
in the plan. Falls back to append if no terminal stage is found."""
|
||||
for i, binding in enumerate(plan.bindings):
|
||||
try:
|
||||
if binding.stage.view in TERMINAL_STAGE_VIEWS:
|
||||
plan.insert_stage(stage, index=i)
|
||||
return
|
||||
except NotImplementedError:
|
||||
continue
|
||||
plan.append_stage(stage)
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||
@@ -46,89 +18,3 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||
if user.attributes.get(USER_ATTRIBUTE_DELETE_ON_LOGOUT, False):
|
||||
LOGGER.debug("Deleted temporary user", user=user)
|
||||
user.delete()
|
||||
|
||||
|
||||
@receiver(flow_pre_user_logout)
|
||||
def handle_saml_source_pre_user_logout(
|
||||
sender, request: HttpRequest, user: User, executor: FlowExecutorView, **kwargs
|
||||
):
|
||||
"""Handle SAML source SP-initiated SLO when user logs out via flow.
|
||||
Injects a stage into the logout flow to redirect the user to the IdP's SLO URL."""
|
||||
if not isinstance(executor.current_stage, UserLogoutStage):
|
||||
return
|
||||
|
||||
if not user.is_authenticated:
|
||||
return
|
||||
|
||||
auth_session = AuthenticatedSession.from_request(request, user)
|
||||
if not auth_session:
|
||||
return
|
||||
|
||||
# Find SAMLSourceSessions for this user's current session
|
||||
saml_source_sessions = SAMLSourceSession.objects.filter(
|
||||
session=auth_session,
|
||||
user=user,
|
||||
).select_related("source")
|
||||
|
||||
for saml_session in saml_source_sessions:
|
||||
source = saml_session.source
|
||||
if not source.slo_url or not source.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Use the flow executor URL as relay_state so that after the IdP
|
||||
# processes the LogoutRequest and sends a LogoutResponse, the user
|
||||
# is redirected back to the flow to continue remaining stages.
|
||||
relay_state = request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={"flow_slug": executor.flow.slug},
|
||||
)
|
||||
)
|
||||
|
||||
# Stash the outbound relay_state so the SLOView can redirect to a
|
||||
# server-known value rather than trusting the echoed request param.
|
||||
executor.plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
|
||||
|
||||
processor = LogoutRequestProcessor(
|
||||
source=source,
|
||||
http_request=request,
|
||||
destination=source.slo_url,
|
||||
name_id=saml_session.name_id,
|
||||
name_id_format=saml_session.name_id_format,
|
||||
session_index=saml_session.session_index,
|
||||
relay_state=relay_state,
|
||||
)
|
||||
|
||||
# Insert before terminal stages (SessionEndStage, NativeLogoutStageView)
|
||||
# so the SLO redirect runs before the flow ends or the user is
|
||||
# redirected away. Provider logout stages (at index 1/2) still run
|
||||
# first since they're inserted earlier.
|
||||
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
|
||||
redirect_url = processor.get_redirect_url()
|
||||
stage = in_memory_stage(RedirectStage, destination=redirect_url)
|
||||
else:
|
||||
# POST binding
|
||||
form_data = processor.get_post_form_data()
|
||||
executor.plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
|
||||
executor.plan.context[PLAN_CONTEXT_URL] = source.slo_url
|
||||
executor.plan.context[PLAN_CONTEXT_ATTRS] = form_data
|
||||
stage = in_memory_stage(AutosubmitStageView)
|
||||
|
||||
_insert_before_terminal_stage(executor.plan, stage)
|
||||
|
||||
LOGGER.debug(
|
||||
"Injected SAML source SLO into logout flow",
|
||||
source=source.name,
|
||||
binding=source.slo_binding,
|
||||
)
|
||||
|
||||
except (KeyError, AttributeError) as exc:
|
||||
LOGGER.warning(
|
||||
"Failed to generate SAML source logout request",
|
||||
source=source.name,
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
# Clean up SAMLSourceSessions for this auth session
|
||||
saml_source_sessions.delete()
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"""SAML Source stages and flow manager"""
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.core.sources.flow_manager import SourceFlowManager
|
||||
from authentik.core.sources.stage import PostSourceStage
|
||||
from authentik.flows.models import Flow, Stage, in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE
|
||||
from authentik.sources.saml.models import (
|
||||
GroupSAMLSourceConnection,
|
||||
SAMLSource,
|
||||
SAMLSourceSession,
|
||||
UserSAMLSourceConnection,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_SAML_SESSION_DATA = "saml_session_data"
|
||||
|
||||
|
||||
class SAMLPostSourceStage(PostSourceStage):
|
||||
"""Extends PostSourceStage to also create SAMLSourceSession for SLO support."""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
response = super().dispatch(request)
|
||||
|
||||
session_data = self.executor.plan.context.get(PLAN_CONTEXT_SAML_SESSION_DATA)
|
||||
if not session_data:
|
||||
return response
|
||||
|
||||
source = self.executor.plan.context.get(PLAN_CONTEXT_SOURCE)
|
||||
if not isinstance(source, SAMLSource):
|
||||
return response
|
||||
|
||||
user: User = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
if not user or not user.pk:
|
||||
return response
|
||||
|
||||
auth_session = AuthenticatedSession.from_request(request, user)
|
||||
if not auth_session:
|
||||
return response
|
||||
|
||||
SAMLSourceSession.objects.create(
|
||||
source=source,
|
||||
user=user,
|
||||
session=auth_session,
|
||||
session_index=session_data.get("session_index", ""),
|
||||
name_id=session_data.get("name_id", ""),
|
||||
name_id_format=session_data.get("name_id_format", ""),
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Created SAMLSourceSession",
|
||||
source=source.name,
|
||||
user=user,
|
||||
session_index=session_data.get("session_index", ""),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class SAMLSourceFlowManager(SourceFlowManager):
|
||||
"""Source flow manager for SAML Sources"""
|
||||
|
||||
user_connection_type = UserSAMLSourceConnection
|
||||
group_connection_type = GroupSAMLSourceConnection
|
||||
|
||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||
return [
|
||||
in_memory_stage(SAMLPostSourceStage),
|
||||
]
|
||||
@@ -3,6 +3,7 @@
|
||||
from urllib.parse import parse_qsl, urlparse, urlunparse
|
||||
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
@@ -12,13 +13,9 @@ from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from lxml import etree # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
from xmlsec import InternalError, VerificationError
|
||||
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.common.saml.parsers.logout_response import LogoutResponseParser
|
||||
from authentik.flows.challenge import (
|
||||
PLAN_CONTEXT_ATTRS,
|
||||
PLAN_CONTEXT_TITLE,
|
||||
@@ -36,7 +33,7 @@ from authentik.flows.planner import (
|
||||
FlowPlan,
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.stage import ChallengeStageView, RedirectStage, SessionEndStage
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.saml.utils.encoding import nice64
|
||||
@@ -47,13 +44,7 @@ from authentik.sources.saml.exceptions import (
|
||||
MissingSAMLResponse,
|
||||
UnsupportedNameIDFormat,
|
||||
)
|
||||
from authentik.sources.saml.models import (
|
||||
SAMLBindingTypes,
|
||||
SAMLSLOBindingTypes,
|
||||
SAMLSource,
|
||||
SAMLSourceSession,
|
||||
)
|
||||
from authentik.sources.saml.processors.logout_response import LogoutResponseBuilder
|
||||
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
|
||||
from authentik.sources.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.sources.saml.processors.request import RequestProcessor
|
||||
from authentik.sources.saml.processors.response import ResponseProcessor
|
||||
@@ -61,8 +52,6 @@ from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_HEADER, ConsentS
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_SAML_RELAY_STATE = "goauthentik.io/sources/saml/relay_state"
|
||||
|
||||
|
||||
class AutosubmitStageView(ChallengeStageView):
|
||||
"""Wrapper stage to create an autosubmit challenge from plan context variables"""
|
||||
@@ -192,195 +181,16 @@ class ACSView(View):
|
||||
return bad_request_message(request, str(exc))
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class SLOView(View):
|
||||
"""Single-Logout-View: handles SP-initiated SLO, IdP-initiated LogoutRequest,
|
||||
and LogoutResponse from IdP"""
|
||||
class SLOView(LoginRequiredMixin, View):
|
||||
"""Single-Logout-View"""
|
||||
|
||||
def get(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||
"""Handle GET requests: LogoutResponse, LogoutRequest, or initiate SLO."""
|
||||
def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||
"""Log user out and redirect them to the IdP's SLO URL."""
|
||||
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
|
||||
if not source.enabled:
|
||||
raise Http404
|
||||
|
||||
if "SAMLResponse" in request.GET:
|
||||
return self._handle_logout_response(
|
||||
request,
|
||||
request.GET["SAMLResponse"],
|
||||
relay_state=request.GET.get("RelayState"),
|
||||
)
|
||||
|
||||
if "SAMLRequest" in request.GET:
|
||||
return self._handle_logout_request(
|
||||
request, source, request.GET["SAMLRequest"], is_post=False
|
||||
)
|
||||
|
||||
# No SAML message, initiate SP-initiated SLO
|
||||
return self._initiate_logout(request)
|
||||
|
||||
def post(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||
"""Handle POST requests: LogoutResponse or LogoutRequest from the IdP."""
|
||||
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
|
||||
if not source.enabled:
|
||||
raise Http404
|
||||
|
||||
if "SAMLResponse" in request.POST:
|
||||
return self._handle_logout_response(
|
||||
request,
|
||||
request.POST["SAMLResponse"],
|
||||
relay_state=request.POST.get("RelayState"),
|
||||
)
|
||||
|
||||
if "SAMLRequest" in request.POST:
|
||||
return self._handle_logout_request(
|
||||
request, source, request.POST["SAMLRequest"], is_post=True
|
||||
)
|
||||
|
||||
return bad_request_message(request, "Missing SAMLRequest or SAMLResponse")
|
||||
|
||||
def _initiate_logout(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Initiate logout using the brand's invalidation flow.
|
||||
|
||||
The invalidation flow contains a UserLogoutStage which fires the
|
||||
flow_pre_user_logout signal. Our signal handler in signals.py picks that up,
|
||||
finds the SAMLSourceSession, and injects the SLO redirect/POST stage."""
|
||||
# Sources do not have an invalidation flow, use the brand's
|
||||
flow = request.brand.flow_invalidation
|
||||
if not flow:
|
||||
logout(request)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
plan = planner.plan(request)
|
||||
except FlowNonApplicableException:
|
||||
logout(request)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
return plan.to_redirect(request, flow)
|
||||
|
||||
def _handle_logout_request(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
source: SAMLSource,
|
||||
raw_request: str,
|
||||
is_post: bool = False,
|
||||
) -> HttpResponse:
|
||||
"""Handle an incoming LogoutRequest from the IdP (IdP-initiated SLO).
|
||||
|
||||
Parses the request, deletes the SAMLSourceSession (to prevent circular
|
||||
redirect back to the IdP), runs the invalidation flow, and appends a
|
||||
final stage to send the LogoutResponse back to the IdP."""
|
||||
parser = LogoutRequestParser()
|
||||
try:
|
||||
if is_post:
|
||||
logout_request = parser.parse(raw_request)
|
||||
else:
|
||||
logout_request = parser.parse_detached(raw_request)
|
||||
except (CannotHandleAssertion, ValueError) as exc:
|
||||
LOGGER.warning("Failed to parse LogoutRequest from IdP", exc=exc)
|
||||
return bad_request_message(request, str(exc))
|
||||
|
||||
relay_state = (
|
||||
request.GET.get("RelayState") if not is_post else request.POST.get("RelayState")
|
||||
)
|
||||
|
||||
# Delete SAMLSourceSession so the source signal handler doesn't try to
|
||||
# redirect back to the IdP (which would be circular)
|
||||
SAMLSourceSession.objects.filter(
|
||||
source=source,
|
||||
user=request.user,
|
||||
).delete()
|
||||
|
||||
# Build the LogoutResponse to send back to the IdP after logout
|
||||
response_builder = LogoutResponseBuilder(
|
||||
source=source,
|
||||
http_request=request,
|
||||
destination=source.slo_url,
|
||||
in_response_to=logout_request.id,
|
||||
)
|
||||
|
||||
# Sources do not have an invalidation flow, use the brand's
|
||||
flow = request.brand.flow_invalidation
|
||||
if not flow:
|
||||
logout(request)
|
||||
return self._send_logout_response(response_builder, relay_state)
|
||||
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
plan = planner.plan(request)
|
||||
except FlowNonApplicableException:
|
||||
logout(request)
|
||||
return self._send_logout_response(response_builder, relay_state)
|
||||
|
||||
# Append logout response stage, then session end
|
||||
self._append_response_stage(plan, source, response_builder, relay_state)
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
return plan.to_redirect(request, flow)
|
||||
|
||||
def _send_logout_response(
|
||||
self,
|
||||
response_builder: LogoutResponseBuilder,
|
||||
relay_state: str | None = None,
|
||||
) -> HttpResponse:
|
||||
"""Send LogoutResponse back to the IdP directly (no flow).
|
||||
Without a flow we can't render an autosubmit form, so always redirect."""
|
||||
return redirect(response_builder.get_redirect_url(relay_state))
|
||||
|
||||
def _append_response_stage(
|
||||
self,
|
||||
plan: FlowPlan,
|
||||
source: SAMLSource,
|
||||
response_builder: LogoutResponseBuilder,
|
||||
relay_state: str | None = None,
|
||||
):
|
||||
"""Append a stage to send the LogoutResponse back to the IdP."""
|
||||
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
|
||||
redirect_url = response_builder.get_redirect_url(relay_state)
|
||||
plan.append_stage(in_memory_stage(RedirectStage, destination=redirect_url))
|
||||
else:
|
||||
# POST binding — use autosubmit form
|
||||
form_data = response_builder.get_post_form_data(relay_state)
|
||||
plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
|
||||
plan.context[PLAN_CONTEXT_URL] = source.slo_url
|
||||
plan.context[PLAN_CONTEXT_ATTRS] = form_data
|
||||
plan.append_stage(in_memory_stage(AutosubmitStageView))
|
||||
|
||||
def _handle_logout_response(
|
||||
self, request: HttpRequest, raw_response: str, relay_state: str | None = None
|
||||
) -> HttpResponse:
|
||||
"""Parse and handle a LogoutResponse from the IdP."""
|
||||
processor = LogoutResponseParser(raw_response)
|
||||
try:
|
||||
processor.parse()
|
||||
except (ValueError, etree.XMLSyntaxError) as exc:
|
||||
LOGGER.warning("Failed to parse LogoutResponse", exc=exc)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
processor.verify_status()
|
||||
|
||||
# If a RelayState was provided (e.g. the flow executor URL), advance
|
||||
# past the current stage (RedirectStage) in the plan so the flow
|
||||
# continues to the next stage instead of looping. Only redirect to the
|
||||
# value stashed in the plan context on outbound — never to the value
|
||||
# echoed back in the request, which is attacker-controllable.
|
||||
if relay_state and SESSION_KEY_PLAN in request.session:
|
||||
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
stored_relay_state = plan.context.get(PLAN_CONTEXT_SAML_RELAY_STATE, "")
|
||||
if relay_state != stored_relay_state:
|
||||
LOGGER.warning(
|
||||
"SAML logout relay_state mismatch, possible open redirect attempt",
|
||||
received_relay_state=relay_state,
|
||||
stored_relay_state=stored_relay_state,
|
||||
)
|
||||
if plan.bindings:
|
||||
plan.pop()
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
if stored_relay_state:
|
||||
return redirect(stored_relay_state)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
logout(request)
|
||||
return redirect(source.slo_url)
|
||||
|
||||
|
||||
class MetadataView(View):
|
||||
|
||||
File diff suppressed because one or more lines are too long
64
authentik/stages/prompt/migrations/0012_alter_prompt_type.py
Normal file
64
authentik/stages/prompt/migrations/0012_alter_prompt_type.py
Normal file
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-14 02:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_stages_prompt",
|
||||
"0011_prompt_initial_value_prompt_initial_value_expression_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="prompt",
|
||||
name="type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("text", "Text: Simple Text input"),
|
||||
("text_area", "Text area: Multiline Text Input."),
|
||||
(
|
||||
"text_read_only",
|
||||
"Text (read-only): Simple Text input, but cannot be edited.",
|
||||
),
|
||||
(
|
||||
"text_area_read_only",
|
||||
"Text area (read-only): Multiline Text input, but cannot be edited.",
|
||||
),
|
||||
(
|
||||
"username",
|
||||
"Username: Same as Text input, but checks for and prevents duplicate usernames.",
|
||||
),
|
||||
("email", "Email: Text field with Email type."),
|
||||
(
|
||||
"password",
|
||||
"Password: Masked input, multiple inputs of this type on the same prompt need to be identical.",
|
||||
),
|
||||
("number", "Number"),
|
||||
("checkbox", "Checkbox"),
|
||||
(
|
||||
"radio-button-group",
|
||||
"Fixed choice field rendered as a group of radio buttons.",
|
||||
),
|
||||
("dropdown", "Fixed choice field rendered as a dropdown."),
|
||||
("date", "Date"),
|
||||
("date-time", "Date Time"),
|
||||
(
|
||||
"file",
|
||||
"File: File upload for arbitrary files. File content will be available in flow context as data-URI",
|
||||
),
|
||||
("separator", "Separator: Static Separator Line"),
|
||||
("hidden", "Hidden: Hidden field, can be used to insert data into form."),
|
||||
("static", "Static: Static value, displayed as-is."),
|
||||
("alert_info", "Alert (Info): Static alert box with info styling"),
|
||||
("alert_warning", "Alert (Warning): Static alert box with warning styling"),
|
||||
("alert_danger", "Alert (Danger): Static alert box with danger styling"),
|
||||
("ak-locale", "authentik: Selection of locales authentik supports"),
|
||||
],
|
||||
max_length=100,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -87,6 +87,11 @@ class FieldTypes(models.TextChoices):
|
||||
HIDDEN = "hidden", _("Hidden: Hidden field, can be used to insert data into form.")
|
||||
STATIC = "static", _("Static: Static value, displayed as-is.")
|
||||
|
||||
# Alert box types for displaying styled messages
|
||||
ALERT_INFO = "alert_info", _("Alert (Info): Static alert box with info styling")
|
||||
ALERT_WARNING = "alert_warning", _("Alert (Warning): Static alert box with warning styling")
|
||||
ALERT_DANGER = "alert_danger", _("Alert (Danger): Static alert box with danger styling")
|
||||
|
||||
AK_LOCALE = "ak-locale", _("authentik: Selection of locales authentik supports")
|
||||
|
||||
|
||||
@@ -299,7 +304,12 @@ class Prompt(SerializerModel):
|
||||
field_class = HiddenField
|
||||
kwargs["required"] = False
|
||||
kwargs["default"] = self.placeholder
|
||||
case FieldTypes.STATIC:
|
||||
case (
|
||||
FieldTypes.STATIC
|
||||
| FieldTypes.ALERT_INFO
|
||||
| FieldTypes.ALERT_WARNING
|
||||
| FieldTypes.ALERT_DANGER
|
||||
):
|
||||
kwargs["default"] = self.placeholder
|
||||
kwargs["required"] = False
|
||||
kwargs["label"] = ""
|
||||
|
||||
@@ -124,6 +124,9 @@ class PromptChallengeResponse(ChallengeResponse):
|
||||
type__in=[
|
||||
FieldTypes.HIDDEN,
|
||||
FieldTypes.STATIC,
|
||||
FieldTypes.ALERT_INFO,
|
||||
FieldTypes.ALERT_WARNING,
|
||||
FieldTypes.ALERT_DANGER,
|
||||
FieldTypes.TEXT_READ_ONLY,
|
||||
FieldTypes.TEXT_AREA_READ_ONLY,
|
||||
]
|
||||
|
||||
@@ -330,10 +330,20 @@ class TestPromptStage(FlowTestCase):
|
||||
|
||||
def test_static_hidden_overwrite(self):
|
||||
"""Test that static and hidden fields ignore any value sent to them"""
|
||||
alert_prompt = Prompt.objects.create(
|
||||
name=generate_id(),
|
||||
field_key="alert_prompt",
|
||||
type=FieldTypes.ALERT_INFO,
|
||||
required=True,
|
||||
placeholder="alert fallback",
|
||||
initial_value="alert content",
|
||||
)
|
||||
self.stage.fields.add(alert_prompt)
|
||||
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||
plan.context[PLAN_CONTEXT_PROMPT] = {"hidden_prompt": "hidden"}
|
||||
self.prompt_data["hidden_prompt"] = "foo"
|
||||
self.prompt_data["static_prompt"] = "foo"
|
||||
self.prompt_data["alert_prompt"] = "foo"
|
||||
challenge_response = PromptChallengeResponse(
|
||||
None, stage_instance=self.stage, plan=plan, data=self.prompt_data, stage=self.stage_view
|
||||
)
|
||||
@@ -341,6 +351,7 @@ class TestPromptStage(FlowTestCase):
|
||||
self.assertNotEqual(challenge_response.validated_data["hidden_prompt"], "foo")
|
||||
self.assertEqual(challenge_response.validated_data["hidden_prompt"], "hidden")
|
||||
self.assertNotEqual(challenge_response.validated_data["static_prompt"], "foo")
|
||||
self.assertEqual(challenge_response.validated_data["alert_prompt"], "alert content")
|
||||
|
||||
def test_prompt_placeholder(self):
|
||||
"""Test placeholder and expression"""
|
||||
|
||||
@@ -16,7 +16,7 @@ class RedirectMode(models.TextChoices):
|
||||
|
||||
|
||||
class RedirectStage(Stage):
|
||||
"""Redirect the user to another flow, potentially with all gathered context."""
|
||||
"""Redirect the user to a static URL or another flow, optionally with all gathered context."""
|
||||
|
||||
keep_context = models.BooleanField(default=True)
|
||||
mode = models.TextField(choices=RedirectMode.choices)
|
||||
|
||||
@@ -59,6 +59,8 @@ class FlagsJSONExtension(OpenApiSerializerFieldExtension):
|
||||
props[_flag.key] = build_basic_type(get_args(_flag.__orig_bases__[0])[0])
|
||||
if _flag.description:
|
||||
props[_flag.key]["description"] = _flag.description
|
||||
if _flag.deprecated:
|
||||
props[_flag.key]["deprecated"] = _flag.deprecated
|
||||
return build_object_type(props, required=props.keys())
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user