mirror of
https://github.com/goauthentik/authentik
synced 2026-05-15 11:26:31 +02:00
Compare commits
133 Commits
issuer-gen
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d3a289d12 | ||
|
|
99e92bf998 | ||
|
|
1df7b22d29 | ||
|
|
af484738f6 | ||
|
|
19ba3c8453 | ||
|
|
9d1b384baa | ||
|
|
8a702b148f | ||
|
|
2db42a3a04 | ||
|
|
4403baaa28 | ||
|
|
8030d4d734 | ||
|
|
4b7a78453c | ||
|
|
b9746106a0 | ||
|
|
faa47670ef | ||
|
|
add5f4e0cb | ||
|
|
7636c038d9 | ||
|
|
557f781f5c | ||
|
|
1997011328 | ||
|
|
dfdd5ebc8c | ||
|
|
42977caa6a | ||
|
|
bce46c880d | ||
|
|
9c987c5694 | ||
|
|
f33e50c28c | ||
|
|
280d6febf7 | ||
|
|
3a32918ceb | ||
|
|
723bd4bbbc | ||
|
|
7b130e1832 | ||
|
|
3aa969d64e | ||
|
|
04688c86b3 | ||
|
|
4f8dbb5efb | ||
|
|
3b382199eb | ||
|
|
db50991e9c | ||
|
|
88036ebe14 | ||
|
|
680feaefa1 | ||
|
|
8676cd3a43 | ||
|
|
53e0f6b734 | ||
|
|
eaee475662 | ||
|
|
19b672b3bc | ||
|
|
bf0a31ce86 | ||
|
|
28ff561400 | ||
|
|
ff2472a551 | ||
|
|
dac302e8be | ||
|
|
930a6f7c6f | ||
|
|
b661b0bb39 | ||
|
|
5203713ca0 | ||
|
|
94794b106e | ||
|
|
eb8c21cf04 | ||
|
|
8a501377f2 | ||
|
|
5c67a0fecd | ||
|
|
145e6a3a4f | ||
|
|
0219ed73f5 | ||
|
|
88ccb7857b | ||
|
|
e33d6becba | ||
|
|
41417affc0 | ||
|
|
1b4a6c3f6d | ||
|
|
aa98edd661 | ||
|
|
cb70331c82 | ||
|
|
31e105b190 | ||
|
|
08d2615f71 | ||
|
|
bb9e8b1c42 | ||
|
|
6b8d7376a6 | ||
|
|
4f680d8c06 | ||
|
|
3eeb741975 | ||
|
|
39cb638132 | ||
|
|
be8f5d21cb | ||
|
|
acf18836e8 | ||
|
|
d688621de4 | ||
|
|
5173d09191 | ||
|
|
45bab4d32f | ||
|
|
f383e54c72 | ||
|
|
ab6b9b27cc | ||
|
|
db3fb0bf2e | ||
|
|
5859e6a5e5 | ||
|
|
1d3271fec7 | ||
|
|
673c8ef62c | ||
|
|
4614ae320f | ||
|
|
10b103c0bf | ||
|
|
361e64a8a1 | ||
|
|
e56081b863 | ||
|
|
01a44b281b | ||
|
|
7a6631c6e8 | ||
|
|
ada973dd44 | ||
|
|
3a7e962bde | ||
|
|
ae297e2f60 | ||
|
|
2bc2b6bd41 | ||
|
|
8199371172 | ||
|
|
dac1879de5 | ||
|
|
dd7c6b29d9 | ||
|
|
41b7e05f59 | ||
|
|
c5f5714e02 | ||
|
|
d20d8322af | ||
|
|
288f5d5015 | ||
|
|
a640eb9180 | ||
|
|
7d48baab3e | ||
|
|
b1cd6d34fc | ||
|
|
f14d033cef | ||
|
|
ec75e161e2 | ||
|
|
2e8fb8f2c6 | ||
|
|
362bf22139 | ||
|
|
890da9b287 | ||
|
|
7b8dadf945 | ||
|
|
8f70dbb963 | ||
|
|
15505f5caf | ||
|
|
b2d770c0a4 | ||
|
|
f7e25fff1a | ||
|
|
688b3a9f8b | ||
|
|
8f48e18854 | ||
|
|
306f75be59 | ||
|
|
982c3cf4dc | ||
|
|
156cda6cb6 | ||
|
|
9f5125cf6b | ||
|
|
a84411363e | ||
|
|
9f3bb0210b | ||
|
|
d1271502ef | ||
|
|
d1065b2d49 | ||
|
|
aa19227e30 | ||
|
|
d7a2861bbe | ||
|
|
5aae9c9afa | ||
|
|
93cb48c928 | ||
|
|
55f7f93a24 | ||
|
|
5a608a4235 | ||
|
|
77d023758f | ||
|
|
f9edafd374 | ||
|
|
d94219eb0e | ||
|
|
0871aa2cf3 | ||
|
|
e0fe99d0b8 | ||
|
|
c1acf53585 | ||
|
|
baf4eed0d9 | ||
|
|
60e1192a7a | ||
|
|
2608e02d6e | ||
|
|
0a5928fbcb | ||
|
|
7b99b02b4a | ||
|
|
dfeadc9ebe | ||
|
|
7ff64fbd09 |
@@ -1,10 +1,11 @@
|
||||
"""Helper script to get the actual branch name, docker safe"""
|
||||
|
||||
import os
|
||||
from importlib.metadata import version as package_version
|
||||
from json import dumps
|
||||
from time import time
|
||||
|
||||
from authentik import authentik_version
|
||||
|
||||
# Decide if we should push the image or not
|
||||
should_push = True
|
||||
if len(os.environ.get("DOCKER_USERNAME", "")) < 1:
|
||||
@@ -28,7 +29,7 @@ is_release = "dev" not in image_names[0]
|
||||
sha = os.environ["GITHUB_SHA"] if not is_pull_request else os.getenv("PR_HEAD_SHA")
|
||||
|
||||
# 2042.1.0 or 2042.1.0-rc1
|
||||
version = package_version("authentik")
|
||||
version = authentik_version()
|
||||
# 2042.1
|
||||
version_family = ".".join(version.split("-", 1)[0].split(".")[:-1])
|
||||
prerelease = "-" in version
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ${{ inputs.image_name }}
|
||||
image-arch: ${{ inputs.image_arch }}
|
||||
@@ -58,8 +58,8 @@ jobs:
|
||||
if: ${{ inputs.registry_dockerhub }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ inputs.registry_ghcr }}
|
||||
uses: docker/login-action@v3
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ${{ inputs.image_name }}
|
||||
merge-server:
|
||||
@@ -74,15 +74,15 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ${{ inputs.image_name }}
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ inputs.registry_dockerhub }}
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
username: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_CORP_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
if: ${{ inputs.registry_ghcr }}
|
||||
uses: docker/login-action@v3
|
||||
|
||||
2
.github/workflows/ci-docs.yml
vendored
2
.github/workflows/ci-docs.yml
vendored
@@ -81,7 +81,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-docs
|
||||
- name: Login to Container Registry
|
||||
|
||||
2
.github/workflows/ci-main.yml
vendored
2
.github/workflows/ci-main.yml
vendored
@@ -278,7 +278,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-server
|
||||
- name: Comment on PR
|
||||
|
||||
2
.github/workflows/ci-outpost.yml
vendored
2
.github/workflows/ci-outpost.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_CORP_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
|
||||
- name: Login to Container Registry
|
||||
|
||||
168
.github/workflows/release-bump-version.yml
vendored
168
.github/workflows/release-bump-version.yml
vendored
@@ -1,168 +0,0 @@
|
||||
---
|
||||
name: Release - Bump version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version
|
||||
required: true
|
||||
type: string
|
||||
release_reason:
|
||||
description: Release reason
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- bugfix
|
||||
- feature
|
||||
- security
|
||||
- other
|
||||
- prerelease
|
||||
|
||||
env:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||
|
||||
jobs:
|
||||
check-inputs:
|
||||
name: Check inputs validity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: check
|
||||
run: |
|
||||
echo "${{ inputs.version }}" | grep -E "^[0-9]{4}\.[0-9]{1,2}\.[0-9]+(-rc[0-9]+)?$"
|
||||
echo "major_version=${{ inputs.version }}" | grep -oE "^major_version=[0-9]{4}\.[0-9]{1,2}" >> "$GITHUB_OUTPUT"
|
||||
outputs:
|
||||
major_version: "${{ steps.check.outputs.major_version }}"
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
needs:
|
||||
- check-inputs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run migrations
|
||||
run: make migrate
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.version }}"
|
||||
- name: Commit and push
|
||||
run: |
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
||||
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
bump-helm:
|
||||
name: Bump Helm version
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
needs:
|
||||
- bump-authentik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: helm
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/helm"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
run: |
|
||||
sed -i 's/^version: .*/version: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/^appVersion: .*/appVersion: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/upgrade to authentik .*/upgrade to authentik ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
title: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
body: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] ${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com"
|
||||
bump-version:
|
||||
name: Bump version repository
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
needs:
|
||||
- check-inputs
|
||||
- bump-authentik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: version
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/version"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump feature version
|
||||
if: "${{ inputs.release_reason == 'feature' }}"
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Bump feature version
|
||||
if: "${{ inputs.release_reason != 'feature' }}"
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "version: bump to ${{ inputs.version }}"
|
||||
title: "version: bump to ${{ inputs.version }}"
|
||||
body: "version: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
3
.github/workflows/release-publish.yml
vendored
3
.github/workflows/release-publish.yml
vendored
@@ -10,6 +10,7 @@ jobs:
|
||||
uses: ./.github/workflows/_reusable-docker-build.yaml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
@@ -23,6 +24,7 @@ jobs:
|
||||
build-docs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
@@ -66,6 +68,7 @@ jobs:
|
||||
build-outpost:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed to upload container images to ghcr.io
|
||||
packages: write
|
||||
# Needed for attestation
|
||||
|
||||
210
.github/workflows/release-tag.yml
vendored
210
.github/workflows/release-tag.yml
vendored
@@ -1,39 +1,195 @@
|
||||
---
|
||||
name: Release - On tag
|
||||
name: Release - Tag new version
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "version/*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version
|
||||
required: true
|
||||
type: string
|
||||
release_reason:
|
||||
description: Release reason
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- bugfix
|
||||
- feature
|
||||
- security
|
||||
- other
|
||||
- prerelease
|
||||
|
||||
env:
|
||||
POSTGRES_DB: authentik
|
||||
POSTGRES_USER: authentik
|
||||
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Create Release from Tag
|
||||
check-inputs:
|
||||
name: Check inputs validity
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: check
|
||||
run: |
|
||||
echo "${{ inputs.version }}" | grep -E '^[0-9]{4}\.(0?[1-9]|1[0-2])\.[0-9]+(-rc[0-9]+)?$'
|
||||
echo "major_version=${{ inputs.version }}" | grep -oE "^major_version=[0-9]{4}\.[0-9]{1,2}" >> "$GITHUB_OUTPUT"
|
||||
- id: changelog-url
|
||||
run: |
|
||||
if [ "${{ inputs.release_reason }}" = "feature" ] || [ "${{ inputs.release_reason }}" = "prerelease" ]; then
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}"
|
||||
else
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ steps.check.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version }} | sed 's/\.//g')"
|
||||
fi
|
||||
echo "changelog_url=${changelog_url}" >> "$GITHUB_OUTPUT"
|
||||
outputs:
|
||||
major_version: "${{ steps.check.outputs.major_version }}"
|
||||
changelog_url: "${{ steps.changelog-url.outputs.changelog_url }}"
|
||||
test:
|
||||
name: Pre-release test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Pre-release test
|
||||
- run: make test-docker
|
||||
bump-authentik:
|
||||
name: Bump authentik version
|
||||
needs:
|
||||
- check-inputs
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
ref: "version-${{ needs.check-inputs.outputs.major_version }}"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Run migrations
|
||||
run: make migrate
|
||||
- name: Bump version
|
||||
run: "make bump version=${{ inputs.version }}"
|
||||
- name: Commit and push
|
||||
run: |
|
||||
make test-docker
|
||||
- id: generate_token
|
||||
uses: tibdex/github-app-token@v2
|
||||
with:
|
||||
app_id: ${{ secrets.GH_APP_ID }}
|
||||
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
with:
|
||||
image-name: ghcr.io/goauthentik/server
|
||||
# ID from https://api.github.com/users/authentik-automation[bot]
|
||||
git config --global user.name '${{ steps.app-token.outputs.app-slug }}[bot]'
|
||||
git config --global user.email '${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com'
|
||||
git commit -a -m "release: ${{ inputs.version }}" --allow-empty
|
||||
git tag "version/${{ inputs.version }}" HEAD -m "version/${{ inputs.version }}"
|
||||
git push --follow-tags
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1.1.4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref }}
|
||||
release_name: Release ${{ steps.ev.outputs.version }}
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
tag_name: "version/${{ inputs.version }}"
|
||||
name: Release ${{ inputs.version }}
|
||||
draft: true
|
||||
prerelease: ${{ steps.ev.outputs.prerelease == 'true' }}
|
||||
prerelease: ${{ inputs.release_reason == 'prerelease' }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
See ${{ needs.check-inputs.outputs.changelog_url }}
|
||||
bump-helm:
|
||||
name: Bump Helm version
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
needs:
|
||||
- bump-authentik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: helm
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/helm"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
run: |
|
||||
sed -i 's/^version: .*/version: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/^appVersion: .*/appVersion: ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -i 's/upgrade to authentik .*/upgrade to authentik ${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
sed -E -i 's/[0-9]{4}\.[0-9]{1,2}\.[0-9]+$/${{ inputs.version }}/' charts/authentik/Chart.yaml
|
||||
./scripts/helm-docs.sh
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
title: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
body: "charts/authentik: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
bump-version:
|
||||
name: Bump version repository
|
||||
if: ${{ inputs.release_reason != 'prerelease' }}
|
||||
needs:
|
||||
- check-inputs
|
||||
- bump-authentik
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: app-token
|
||||
name: Generate app token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ secrets.GH_APP_ID }}
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
repositories: version
|
||||
- id: get-user-id
|
||||
name: Get GitHub app user ID
|
||||
run: echo "user-id=$(gh api "/users/${{ steps.app-token.outputs.app-slug }}[bot]" --jq .id)" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: "${{ steps.app-token.outputs.token }}"
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
repository: "${{ github.repository_owner }}/version"
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
- name: Bump version
|
||||
if: "${{ inputs.release_reason == 'feature' }}"
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Bump version
|
||||
if: "${{ inputs.release_reason != 'feature' }}"
|
||||
run: |
|
||||
changelog_url="https://docs.goauthentik.io/docs/releases/${{ needs.check-inputs.outputs.major_version }}#fixed-in-$(echo -n ${{ inputs.version}} | sed 's/\.//g')"
|
||||
jq \
|
||||
--arg version "${{ inputs.version }}" \
|
||||
--arg changelog "See ${changelog_url}" \
|
||||
--arg changelog_url "${changelog_url}" \
|
||||
'.stable.version = $version | .stable.changelog = $changelog | .stable.changelog_url = $changelog_url' version.json > version.new.json
|
||||
mv version.new.json version.json
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: "${{ steps.app-token.outputs.token }}"
|
||||
branch: bump-${{ inputs.version }}
|
||||
commit-message: "version: bump to ${{ inputs.version }}"
|
||||
title: "version: bump to ${{ inputs.version }}"
|
||||
body: "version: bump to ${{ inputs.version }}"
|
||||
delete-branch: true
|
||||
signoff: true
|
||||
author: "${{ steps.app-token.outputs.app-slug }}[bot] <${{ steps.get-user-id.outputs.user-id }}+${{ steps.app-token.outputs.app-slug }}[bot]@users.noreply.github.com>"
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
website/docs/developer-docs/index.md
|
||||
4
CONTRIBUTING.md
Normal file
4
CONTRIBUTING.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Contributing to authentik
|
||||
|
||||
Thanks for your interest in contributing! Please see our [contributing guide](https://docs.goauthentik.io/docs/developer-docs/?utm_source=github) for more information.
|
||||
|
||||
@@ -119,7 +119,11 @@ RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/v
|
||||
libltdl-dev && \
|
||||
curl https://sh.rustup.rs -sSf | sh -s -- -y
|
||||
|
||||
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec"
|
||||
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec" \
|
||||
# https://github.com/rust-lang/rustup/issues/2949
|
||||
# Fixes issues where the rust version in the build cache is older than latest
|
||||
# and rustup tries to update it, which fails
|
||||
RUSTUP_PERMIT_COPY_RENAME="true"
|
||||
|
||||
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
|
||||
--mount=type=bind,target=uv.lock,src=uv.lock \
|
||||
@@ -175,6 +179,7 @@ COPY ./lifecycle/ /lifecycle
|
||||
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
|
||||
COPY --from=go-builder /go/authentik /bin/authentik
|
||||
COPY ./packages/ /ak-root/packages
|
||||
RUN ln -s /ak-root/packages /packages
|
||||
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
|
||||
COPY --from=node-builder /work/web/dist/ /web/dist/
|
||||
COPY --from=node-builder /work/web/authentik/ /web/authentik/
|
||||
|
||||
27
README.md
27
README.md
@@ -15,15 +15,16 @@
|
||||
|
||||
## What is authentik?
|
||||
|
||||
authentik is an open-source Identity Provider that emphasizes flexibility and versatility, with support for a wide set of protocols.
|
||||
authentik is an open-source Identity Provider (IdP) for modern SSO. It supports SAML, OAuth2/OIDC, LDAP, RADIUS, and more, designed for self-hosting from small labs to large production clusters.
|
||||
|
||||
Our [enterprise offer](https://goauthentik.io/pricing) can also be used as a self-hosted replacement for large-scale deployments of Okta/Auth0, Entra ID, Ping Identity, or other legacy IdPs for employees and B2B2C use.
|
||||
Our [enterprise offering](https://goauthentik.io/pricing) is available for organizations to securely replace existing IdPs such as Okta, Auth0, Entra ID, and Ping Identity for robust, large-scale identity management.
|
||||
|
||||
## Installation
|
||||
|
||||
For small/test setups it is recommended to use Docker Compose; refer to the [documentation](https://goauthentik.io/docs/installation/docker-compose/?utm_source=github).
|
||||
|
||||
For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/?utm_source=github).
|
||||
- Docker Compose: recommended for small/test setups. See the [documentation](https://docs.goauthentik.io/docs/install-config/install/docker-compose/).
|
||||
- Kubernetes (Helm Chart): recommended for larger setups. See the [documentation](https://docs.goauthentik.io/docs/install-config/install/kubernetes/) and the Helm chart [repository](https://github.com/goauthentik/helm).
|
||||
- AWS CloudFormation: deploy on AWS using our official templates. See the [documentation](https://docs.goauthentik.io/docs/install-config/install/aws/).
|
||||
- DigitalOcean Marketplace: one-click deployment via the official Marketplace app. See the [app listing](https://marketplace.digitalocean.com/apps/authentik).
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -32,14 +33,20 @@ For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/h
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
## Development
|
||||
## Development and contributions
|
||||
|
||||
See [Developer Documentation](https://docs.goauthentik.io/docs/developer-docs/?utm_source=github)
|
||||
See the [Developer Documentation](https://docs.goauthentik.io/docs/developer-docs/) for information about setting up local build environments, testing your contributions, and our contribution process.
|
||||
|
||||
## Security
|
||||
|
||||
See [SECURITY.md](SECURITY.md)
|
||||
Please see [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Adoption and Contributions
|
||||
## Adoption
|
||||
|
||||
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [contribution guide](https://docs.goauthentik.io/docs/developer-docs?utm_source=github).
|
||||
Using authentik? We'd love to hear your story and feature your logo. Email us at [hello@goauthentik.io](mailto:hello@goauthentik.io) or open a GitHub Issue/PR!
|
||||
|
||||
## License
|
||||
|
||||
[](LICENSE)
|
||||
[](website/LICENSE)
|
||||
[](authentik/enterprise/LICENSE)
|
||||
|
||||
25
SECURITY.md
25
SECURITY.md
@@ -20,12 +20,33 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
|
||||
|
||||
| Version | Supported |
|
||||
| --------- | --------- |
|
||||
| 2025.4.x | ✅ |
|
||||
| 2025.6.x | ✅ |
|
||||
| 2025.8.x | ✅ |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability, send an email to [security@goauthentik.io](mailto:security@goauthentik.io). Be sure to include relevant information like which version you've found the issue in, instructions on how to reproduce the issue, and anything else that might make it easier for us to find the issue.
|
||||
If you discover a potential vulnerability, please report it responsibly through one of the following channels:
|
||||
|
||||
- **Email**: [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||
- **GitHub**: Submit a private security advisory via our [repository’s advisory portal](https://github.com/goauthentik/authentik/security/advisories/new)
|
||||
|
||||
When submitting a report, please include as much detail as possible, such as:
|
||||
|
||||
- **Affected version(s)**: The version of authentik where the issue was identified.
|
||||
- **Steps to reproduce**: A clear description or proof of concept to help us verify the issue.
|
||||
- **Impact assessment**: How the vulnerability could be exploited and its potential effect.
|
||||
- **Additional information**: Logs, configuration details (if relevant), or any suggested mitigations.
|
||||
|
||||
We kindly ask that you do not disclose the vulnerability publicly until we have confirmed and addressed the issue.
|
||||
|
||||
Our team will:
|
||||
|
||||
- Acknowledge receipt of your report as quickly as possible.
|
||||
- Keep you updated on the investigation and resolution progress.
|
||||
|
||||
## Researcher Recognition
|
||||
|
||||
We value contributions from the security community. For each valid report, we will publish a dedicated entry on our Security Advisory page that optionally includes the reporter’s name (or preferred alias). Please note that while we do not currently offer monetary bounties, we are committed to giving researchers appropriate credit for their efforts in keeping authentik secure.
|
||||
|
||||
## Severity levels
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2025.8.0-rc1"
|
||||
VERSION = "2025.8.4"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from authentik.blueprints.v1.oci import OCI_PREFIX
|
||||
from authentik.events.logs import capture_logs
|
||||
from authentik.events.utils import sanitize_dict
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.tasks.apps import PRIORITY_HIGH
|
||||
from authentik.tasks.models import Task
|
||||
from authentik.tasks.schedules.models import Schedule
|
||||
from authentik.tenants.models import Tenant
|
||||
@@ -111,6 +112,7 @@ class BlueprintEventHandler(FileSystemEventHandler):
|
||||
@actor(
|
||||
description=_("Find blueprints as `blueprints_find` does, but return a safe dict."),
|
||||
throws=(DatabaseError, ProgrammingError, InternalError),
|
||||
priority=PRIORITY_HIGH,
|
||||
)
|
||||
def blueprints_find_dict():
|
||||
blueprints = []
|
||||
|
||||
@@ -63,28 +63,6 @@ class TestBrands(APITestCase):
|
||||
},
|
||||
)
|
||||
|
||||
def test_brand_subdomain_same_suffix(self):
|
||||
"""Test Current brand API"""
|
||||
Brand.objects.all().delete()
|
||||
Brand.objects.create(domain="bar.baz", branding_title="custom")
|
||||
Brand.objects.create(domain="foo.bar.baz", branding_title="custom")
|
||||
self.assertJSONEqual(
|
||||
self.client.get(
|
||||
reverse("authentik_api:brand-current"), HTTP_HOST="foo.bar.baz"
|
||||
).content.decode(),
|
||||
{
|
||||
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
|
||||
"branding_favicon": "/static/dist/assets/icons/icon.png",
|
||||
"branding_title": "custom",
|
||||
"branding_custom_css": "",
|
||||
"matched_domain": "foo.bar.baz",
|
||||
"ui_footer_links": [],
|
||||
"ui_theme": Themes.AUTOMATIC,
|
||||
"default_locale": "",
|
||||
"flags": self.default_flags,
|
||||
},
|
||||
)
|
||||
|
||||
def test_fallback(self):
|
||||
"""Test fallback brand"""
|
||||
Brand.objects.all().delete()
|
||||
|
||||
@@ -4,7 +4,6 @@ from typing import Any
|
||||
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Value as V
|
||||
from django.db.models.functions import Length
|
||||
from django.http.request import HttpRequest
|
||||
from django.utils.html import _json_script_escapes
|
||||
from django.utils.safestring import mark_safe
|
||||
@@ -21,9 +20,9 @@ DEFAULT_BRAND = Brand(domain="fallback")
|
||||
def get_brand_for_request(request: HttpRequest) -> Brand:
|
||||
"""Get brand object for current request"""
|
||||
db_brands = (
|
||||
Brand.objects.annotate(host_domain=V(request.get_host()), match_length=Length("domain"))
|
||||
Brand.objects.annotate(host_domain=V(request.get_host()))
|
||||
.filter(Q(host_domain__iendswith=F("domain")) | _q_default)
|
||||
.order_by("-match_length", "default")
|
||||
.order_by("default")
|
||||
)
|
||||
brands = list(db_brands.all())
|
||||
if len(brands) < 1:
|
||||
|
||||
@@ -328,6 +328,12 @@ class SessionUserSerializer(PassiveSerializer):
|
||||
original = UserSelfSerializer(required=False)
|
||||
|
||||
|
||||
class UserPasswordSetSerializer(PassiveSerializer):
|
||||
"""Payload to set a users' password directly"""
|
||||
|
||||
password = CharField(required=True)
|
||||
|
||||
|
||||
class UsersFilter(FilterSet):
|
||||
"""Filter for users"""
|
||||
|
||||
@@ -585,12 +591,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@permission_required("authentik_core.reset_user_password")
|
||||
@extend_schema(
|
||||
request=inline_serializer(
|
||||
"UserPasswordSetSerializer",
|
||||
{
|
||||
"password": CharField(required=True),
|
||||
},
|
||||
),
|
||||
request=UserPasswordSetSerializer,
|
||||
responses={
|
||||
204: OpenApiResponse(description="Successfully changed password"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
@@ -599,9 +600,11 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
@action(detail=True, methods=["POST"], permission_classes=[])
|
||||
def set_password(self, request: Request, pk: int) -> Response:
|
||||
"""Set password for user"""
|
||||
data = UserPasswordSetSerializer(data=request.data)
|
||||
data.is_valid(raise_exception=True)
|
||||
user: User = self.get_object()
|
||||
try:
|
||||
user.set_password(request.data.get("password"), request=request)
|
||||
user.set_password(data.validated_data["password"], request=request)
|
||||
user.save()
|
||||
except (ValidationError, IntegrityError) as exc:
|
||||
LOGGER.debug("Failed to set password", exc=exc)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.12 on 2025-09-25 13:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0050_user_last_updated_and_more"),
|
||||
("authentik_rbac", "0006_alter_role_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="group",
|
||||
index=models.Index(fields=["is_superuser"], name="authentik_c_is_supe_1e5a97_idx"),
|
||||
),
|
||||
]
|
||||
@@ -200,7 +200,10 @@ class Group(SerializerModel, AttributesMixin):
|
||||
"parent",
|
||||
),
|
||||
)
|
||||
indexes = [models.Index(fields=["name"])]
|
||||
indexes = (
|
||||
models.Index(fields=["name"]),
|
||||
models.Index(fields=["is_superuser"]),
|
||||
)
|
||||
verbose_name = _("Group")
|
||||
verbose_name_plural = _("Groups")
|
||||
permissions = [
|
||||
|
||||
@@ -102,6 +102,16 @@ class TestUsersAPI(APITestCase):
|
||||
self.admin.refresh_from_db()
|
||||
self.assertTrue(self.admin.check_password(new_pw))
|
||||
|
||||
def test_set_password_blank(self):
|
||||
"""Test Direct password set"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-set-password", kwargs={"pk": self.admin.pk}),
|
||||
data={"password": ""},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]})
|
||||
|
||||
def test_recovery(self):
|
||||
"""Test user recovery link"""
|
||||
flow = create_test_flow(
|
||||
|
||||
@@ -23,6 +23,7 @@ from authentik.events.models import (
|
||||
)
|
||||
from authentik.events.utils import get_user
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.stages.email.models import get_template_choices
|
||||
|
||||
|
||||
class NotificationTransportSerializer(ModelSerializer):
|
||||
@@ -30,6 +31,18 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
|
||||
mode_verbose = SerializerMethodField()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["email_template"].choices = get_template_choices()
|
||||
|
||||
def validate_email_template(self, value: str) -> str:
|
||||
"""Check validity of email template"""
|
||||
choices = get_template_choices()
|
||||
for path, _ in choices:
|
||||
if path == value:
|
||||
return value
|
||||
raise ValidationError(f"Invalid template '{value}' specified.")
|
||||
|
||||
def get_mode_verbose(self, instance: NotificationTransport) -> str:
|
||||
"""Return selected mode with a UI Label"""
|
||||
return TransportMode(instance.mode).label
|
||||
@@ -52,6 +65,8 @@ class NotificationTransportSerializer(ModelSerializer):
|
||||
"webhook_url",
|
||||
"webhook_mapping_body",
|
||||
"webhook_mapping_headers",
|
||||
"email_subject_prefix",
|
||||
"email_template",
|
||||
"send_once",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.11 on 2025-08-14 13:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0011_alter_systemtask_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="notificationtransport",
|
||||
name="email_subject_prefix",
|
||||
field=models.TextField(blank=True, default="authentik Notification: "),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="notificationtransport",
|
||||
name="email_template",
|
||||
field=models.TextField(default="email/event_notification.html"),
|
||||
),
|
||||
]
|
||||
@@ -41,6 +41,7 @@ from authentik.lib.utils.http import get_http_session
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
from authentik.stages.email.models import EmailTemplates
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.tasks.models import TasksModel
|
||||
from authentik.tenants.models import Tenant
|
||||
@@ -295,6 +296,15 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL)
|
||||
send_once = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Only send notification once, for example when sending a webhook into a chat channel."
|
||||
),
|
||||
)
|
||||
|
||||
email_subject_prefix = models.TextField(default="authentik Notification: ", blank=True)
|
||||
email_template = models.TextField(default=EmailTemplates.EVENT_NOTIFICATION)
|
||||
|
||||
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
|
||||
webhook_mapping_body = models.ForeignKey(
|
||||
@@ -319,12 +329,6 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
"Mapping should return a dictionary of key-value pairs"
|
||||
),
|
||||
)
|
||||
send_once = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_(
|
||||
"Only send notification once, for example when sending a webhook into a chat channel."
|
||||
),
|
||||
)
|
||||
|
||||
def send(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to user, called from async task"""
|
||||
@@ -462,7 +466,6 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
notification=notification,
|
||||
)
|
||||
return None
|
||||
subject_prefix = "authentik Notification: "
|
||||
context = {
|
||||
"key_value": {
|
||||
"user_email": notification.user.email,
|
||||
@@ -490,10 +493,10 @@ class NotificationTransport(TasksModel, SerializerModel):
|
||||
"from": self.name,
|
||||
}
|
||||
mail = TemplateEmailMessage(
|
||||
subject=subject_prefix + context["title"],
|
||||
subject=self.email_subject_prefix + context["title"],
|
||||
to=[(notification.user.name, notification.user.email)],
|
||||
language=notification.user.locale(),
|
||||
template_name="email/event_notification.html",
|
||||
template_name=self.email_template,
|
||||
template_context=context,
|
||||
)
|
||||
send_mail.send_with_options(args=(mail.__dict__,), rel_obj=self)
|
||||
|
||||
@@ -5,10 +5,12 @@ from unittest.mock import PropertyMock, patch
|
||||
from django.core import mail
|
||||
from django.core.mail.backends.locmem import EmailBackend
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from requests_mock import Mocker
|
||||
|
||||
from authentik import authentik_full_version
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.events.api.notification_transports import NotificationTransportSerializer
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
Notification,
|
||||
@@ -18,6 +20,7 @@ from authentik.events.models import (
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.email.models import get_template_choices
|
||||
|
||||
|
||||
class TestEventTransports(TestCase):
|
||||
@@ -138,3 +141,76 @@ class TestEventTransports(TestCase):
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].subject, "authentik Notification: custom_foo")
|
||||
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])
|
||||
|
||||
def test_transport_email_custom_template(self):
|
||||
"""Test email transport with custom template"""
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.EMAIL,
|
||||
email_template="email/event_notification.html",
|
||||
)
|
||||
with patch(
|
||||
"authentik.stages.email.models.EmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
):
|
||||
transport.send(self.notification)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn(self.notification.body, mail.outbox[0].alternatives[0][0])
|
||||
|
||||
def test_transport_email_custom_subject_prefix(self):
|
||||
"""Test email transport with custom subject prefix"""
|
||||
transport: NotificationTransport = NotificationTransport.objects.create(
|
||||
name=generate_id(),
|
||||
mode=TransportMode.EMAIL,
|
||||
email_subject_prefix="[CUSTOM] ",
|
||||
)
|
||||
with patch(
|
||||
"authentik.stages.email.models.EmailStage.backend_class",
|
||||
PropertyMock(return_value=EmailBackend),
|
||||
):
|
||||
transport.send(self.notification)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].subject, "[CUSTOM] custom_foo")
|
||||
|
||||
def test_transport_email_validation(self):
|
||||
"""Test email transport template validation"""
|
||||
|
||||
# Test valid template
|
||||
serializer = NotificationTransportSerializer(
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"mode": TransportMode.EMAIL,
|
||||
"email_template": "email/event_notification.html",
|
||||
}
|
||||
)
|
||||
self.assertTrue(serializer.is_valid())
|
||||
|
||||
# Test invalid template - should fail due to choices validation
|
||||
serializer = NotificationTransportSerializer(
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"mode": TransportMode.EMAIL,
|
||||
"email_template": "invalid/template.html",
|
||||
}
|
||||
)
|
||||
self.assertFalse(serializer.is_valid())
|
||||
self.assertIn("email_template", serializer.errors)
|
||||
|
||||
def test_templates_api_endpoint(self):
|
||||
"""Test templates API endpoint returns valid templates"""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(reverse("authentik_api:emailstage-templates"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
data = response.json()
|
||||
self.assertIsInstance(data, list)
|
||||
|
||||
# Check that we have at least the default templates
|
||||
template_names = [item["name"] for item in data]
|
||||
self.assertIn("email/event_notification.html", template_names)
|
||||
|
||||
# Verify all templates are valid choices
|
||||
valid_choices = dict(get_template_choices())
|
||||
for template in data:
|
||||
self.assertIn(template["name"], valid_choices)
|
||||
self.assertEqual(template["description"], valid_choices[template["name"]])
|
||||
|
||||
@@ -46,5 +46,5 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = FlowStageBindingSerializer
|
||||
filterset_fields = "__all__"
|
||||
search_fields = ["stage__name"]
|
||||
ordering = ["order"]
|
||||
ordering_fields = ["order", "stage__name"]
|
||||
ordering = ["order", "pk"]
|
||||
ordering_fields = ["order", "stage__name", "target__uuid", "pk"]
|
||||
|
||||
@@ -19,7 +19,7 @@ def start_debug_server(**kwargs) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
listen: str = CONFIG.get("listen.listen_debug_py", "127.0.0.1:9901")
|
||||
listen: str = CONFIG.get("listen.debug_py", "127.0.0.1:9901")
|
||||
host, _, port = listen.rpartition(":")
|
||||
try:
|
||||
debugpy.listen((host, int(port)), **kwargs) # nosec
|
||||
|
||||
@@ -31,14 +31,14 @@ postgresql:
|
||||
# host: replica1.example.com
|
||||
|
||||
listen:
|
||||
listen_http: 0.0.0.0:9000
|
||||
listen_https: 0.0.0.0:9443
|
||||
listen_ldap: 0.0.0.0:3389
|
||||
listen_ldaps: 0.0.0.0:6636
|
||||
listen_radius: 0.0.0.0:1812
|
||||
listen_metrics: 0.0.0.0:9300
|
||||
listen_debug: 0.0.0.0:9900
|
||||
listen_debug_py: 0.0.0.0:9901
|
||||
http: 0.0.0.0:9000
|
||||
https: 0.0.0.0:9443
|
||||
ldap: 0.0.0.0:3389
|
||||
ldaps: 0.0.0.0:6636
|
||||
radius: 0.0.0.0:1812
|
||||
metrics: 0.0.0.0:9300
|
||||
debug: 0.0.0.0:9900
|
||||
debug_py: 0.0.0.0:9901
|
||||
trusted_proxy_cidrs:
|
||||
- 127.0.0.0/8
|
||||
- 10.0.0.0/8
|
||||
@@ -152,8 +152,9 @@ worker:
|
||||
processes: 1
|
||||
threads: 2
|
||||
consumer_listen_timeout: "seconds=30"
|
||||
task_max_retries: 20
|
||||
task_max_retries: 5
|
||||
task_default_time_limit: "minutes=10"
|
||||
lock_purge_interval: "minutes=1"
|
||||
task_purge_interval: "days=1"
|
||||
task_expiration: "days=30"
|
||||
scheduler_interval: "seconds=60"
|
||||
|
||||
@@ -43,7 +43,9 @@ def structlog_configure():
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
structlog.processors.TimeStamper(fmt="iso", utc=False),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.dict_tracebacks,
|
||||
structlog.processors.ExceptionRenderer(
|
||||
structlog.processors.ExceptionDictTransformer(show_locals=CONFIG.get_bool("debug"))
|
||||
),
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
@@ -65,7 +67,14 @@ def get_logger_config():
|
||||
"json": {
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
"processor": structlog.processors.JSONRenderer(sort_keys=True),
|
||||
"foreign_pre_chain": LOG_PRE_CHAIN + [structlog.processors.dict_tracebacks],
|
||||
"foreign_pre_chain": LOG_PRE_CHAIN
|
||||
+ [
|
||||
structlog.processors.ExceptionRenderer(
|
||||
structlog.processors.ExceptionDictTransformer(
|
||||
show_locals=CONFIG.get_bool("debug")
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
"console": {
|
||||
"()": structlog.stdlib.ProcessorFormatter,
|
||||
|
||||
@@ -22,6 +22,7 @@ from sentry_sdk import init as sentry_sdk_init
|
||||
from sentry_sdk.api import set_tag
|
||||
from sentry_sdk.integrations.argv import ArgvIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.dramatiq import DramatiqIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from sentry_sdk.integrations.socket import SocketIntegration
|
||||
from sentry_sdk.integrations.stdlib import StdlibIntegration
|
||||
@@ -109,11 +110,12 @@ def sentry_init(**sentry_init_kwargs):
|
||||
dsn=CONFIG.get("error_reporting.sentry_dsn"),
|
||||
integrations=[
|
||||
ArgvIntegration(),
|
||||
StdlibIntegration(),
|
||||
DjangoIntegration(transaction_style="function_name", cache_spans=True),
|
||||
DramatiqIntegration(),
|
||||
RedisIntegration(),
|
||||
ThreadingIntegration(propagate_hub=True),
|
||||
SocketIntegration(),
|
||||
StdlibIntegration(),
|
||||
ThreadingIntegration(propagate_hub=True),
|
||||
],
|
||||
before_send=before_send,
|
||||
traces_sampler=traces_sampler,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from dramatiq.actor import Actor
|
||||
from dramatiq.results.errors import ResultFailure
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import BooleanField, CharField, ChoiceField
|
||||
@@ -110,9 +111,13 @@ class OutgoingSyncProviderStatusMixin:
|
||||
"override_dry_run": params.validated_data["override_dry_run"],
|
||||
"pk": params.validated_data["sync_object_id"],
|
||||
},
|
||||
retries=0,
|
||||
rel_obj=provider,
|
||||
)
|
||||
msg.get_result(block=True)
|
||||
try:
|
||||
msg.get_result(block=True)
|
||||
except ResultFailure:
|
||||
pass
|
||||
task: Task = msg.options["task"]
|
||||
task.refresh_from_db()
|
||||
return Response(SyncObjectResultSerializer(instance={"messages": task._messages}).data)
|
||||
|
||||
@@ -20,6 +20,7 @@ from authentik.lib.sync.outgoing.exceptions import (
|
||||
TransientSyncException,
|
||||
)
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.lib.utils.reflection import class_to_path, path_to_class
|
||||
from authentik.tasks.models import Task
|
||||
|
||||
@@ -164,16 +165,17 @@ class SyncTasks:
|
||||
except BadRequestSyncException as exc:
|
||||
self.logger.warning("failed to sync object", exc=exc, obj=obj)
|
||||
task.warning(
|
||||
f"Failed to sync {obj._meta.verbose_name} {str(obj)} due to error: {str(exc)}",
|
||||
f"Failed to sync {str(obj)} due to error: {str(exc)}",
|
||||
arguments=exc.args[1:],
|
||||
obj=sanitize_item(obj),
|
||||
exception=exception_to_dict(exc),
|
||||
)
|
||||
except TransientSyncException as exc:
|
||||
self.logger.warning("failed to sync object", exc=exc, user=obj)
|
||||
task.warning(
|
||||
f"Failed to sync {obj._meta.verbose_name} {str(obj)} due to "
|
||||
"transient error: {str(exc)}",
|
||||
f"Failed to sync {str(obj)} due to " f"transient error: {str(exc)}",
|
||||
obj=sanitize_item(obj),
|
||||
exception=exception_to_dict(exc),
|
||||
)
|
||||
except StopSync as exc:
|
||||
self.logger.warning("Stopping sync", exc=exc)
|
||||
|
||||
@@ -4,9 +4,11 @@ from traceback import extract_tb
|
||||
|
||||
from structlog.tracebacks import ExceptionDictTransformer
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
|
||||
TRACEBACK_HEADER = "Traceback (most recent call last):"
|
||||
_exception_transformer = ExceptionDictTransformer(show_locals=CONFIG.get_bool("debug"))
|
||||
|
||||
|
||||
def exception_to_string(exc: Exception) -> str:
|
||||
@@ -23,4 +25,4 @@ def exception_to_string(exc: Exception) -> str:
|
||||
|
||||
def exception_to_dict(exc: Exception) -> dict:
|
||||
"""Format exception as a dictionary"""
|
||||
return ExceptionDictTransformer()((type(exc), exc, exc.__traceback__))
|
||||
return _exception_transformer((type(exc), exc, exc.__traceback__))
|
||||
|
||||
@@ -13,6 +13,7 @@ from urllib3.exceptions import HTTPError
|
||||
from yaml import dump_all
|
||||
|
||||
from authentik.events.logs import LogEvent, capture_logs
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
|
||||
from authentik.outposts.controllers.k8s.base import KubernetesObjectReconciler
|
||||
from authentik.outposts.controllers.k8s.deployment import DeploymentReconciler
|
||||
@@ -105,7 +106,7 @@ class KubernetesController(BaseController):
|
||||
LogEvent(
|
||||
log_level="info",
|
||||
event=f"{reconcile_key.title()}: Disabled",
|
||||
logger=str(type(self)),
|
||||
logger=class_to_path(self.__class__),
|
||||
)
|
||||
)
|
||||
continue
|
||||
@@ -144,7 +145,7 @@ class KubernetesController(BaseController):
|
||||
LogEvent(
|
||||
log_level="info",
|
||||
event=f"{reconcile_key.title()}: Disabled",
|
||||
logger=str(type(self)),
|
||||
logger=class_to_path(self.__class__),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
@@ -76,6 +76,7 @@ class OutpostConfig:
|
||||
kubernetes_ingress_annotations: dict[str, str] = field(default_factory=dict)
|
||||
kubernetes_ingress_secret_name: str = field(default="authentik-outpost-tls")
|
||||
kubernetes_ingress_class_name: str | None = field(default=None)
|
||||
kubernetes_ingress_path_type: str | None = field(default=None)
|
||||
kubernetes_httproute_annotations: dict[str, str] = field(default_factory=dict)
|
||||
kubernetes_httproute_parent_refs: list[dict[str, str]] = field(default_factory=list)
|
||||
kubernetes_service_type: str = field(default="ClusterIP")
|
||||
@@ -151,7 +152,7 @@ class OutpostServiceConnection(ScheduledModel, models.Model):
|
||||
|
||||
state = cache.get(self.state_key, None)
|
||||
if not state:
|
||||
outpost_service_connection_monitor.send_with_options(args=(self.pk), rel_obj=self)
|
||||
outpost_service_connection_monitor.send_with_options(args=(self.pk,), rel_obj=self)
|
||||
return OutpostServiceConnectionState("", False)
|
||||
return state
|
||||
|
||||
|
||||
@@ -124,4 +124,5 @@ class PolicyBindingViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = PolicyBindingSerializer
|
||||
search_fields = ["policy__name"]
|
||||
filterset_class = PolicyBindingFilter
|
||||
ordering = ["target", "order"]
|
||||
ordering = ["order", "pk"]
|
||||
ordering_fields = ["order", "target__uuid", "pk"]
|
||||
|
||||
@@ -26,7 +26,6 @@ HIST_POLICIES_EXECUTION_TIME = Histogram(
|
||||
"binding_order",
|
||||
"binding_target_type",
|
||||
"binding_target_name",
|
||||
"object_pk",
|
||||
"object_type",
|
||||
"mode",
|
||||
],
|
||||
|
||||
@@ -86,7 +86,6 @@ class PolicyEngine:
|
||||
binding_order=binding.order,
|
||||
binding_target_type=binding.target_type,
|
||||
binding_target_name=binding.target_name,
|
||||
object_pk=str(self.request.obj.pk),
|
||||
object_type=class_to_path(self.request.obj.__class__),
|
||||
mode="cache_retrieve",
|
||||
).time():
|
||||
|
||||
@@ -103,7 +103,7 @@ class PasswordPolicy(Policy):
|
||||
if self.amount_lowercase > 0 and len(RE_LOWER.findall(password)) < self.amount_lowercase:
|
||||
LOGGER.debug("password failed", check="static", reason="amount_lowercase")
|
||||
return PolicyResult(False, self.error_message)
|
||||
if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_lowercase:
|
||||
if self.amount_uppercase > 0 and len(RE_UPPER.findall(password)) < self.amount_uppercase:
|
||||
LOGGER.debug("password failed", check="static", reason="amount_uppercase")
|
||||
return PolicyResult(False, self.error_message)
|
||||
if self.amount_symbols > 0:
|
||||
|
||||
@@ -131,7 +131,6 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
binding_order=self.binding.order,
|
||||
binding_target_type=self.binding.target_type,
|
||||
binding_target_name=self.binding.target_name,
|
||||
object_pk=str(self.request.obj.pk) if self.request.obj else "",
|
||||
object_type=class_to_path(self.request.obj.__class__) if self.request.obj else "",
|
||||
mode="execute_process",
|
||||
).time(),
|
||||
|
||||
@@ -11,7 +11,8 @@ def migrate_sessions(apps, schema_editor, model):
|
||||
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for obj in Model.objects.using(db_alias).all():
|
||||
objs = list(Model.objects.using(db_alias).select_related("old_session").all())
|
||||
for obj in objs:
|
||||
if not obj.old_session:
|
||||
continue
|
||||
obj.session = (
|
||||
|
||||
@@ -23,7 +23,12 @@ def user_session_deleted_oauth_backchannel_logout_and_tokens_removal(
|
||||
|
||||
backchannel_logout_notification_dispatch.send(
|
||||
revocations=[
|
||||
(token.provider_id, token.id_token.iss, token.session.user.uid)
|
||||
(
|
||||
token.provider_id,
|
||||
token.id_token.iss,
|
||||
token.id_token.sub,
|
||||
instance.session.session_key,
|
||||
)
|
||||
for token in access_tokens
|
||||
],
|
||||
)
|
||||
|
||||
@@ -14,13 +14,19 @@ LOGGER = get_logger()
|
||||
|
||||
|
||||
@actor(description=_("Send a back-channel logout request to the registered client"))
|
||||
def send_backchannel_logout_request(provider_pk: int, iss: str, sub: str = None) -> bool:
|
||||
def send_backchannel_logout_request(
|
||||
provider_pk: int,
|
||||
iss: str,
|
||||
sub: str | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> bool:
|
||||
"""Send a back-channel logout request to the registered client
|
||||
|
||||
Args:
|
||||
provider_pk: The OAuth2 provider's primary key
|
||||
iss: The issuer URL for the logout token
|
||||
sub: The subject identifier to include in the logout token
|
||||
session_key: The authentik session key to hash and include in the logout token
|
||||
|
||||
Returns:
|
||||
bool: True if the request was sent successfully, False otherwise
|
||||
@@ -33,11 +39,10 @@ def send_backchannel_logout_request(provider_pk: int, iss: str, sub: str = None)
|
||||
return
|
||||
|
||||
# Generate the logout token
|
||||
logout_token = create_logout_token(iss, provider, None, sub)
|
||||
logout_token = create_logout_token(provider, iss, sub, session_key)
|
||||
|
||||
# Get the back-channel logout URI from the provider's dedicated backchannel_logout_uri field
|
||||
# Back-channel logout requires explicit configuration - no fallback to redirect URIs
|
||||
|
||||
backchannel_logout_uri = provider.backchannel_logout_uri
|
||||
if not backchannel_logout_uri:
|
||||
self.info("No back-channel logout URI found for provider")
|
||||
@@ -60,9 +65,9 @@ def send_backchannel_logout_request(provider_pk: int, iss: str, sub: str = None)
|
||||
def backchannel_logout_notification_dispatch(revocations: list, **kwargs):
|
||||
"""Handle backchannel logout notifications dispatched via signal"""
|
||||
for revocation in revocations:
|
||||
provider_pk, iss, sub = revocation
|
||||
provider_pk, iss, sub, session_key = revocation
|
||||
provider = OAuth2Provider.objects.filter(pk=provider_pk).first()
|
||||
send_backchannel_logout_request.send_with_options(
|
||||
args=(provider_pk, iss, sub),
|
||||
args=(provider_pk, iss, sub, session_key),
|
||||
rel_obj=provider,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,12 @@ from jwt import decode
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Application, Group, Token, TokenIntents, UserTypes
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.core.tests.utils import (
|
||||
create_test_admin_user,
|
||||
create_test_cert,
|
||||
create_test_flow,
|
||||
create_test_user,
|
||||
)
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.constants import (
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
@@ -182,6 +187,47 @@ class TestTokenClientCredentialsUserNamePassword(OAuthTestCase):
|
||||
self.assertEqual(jwt["given_name"], self.user.name)
|
||||
self.assertEqual(jwt["preferred_username"], self.user.username)
|
||||
|
||||
def test_successful_two_tokens(self):
|
||||
"""test successful when two app passwords with the same key exist"""
|
||||
Token.objects.create(
|
||||
identifier="sa-token-two",
|
||||
user=create_test_user(),
|
||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||
expiring=False,
|
||||
key=self.token.key,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"username": "sa",
|
||||
"password": self.token.key,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], TOKEN_TYPE)
|
||||
_, alg = self.provider.jwt_key
|
||||
jwt = decode(
|
||||
body["access_token"],
|
||||
key=self.provider.signing_key.public_key,
|
||||
algorithms=[alg],
|
||||
audience=self.provider.client_id,
|
||||
)
|
||||
self.assertEqual(jwt["given_name"], self.user.name)
|
||||
self.assertEqual(jwt["preferred_username"], self.user.username)
|
||||
jwt = decode(
|
||||
body["id_token"],
|
||||
key=self.provider.signing_key.public_key,
|
||||
algorithms=[alg],
|
||||
audience=self.provider.client_id,
|
||||
)
|
||||
self.assertEqual(jwt["given_name"], self.user.name)
|
||||
self.assertEqual(jwt["preferred_username"], self.user.username)
|
||||
|
||||
def test_successful_password(self):
|
||||
"""test successful (password grant)"""
|
||||
response = self.client.post(
|
||||
|
||||
@@ -4,17 +4,18 @@ import re
|
||||
import uuid
|
||||
from base64 import b64decode
|
||||
from binascii import Error
|
||||
from time import time
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||
from django.http.response import HttpResponseRedirect
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from django.utils.timezone import now
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.middleware import CTX_AUTH_VIA, KEY_USER
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.providers.oauth2.errors import BearerTokenError
|
||||
from authentik.providers.oauth2.id_token import hash_session_key
|
||||
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
|
||||
@@ -217,23 +218,25 @@ class HttpResponseRedirectScheme(HttpResponseRedirect):
|
||||
|
||||
|
||||
def create_logout_token(
|
||||
iss: str,
|
||||
provider: OAuth2Provider,
|
||||
session_key: str | None = None,
|
||||
iss: str,
|
||||
sub: str | None = None,
|
||||
session_key: str | None = None,
|
||||
) -> str:
|
||||
"""Create a logout token for Back-Channel Logout
|
||||
|
||||
As per https://openid.net/specs/openid-connect-backchannel-1_0.html
|
||||
"""
|
||||
|
||||
LOGGER.debug("Creating logout token", provider=provider, session_key=session_key, sub=sub)
|
||||
LOGGER.debug("Creating logout token", provider=provider, sub=sub)
|
||||
|
||||
_now = now()
|
||||
# Create the logout token payload
|
||||
payload = {
|
||||
"iss": str(iss),
|
||||
"aud": provider.client_id,
|
||||
"iat": int(time()),
|
||||
"iat": int(_now.timestamp()),
|
||||
"exp": int((_now + timedelta_from_string(provider.access_token_validity)).timestamp()),
|
||||
"jti": str(uuid.uuid4()),
|
||||
"events": {
|
||||
"http://schemas.openid.net/event/backchannel-logout": {},
|
||||
|
||||
@@ -340,7 +340,7 @@ class TokenParams:
|
||||
if not user:
|
||||
raise TokenError("invalid_grant")
|
||||
token: Token = Token.filter_not_expired(
|
||||
key=password, intent=TokenIntents.INTENT_APP_PASSWORD
|
||||
key=password, intent=TokenIntents.INTENT_APP_PASSWORD, user=user
|
||||
).first()
|
||||
if not token or token.user.uid != user.uid:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
@@ -127,6 +127,9 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
||||
and self.controller.outpost.config.kubernetes_ingress_secret_name
|
||||
):
|
||||
tls_hosts.append(external_host_name.hostname)
|
||||
path_type = "Prefix"
|
||||
if self.controller.outpost.config.kubernetes_ingress_path_type:
|
||||
path_type = self.controller.outpost.config.kubernetes_ingress_path_type
|
||||
if proxy_provider.mode in [
|
||||
ProxyMode.FORWARD_SINGLE,
|
||||
ProxyMode.FORWARD_DOMAIN,
|
||||
@@ -143,7 +146,7 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
||||
),
|
||||
),
|
||||
path="/outpost.goauthentik.io",
|
||||
path_type="Prefix",
|
||||
path_type=path_type,
|
||||
)
|
||||
]
|
||||
),
|
||||
@@ -161,7 +164,7 @@ class IngressReconciler(KubernetesObjectReconciler[V1Ingress]):
|
||||
),
|
||||
),
|
||||
path="/",
|
||||
path_type="Prefix",
|
||||
path_type=path_type,
|
||||
)
|
||||
]
|
||||
),
|
||||
|
||||
@@ -13,7 +13,7 @@ def migrate_sessions(apps, schema_editor):
|
||||
for token in ConnectionToken.objects.using(db_alias).all():
|
||||
token.session = (
|
||||
AuthenticatedSession.objects.using(db_alias)
|
||||
.filter(session_key=token.old_session.session_key)
|
||||
.filter(session__session_key=token.old_session.session_key)
|
||||
.first()
|
||||
)
|
||||
if token.session:
|
||||
|
||||
@@ -27,3 +27,8 @@ class SCIMRequestException(TransientSyncException):
|
||||
except ValidationError:
|
||||
pass
|
||||
return self._message
|
||||
|
||||
def __str__(self):
|
||||
if self._response:
|
||||
return self._response.text
|
||||
return super().__str__()
|
||||
|
||||
@@ -72,7 +72,8 @@ class SCIMUserClient(SCIMClient[User, SCIMProviderUser, SCIMUserSchema]):
|
||||
if not self._config.filter.supported:
|
||||
raise exc
|
||||
users = self._request(
|
||||
"GET", f"/Users?{urlencode({'filter': f'userName eq {scim_user.userName}'})}"
|
||||
"GET",
|
||||
f"/Users?{urlencode({'filter': f'userName eq \"{scim_user.userName}\"'})}",
|
||||
)
|
||||
users_res = users.get("Resources", [])
|
||||
if len(users_res) < 1:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""common RBAC serializers"""
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.db.transaction import atomic
|
||||
from django_filters.filters import CharFilter, ChoiceFilter
|
||||
@@ -17,7 +18,7 @@ from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.core.api.groups import GroupMemberSerializer
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import User, UserTypes
|
||||
from authentik.core.models import Group, User, UserTypes
|
||||
from authentik.policies.event_matcher.models import model_choices
|
||||
from authentik.rbac.api.rbac import PermissionAssignResultSerializer, PermissionAssignSerializer
|
||||
from authentik.rbac.decorators import permission_required
|
||||
@@ -54,26 +55,56 @@ class UserAssignedPermissionFilter(FilterSet):
|
||||
model = ChoiceFilter(choices=model_choices(), method="filter_model", required=True)
|
||||
object_pk = CharFilter(method="filter_object_pk")
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
queryset = super().filter_queryset(queryset)
|
||||
data = self.form.cleaned_data
|
||||
model: str = data["model"]
|
||||
object_pk: str | None = data.get("object_pk", None)
|
||||
app, _, model = model.partition(".")
|
||||
|
||||
superuser_pks = (
|
||||
Group.objects.filter(is_superuser=True).values_list("users", flat=True).distinct()
|
||||
)
|
||||
|
||||
permissions = Permission.objects.filter(
|
||||
content_type__app_label=app,
|
||||
content_type__model=model,
|
||||
)
|
||||
|
||||
user_pks_with_model_permission = (
|
||||
permissions.order_by().values_list("user", flat=True).distinct()
|
||||
)
|
||||
user_pks_with_object_permission = []
|
||||
if object_pk:
|
||||
user_pks_with_object_permission = (
|
||||
UserObjectPermission.objects.filter(
|
||||
permission__in=permissions,
|
||||
object_pk=object_pk,
|
||||
)
|
||||
.order_by()
|
||||
.values_list("user", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return queryset.filter(
|
||||
Q(pk__in=superuser_pks)
|
||||
| Q(pk__in=user_pks_with_model_permission)
|
||||
| Q(pk__in=user_pks_with_object_permission)
|
||||
)
|
||||
|
||||
def filter_model(self, queryset: QuerySet, name, value: str) -> QuerySet:
|
||||
"""Filter by object type"""
|
||||
app, _, model = value.partition(".")
|
||||
return queryset.filter(
|
||||
Q(
|
||||
user_permissions__content_type__app_label=app,
|
||||
user_permissions__content_type__model=model,
|
||||
)
|
||||
| Q(
|
||||
userobjectpermission__permission__content_type__app_label=app,
|
||||
userobjectpermission__permission__content_type__model=model,
|
||||
)
|
||||
| Q(ak_groups__is_superuser=True)
|
||||
).distinct()
|
||||
# Actual filtering is handled by the above method where both `model` and `object_pk` are
|
||||
# available. Don't do anything here, this method is only left here to avoid overriding too
|
||||
# much of filter_queryset.
|
||||
return queryset
|
||||
|
||||
def filter_object_pk(self, queryset: QuerySet, name, value: str) -> QuerySet:
|
||||
"""Filter by object primary key"""
|
||||
return queryset.filter(
|
||||
Q(userobjectpermission__object_pk=value) | Q(ak_groups__is_superuser=True),
|
||||
).distinct()
|
||||
# Actual filtering is handled by the above method where both `model` and `object_pk` are
|
||||
# available. Don't do anything here, this method is only left here to avoid overriding too
|
||||
# much of filter_queryset.
|
||||
return queryset
|
||||
|
||||
|
||||
class UserAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
|
||||
@@ -83,7 +114,7 @@ class UserAssignedPermissionViewSet(ListModelMixin, GenericViewSet):
|
||||
ordering = ["username"]
|
||||
# The filtering is done in the filterset,
|
||||
# which has a required filter that does the heavy lifting
|
||||
queryset = User.objects.all()
|
||||
queryset = User.objects.all().prefetch_related("userobjectpermission_set")
|
||||
filterset_class = UserAssignedPermissionFilter
|
||||
|
||||
@permission_required("authentik_core.assign_user_permissions")
|
||||
|
||||
24
authentik/rbac/migrations/0006_alter_role_options.py
Normal file
24
authentik/rbac/migrations/0006_alter_role_options.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.11 on 2025-08-29 14:42
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_rbac", "0005_initialpermissions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="role",
|
||||
options={
|
||||
"permissions": [
|
||||
("assign_role_permissions", "Can assign permissions to roles"),
|
||||
("unassign_role_permissions", "Can unassign permissions from roles"),
|
||||
],
|
||||
"verbose_name": "Role",
|
||||
"verbose_name_plural": "Roles",
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -71,8 +71,8 @@ class Role(SerializerModel):
|
||||
verbose_name = _("Role")
|
||||
verbose_name_plural = _("Roles")
|
||||
permissions = [
|
||||
("assign_role_permissions", _("Can assign permissions to users")),
|
||||
("unassign_role_permissions", _("Can unassign permissions from users")),
|
||||
("assign_role_permissions", _("Can assign permissions to roles")),
|
||||
("unassign_role_permissions", _("Can unassign permissions from roles")),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import importlib
|
||||
from collections import OrderedDict
|
||||
from hashlib import sha512
|
||||
from pathlib import Path
|
||||
from tempfile import gettempdir
|
||||
|
||||
import orjson
|
||||
from sentry_sdk import set_tag
|
||||
@@ -368,6 +367,9 @@ DRAMATIQ = {
|
||||
"broker_class": "authentik.tasks.broker.Broker",
|
||||
"channel_prefix": "authentik",
|
||||
"task_model": "authentik.tasks.models.Task",
|
||||
"lock_purge_interval": timedelta_from_string(
|
||||
CONFIG.get("worker.lock_purge_interval")
|
||||
).total_seconds(),
|
||||
"task_purge_interval": timedelta_from_string(
|
||||
CONFIG.get("worker.task_purge_interval")
|
||||
).total_seconds(),
|
||||
@@ -410,7 +412,10 @@ DRAMATIQ = {
|
||||
("dramatiq.middleware.pipelines.Pipelines", {}),
|
||||
(
|
||||
"dramatiq.middleware.retries.Retries",
|
||||
{"max_retries": CONFIG.get_int("worker.task_max_retries") if not TEST else 0},
|
||||
{
|
||||
"max_retries": CONFIG.get_int("worker.task_max_retries") if not TEST else 0,
|
||||
"max_backoff": 60 * 60 * 1000, # 1 hour
|
||||
},
|
||||
),
|
||||
("dramatiq.results.middleware.Results", {"store_results": True}),
|
||||
("django_dramatiq_postgres.middleware.CurrentTask", {}),
|
||||
@@ -424,7 +429,6 @@ DRAMATIQ = {
|
||||
(
|
||||
"authentik.tasks.middleware.MetricsMiddleware",
|
||||
{
|
||||
"multiproc_dir": str(Path(gettempdir()) / "authentik_prometheus_tmp"),
|
||||
"prefix": "authentik",
|
||||
},
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
from django.db.models import Q
|
||||
from ldap3 import SUBTREE
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.sources.ldap.models import LDAP_DISTINGUISHED_NAME, LDAP_UNIQUENESS, LDAPSource
|
||||
@@ -52,7 +53,8 @@ class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
|
||||
for group in page_data:
|
||||
if self._source.lookup_groups_from_user:
|
||||
group_dn = group.get("dn", {})
|
||||
group_filter = f"({self._source.group_membership_field}={group_dn})"
|
||||
escaped_dn = escape_filter_chars(group_dn)
|
||||
group_filter = f"({self._source.group_membership_field}={escaped_dn})"
|
||||
group_members = self._source.connection().extend.standard.paged_search(
|
||||
search_base=self.base_dn_users,
|
||||
search_filter=group_filter,
|
||||
|
||||
@@ -4,6 +4,8 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.db.models import Q
|
||||
from django.test import TestCase
|
||||
from ldap3.core.exceptions import LDAPInvalidFilterError
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.core.models import Group, User
|
||||
@@ -519,3 +521,89 @@ class LDAPSyncTests(TestCase):
|
||||
|
||||
self.assertFalse(User.objects.filter(username__startswith="not-in-the-source").exists())
|
||||
self.assertFalse(Group.objects.filter(name__startswith="not-in-the-source").exists())
|
||||
|
||||
def test_membership_sync_special_chars_in_group_dn(self):
|
||||
"""Test membership synchronization with special characters in group DN"""
|
||||
self.source.object_uniqueness_field = "uid"
|
||||
self.source.group_object_filter = "(objectClass=groupOfNames)"
|
||||
self.source.lookup_groups_from_user = True
|
||||
self.source.group_membership_field = "memberOf"
|
||||
|
||||
# Mock connection with group DN containing special characters
|
||||
mock_conn = MagicMock()
|
||||
|
||||
# Simulate group with special characters in DN: parentheses, backslashes, asterisks
|
||||
special_group_dn = "cn=test(group),ou=groups,dc=example,dc=com"
|
||||
backslash_group_dn = "cn=test\\group,ou=groups,dc=example,dc=com"
|
||||
asterisk_group_dn = "cn=test*group,ou=groups,dc=example,dc=com"
|
||||
|
||||
# Mock the paged_search method that would be called with the filter
|
||||
mock_standard = MagicMock()
|
||||
mock_conn.extend.standard = mock_standard
|
||||
|
||||
# Test case 1: Group DN with parentheses
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", return_value=mock_conn):
|
||||
membership_sync = MembershipLDAPSynchronizer(self.source, Task())
|
||||
|
||||
# Simulate group data with special characters in DN
|
||||
page_data = [{"dn": special_group_dn}]
|
||||
|
||||
# This should not raise LDAPInvalidFilterError anymore
|
||||
try:
|
||||
membership_sync.sync(page_data)
|
||||
# Verify that the filter was properly escaped
|
||||
# The call should have been made with escaped characters
|
||||
mock_standard.paged_search.assert_called()
|
||||
call_args = mock_standard.paged_search.call_args
|
||||
search_filter = call_args[1]["search_filter"]
|
||||
# The parentheses should be escaped as \28 and \29
|
||||
self.assertIn("\\28", search_filter) # Escaped (
|
||||
self.assertIn("\\29", search_filter) # Escaped )
|
||||
except LDAPInvalidFilterError:
|
||||
self.fail("LDAPInvalidFilterError should not be raised with escaped filter")
|
||||
|
||||
# Test case 2: Group DN with backslashes
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", return_value=mock_conn):
|
||||
membership_sync = MembershipLDAPSynchronizer(self.source, Task())
|
||||
page_data = [{"dn": backslash_group_dn}]
|
||||
|
||||
try:
|
||||
membership_sync.sync(page_data)
|
||||
call_args = mock_standard.paged_search.call_args
|
||||
search_filter = call_args[1]["search_filter"]
|
||||
# The backslash should be escaped as \5c
|
||||
self.assertIn("\\5c", search_filter) # Escaped \
|
||||
except LDAPInvalidFilterError:
|
||||
self.fail("LDAPInvalidFilterError should not be raised with escaped filter")
|
||||
|
||||
# Test case 3: Group DN with asterisks
|
||||
with patch("authentik.sources.ldap.models.LDAPSource.connection", return_value=mock_conn):
|
||||
membership_sync = MembershipLDAPSynchronizer(self.source, Task())
|
||||
page_data = [{"dn": asterisk_group_dn}]
|
||||
|
||||
try:
|
||||
membership_sync.sync(page_data)
|
||||
call_args = mock_standard.paged_search.call_args
|
||||
search_filter = call_args[1]["search_filter"]
|
||||
# The asterisk should be escaped as \2a
|
||||
self.assertIn("\\2a", search_filter) # Escaped *
|
||||
except LDAPInvalidFilterError:
|
||||
self.fail("LDAPInvalidFilterError should not be raised with escaped filter")
|
||||
|
||||
def test_escape_filter_chars_function(self):
|
||||
"""Test the escape_filter_chars function directly"""
|
||||
|
||||
# Test various special characters that need escaping
|
||||
test_cases = [
|
||||
("test(group)", "test\\28group\\29"), # parentheses
|
||||
("test\\group", "test\\5cgroup"), # backslash
|
||||
("test*group", "test\\2agroup"), # asterisk
|
||||
("test(*)group", "test\\28\\2a\\29group"), # multiple special chars
|
||||
("normalgroup", "normalgroup"), # no special chars
|
||||
("", ""), # empty string
|
||||
]
|
||||
|
||||
for input_str, expected in test_cases:
|
||||
with self.subTest(input_str=input_str):
|
||||
result = escape_filter_chars(input_str)
|
||||
self.assertEqual(result, expected)
|
||||
|
||||
@@ -96,7 +96,11 @@ class EntraIDType(SourceType):
|
||||
}
|
||||
|
||||
def get_base_group_properties(self, source, group_id, **kwargs):
|
||||
raw_group = kwargs["info"]["raw_groups"][group_id]
|
||||
raw_groups = kwargs["info"]["raw_groups"]
|
||||
if group_id in raw_groups:
|
||||
name = raw_groups[group_id]["displayName"]
|
||||
else:
|
||||
name = group_id
|
||||
return {
|
||||
"name": raw_group["displayName"],
|
||||
"name": name,
|
||||
}
|
||||
|
||||
@@ -15,18 +15,10 @@ from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.stages.authenticator.models import SideChannelDevice
|
||||
from authentik.stages.email.models import EmailTemplates
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
|
||||
class EmailTemplates(models.TextChoices):
|
||||
"""Templates used for rendering the Email"""
|
||||
|
||||
EMAIL_OTP = (
|
||||
"email/email_otp.html",
|
||||
_("Email OTP"),
|
||||
) # nosec
|
||||
|
||||
|
||||
class AuthenticatorEmailStage(ConfigurableStage, FriendlyNamedStage, Stage):
|
||||
"""Use Email-based authentication instead of authenticator-based."""
|
||||
|
||||
|
||||
@@ -142,6 +142,11 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
user = self.get_pending_user()
|
||||
|
||||
stage: AuthenticatorEmailStage = self.executor.current_stage
|
||||
# For the moment we only allow one email device per user
|
||||
if EmailDevice.objects.filter(Q(user=user), stage=stage.pk).exists():
|
||||
return self.executor.stage_invalid(
|
||||
_("The user already has an email address registered for MFA.")
|
||||
)
|
||||
if SESSION_KEY_EMAIL_DEVICE not in self.request.session:
|
||||
device = EmailDevice(user=user, confirmed=False, stage=stage, name="Email Device")
|
||||
valid_secs: int = timedelta_from_string(stage.token_expiry).total_seconds()
|
||||
|
||||
@@ -108,6 +108,17 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
)
|
||||
def test_stage_submit(self):
|
||||
"""Test stage email submission"""
|
||||
# test fail because of existing device
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
self.user,
|
||||
component="ak-stage-access-denied",
|
||||
)
|
||||
self.device.delete()
|
||||
# Initialize the flow
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
@@ -232,6 +243,7 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
def test_challenge_generation(self):
|
||||
"""Test challenge generation"""
|
||||
# Test with masked email
|
||||
self.device.delete()
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -35,7 +35,12 @@ class Command(TenantCommand):
|
||||
template_context={},
|
||||
)
|
||||
try:
|
||||
send_mail(message.__dict__, stage.pk)
|
||||
if not stage.use_global_settings:
|
||||
message.from_email = stage.from_address
|
||||
|
||||
send_mail.send(message.__dict__, stage.pk).get_result(block=True)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Test email sent to {options['to']}"))
|
||||
finally:
|
||||
if delete_stage:
|
||||
stage.delete()
|
||||
|
||||
@@ -32,6 +32,14 @@ class EmailTemplates(models.TextChoices):
|
||||
"email/account_confirmation.html",
|
||||
_("Account Confirmation"),
|
||||
)
|
||||
EMAIL_OTP = (
|
||||
"email/email_otp.html",
|
||||
_("Email OTP"),
|
||||
) # nosec
|
||||
EVENT_NOTIFICATION = (
|
||||
"email/event_notification.html",
|
||||
_("Event Notification"),
|
||||
)
|
||||
|
||||
|
||||
def get_template_choices():
|
||||
|
||||
66
authentik/stages/email/tests/test_management_commands.py
Normal file
66
authentik/stages/email/tests/test_management_commands.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Test email management commands"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core import mail
|
||||
from django.core.mail.backends.locmem import EmailBackend
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.stages.email.models import EmailStage
|
||||
|
||||
|
||||
class TestEmailManagementCommands(TestCase):
|
||||
"""Test email management commands"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = create_test_admin_user()
|
||||
|
||||
def test_test_email_command_with_stage(self):
|
||||
"""Test test_email command with specified stage"""
|
||||
EmailStage.objects.create(
|
||||
name="test-stage",
|
||||
from_address="test@authentik.local",
|
||||
host="localhost",
|
||||
port=25,
|
||||
)
|
||||
|
||||
with patch("authentik.stages.email.models.EmailStage.backend_class", EmailBackend):
|
||||
call_command("test_email", "test@example.com", stage="test-stage")
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].subject, "authentik Test-Email")
|
||||
self.assertEqual(mail.outbox[0].to, ["test@example.com"])
|
||||
|
||||
def test_test_email_command_with_global_settings(self):
|
||||
"""Test test_email command with global settings"""
|
||||
# Mock the backend to use Django's locmem backend
|
||||
with patch("authentik.stages.email.models.EmailStage.backend_class", EmailBackend):
|
||||
call_command("test_email", "test@example.com")
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].subject, "authentik Test-Email")
|
||||
self.assertEqual(mail.outbox[0].to, ["test@example.com"])
|
||||
|
||||
def test_test_email_command_invalid_stage(self):
|
||||
"""Test test_email command with invalid stage"""
|
||||
call_command("test_email", "test@example.com", stage="nonexistent")
|
||||
|
||||
self.assertEqual(len(mail.outbox), 0)
|
||||
|
||||
def test_test_email_command_with_custom_from(self):
|
||||
"""Test test_email command respects custom from address"""
|
||||
EmailStage.objects.create(
|
||||
name="test-stage",
|
||||
from_address="custom@authentik.local",
|
||||
host="localhost",
|
||||
port=25,
|
||||
)
|
||||
|
||||
with patch("authentik.stages.email.models.EmailStage.backend_class", EmailBackend):
|
||||
call_command("test_email", "test@example.com", stage="test-stage")
|
||||
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertEqual(mail.outbox[0].from_email, "custom@authentik.local")
|
||||
self.assertEqual(mail.outbox[0].to, ["test@example.com"])
|
||||
@@ -54,7 +54,7 @@ class TestEmailStageTemplates(FlowTestCase):
|
||||
chmod(file2, 0o000) # Remove all permissions so we can't read the file
|
||||
choices = get_template_choices()
|
||||
self.assertEqual(choices[-1][0], Path(file).name)
|
||||
self.assertEqual(len(choices), 3)
|
||||
self.assertEqual(len(choices), 5)
|
||||
unlink(file)
|
||||
unlink(file2)
|
||||
|
||||
|
||||
@@ -148,7 +148,10 @@ class IdentificationChallengeResponse(ChallengeResponse):
|
||||
captcha_token = attrs.get("captcha_token", None)
|
||||
if not captcha_token:
|
||||
self.stage.logger.warning("Token not set for captcha attempt")
|
||||
verify_captcha_token(captcha_stage, captcha_token, client_ip)
|
||||
try:
|
||||
verify_captcha_token(captcha_stage, captcha_token, client_ip)
|
||||
except ValidationError:
|
||||
raise ValidationError(_("Failed to authenticate.")) from None
|
||||
|
||||
# Password check
|
||||
if not current_stage.password_stage:
|
||||
|
||||
@@ -194,7 +194,7 @@ class TestIdentificationStage(FlowTestCase):
|
||||
password_fields=False,
|
||||
primary_action="Log in",
|
||||
response_errors={
|
||||
"non_field_errors": [{"code": "invalid", "string": "Invalid captcha response"}]
|
||||
"non_field_errors": [{"code": "invalid", "string": "Failed to authenticate."}]
|
||||
},
|
||||
sources=[
|
||||
{
|
||||
@@ -247,7 +247,7 @@ class TestIdentificationStage(FlowTestCase):
|
||||
"non_field_errors": [
|
||||
{
|
||||
"code": "invalid",
|
||||
"string": "Invalid captcha response. Retrying may solve this issue.",
|
||||
"string": "Failed to authenticate.",
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
from authentik.tasks.schedules.common import ScheduleSpec
|
||||
|
||||
PRIORITY_HIGH = 1000
|
||||
|
||||
|
||||
class AuthentikTasksConfig(ManagedAppConfig):
|
||||
name = "authentik.tasks"
|
||||
|
||||
@@ -11,7 +11,7 @@ def worker_healthcheck():
|
||||
import authentik.tasks.setup # noqa
|
||||
from authentik.tasks.middleware import WorkerHealthcheckMiddleware
|
||||
|
||||
host, _, port = CONFIG.get("listen.listen_http").rpartition(":")
|
||||
host, _, port = CONFIG.get("listen.http").rpartition(":")
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
@@ -33,7 +33,7 @@ def worker_metrics():
|
||||
import authentik.tasks.setup # noqa
|
||||
from authentik.tasks.middleware import MetricsMiddleware
|
||||
|
||||
addr, _, port = CONFIG.get("listen.listen_metrics").rpartition(":")
|
||||
addr, _, port = CONFIG.get("listen.metrics").rpartition(":")
|
||||
|
||||
try:
|
||||
port = int(port)
|
||||
|
||||
@@ -14,18 +14,21 @@ from django_redis import get_redis_connection
|
||||
from dramatiq.broker import Broker
|
||||
from dramatiq.message import Message
|
||||
from dramatiq.middleware import Middleware
|
||||
from psycopg.errors import Error
|
||||
from redis.exceptions import RedisError
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import authentik_full_version
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.sentry import should_ignore_exception
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.tasks.models import Task, TaskStatus, WorkerStatus
|
||||
from authentik.tenants.models import Tenant
|
||||
from authentik.tenants.utils import get_current_tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
HEALTHCHECK_LOGGER = get_logger("authentik.worker").bind()
|
||||
DB_ERRORS = (OperationalError, Error, RedisError)
|
||||
|
||||
|
||||
class TenantMiddleware(Middleware):
|
||||
@@ -59,7 +62,7 @@ class MessagesMiddleware(Middleware):
|
||||
if task_created:
|
||||
task._messages.append(
|
||||
Task._make_message(
|
||||
str(type(self)),
|
||||
class_to_path(type(self)),
|
||||
TaskStatus.INFO,
|
||||
"Task has been queued",
|
||||
delay=delay,
|
||||
@@ -69,7 +72,7 @@ class MessagesMiddleware(Middleware):
|
||||
task._previous_messages.extend(task._messages)
|
||||
task._messages = [
|
||||
Task._make_message(
|
||||
str(type(self)),
|
||||
class_to_path(type(self)),
|
||||
TaskStatus.INFO,
|
||||
"Task will be retried",
|
||||
delay=delay,
|
||||
@@ -79,7 +82,7 @@ class MessagesMiddleware(Middleware):
|
||||
|
||||
def before_process_message(self, broker: Broker, message: Message):
|
||||
task: Task = message.options["task"]
|
||||
task.log(str(type(self)), TaskStatus.INFO, "Task is being processed")
|
||||
task.log(class_to_path(type(self)), TaskStatus.INFO, "Task is being processed")
|
||||
|
||||
def after_process_message(
|
||||
self,
|
||||
@@ -91,24 +94,33 @@ class MessagesMiddleware(Middleware):
|
||||
):
|
||||
task: Task = message.options["task"]
|
||||
if exception is None:
|
||||
task.log(str(type(self)), TaskStatus.INFO, "Task finished processing without errors")
|
||||
task.log(
|
||||
class_to_path(type(self)),
|
||||
TaskStatus.INFO,
|
||||
"Task finished processing without errors",
|
||||
)
|
||||
return
|
||||
if should_ignore_exception(exception):
|
||||
return
|
||||
task.log(
|
||||
str(type(self)),
|
||||
class_to_path(type(self)),
|
||||
TaskStatus.ERROR,
|
||||
exception,
|
||||
)
|
||||
event_kwargs = {
|
||||
"actor": task.actor_name,
|
||||
}
|
||||
if task.rel_obj:
|
||||
event_kwargs["rel_obj"] = task.rel_obj
|
||||
Event.new(
|
||||
EventAction.SYSTEM_TASK_EXCEPTION,
|
||||
message=f"Task {task.actor_name} encountered an error",
|
||||
actor=task.actor_name,
|
||||
**event_kwargs,
|
||||
).with_exception(exception).save()
|
||||
|
||||
def after_skip_message(self, broker: Broker, message: Message):
|
||||
task: Task = message.options["task"]
|
||||
task.log(str(type(self)), TaskStatus.INFO, "Task has been skipped")
|
||||
task.log(class_to_path(type(self)), TaskStatus.INFO, "Task has been skipped")
|
||||
|
||||
|
||||
class LoggingMiddleware(Middleware):
|
||||
@@ -151,7 +163,6 @@ class DescriptionMiddleware(Middleware):
|
||||
|
||||
|
||||
class _healthcheck_handler(BaseHTTPRequestHandler):
|
||||
|
||||
def log_request(self, code="-", size="-"):
|
||||
HEALTHCHECK_LOGGER.info(
|
||||
self.path,
|
||||
@@ -171,7 +182,7 @@ class _healthcheck_handler(BaseHTTPRequestHandler):
|
||||
redis_conn = get_redis_connection()
|
||||
redis_conn.ping()
|
||||
self.send_response(200)
|
||||
except (OperationalError, RedisError): # pragma: no cover
|
||||
except DB_ERRORS: # pragma: no cover
|
||||
self.send_response(503)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", "0")
|
||||
@@ -212,6 +223,14 @@ class WorkerStatusMiddleware(Middleware):
|
||||
hostname=socket.gethostname(),
|
||||
version=authentik_full_version(),
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
WorkerStatusMiddleware.keep(status)
|
||||
except DB_ERRORS: # pragma: no cover
|
||||
sleep(10)
|
||||
pass
|
||||
|
||||
def keep(status: WorkerStatus):
|
||||
lock_id = f"goauthentik.io/worker/status/{status.pk}"
|
||||
with pglock.advisory(lock_id, side_effect=pglock.Raise):
|
||||
while True:
|
||||
|
||||
@@ -107,7 +107,6 @@ class ScheduleViewSet(
|
||||
"rel_obj_content_type__app_label",
|
||||
"rel_obj_content_type__model",
|
||||
"rel_obj_id",
|
||||
"description",
|
||||
)
|
||||
filterset_class = ScheduleFilter
|
||||
ordering = (
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2025.8.0-rc1 Blueprint schema",
|
||||
"title": "authentik 2025.8.4 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
@@ -6753,6 +6753,15 @@
|
||||
"title": "Webhook mapping headers",
|
||||
"description": "Configure additional headers to be sent. Mapping should return a dictionary of key-value pairs"
|
||||
},
|
||||
"email_subject_prefix": {
|
||||
"type": "string",
|
||||
"title": "Email subject prefix"
|
||||
},
|
||||
"email_template": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"title": "Email template"
|
||||
},
|
||||
"send_once": {
|
||||
"type": "boolean",
|
||||
"title": "Send once",
|
||||
|
||||
@@ -5,7 +5,7 @@ metadata:
|
||||
blueprints.goauthentik.io/system-bootstrap: "true"
|
||||
blueprints.goauthentik.io/system: "true"
|
||||
blueprints.goauthentik.io/description: |
|
||||
This blueprint configures the default admin user and group, and configures them for the [Automated install](https://goauthentik.io/docs/installation/automated-install).
|
||||
This blueprint configures the default admin user and group, and configures them for the [Automated install](https://docs.goauthentik.io/docs/install-config/automated-install?utm_source=bootstrap_blueprint).
|
||||
context:
|
||||
username: akadmin
|
||||
group_name: authentik Admins
|
||||
|
||||
@@ -133,6 +133,6 @@ func checkWorker() int {
|
||||
}
|
||||
}
|
||||
|
||||
log.Debug("successfully checked health")
|
||||
log.Info("successfully checked health")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_REDIS__HOST: redis
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.8.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.8.4}
|
||||
ports:
|
||||
- ${COMPOSE_PORT_HTTP:-9000}:9000
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_REDIS__HOST: redis
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.8.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.8.4}
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
volumes:
|
||||
|
||||
@@ -37,13 +37,13 @@ type RedisConfig struct {
|
||||
}
|
||||
|
||||
type ListenConfig struct {
|
||||
HTTP string `yaml:"listen_http" env:"HTTP, overwrite"`
|
||||
HTTPS string `yaml:"listen_https" env:"HTTPS, overwrite"`
|
||||
LDAP string `yaml:"listen_ldap" env:"LDAP, overwrite"`
|
||||
LDAPS string `yaml:"listen_ldaps" env:"LDAPS, overwrite"`
|
||||
Radius string `yaml:"listen_radius" env:"RADIUS, overwrite"`
|
||||
Metrics string `yaml:"listen_metrics" env:"METRICS, overwrite"`
|
||||
Debug string `yaml:"listen_debug" env:"DEBUG, overwrite"`
|
||||
HTTP string `yaml:"http" env:"HTTP, overwrite"`
|
||||
HTTPS string `yaml:"https" env:"HTTPS, overwrite"`
|
||||
LDAP string `yaml:"ldap" env:"LDAP, overwrite"`
|
||||
LDAPS string `yaml:"ldaps" env:"LDAPS, overwrite"`
|
||||
Radius string `yaml:"radius" env:"RADIUS, overwrite"`
|
||||
Metrics string `yaml:"metrics" env:"METRICS, overwrite"`
|
||||
Debug string `yaml:"debug" env:"DEBUG, overwrite"`
|
||||
TrustedProxyCIDRs []string `yaml:"trusted_proxy_cidrs" env:"TRUSTED_PROXY_CIDRS, overwrite"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
2025.8.0-rc1
|
||||
2025.8.4
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"beryju.io/ldap"
|
||||
|
||||
@@ -50,10 +51,13 @@ func (pi *ProviderInstance) UserEntry(u api.User) *ldap.Entry {
|
||||
constants.OCPosixAccount,
|
||||
constants.OCAKUser,
|
||||
},
|
||||
"uidNumber": {pi.GetUserUidNumber(u)},
|
||||
"gidNumber": {pi.GetUserGidNumber(u)},
|
||||
"homeDirectory": {fmt.Sprintf("/home/%s", u.Username)},
|
||||
"sn": {u.Name},
|
||||
"uidNumber": {pi.GetUserUidNumber(u)},
|
||||
"gidNumber": {pi.GetUserGidNumber(u)},
|
||||
"homeDirectory": {fmt.Sprintf("/home/%s", u.Username)},
|
||||
"sn": {u.Name},
|
||||
"pwdChangedTime": {u.PasswordChangeDate.In(time.UTC).Format("20060102150405Z")},
|
||||
"createTimestamp": {u.DateJoined.In(time.UTC).Format("20060102150405Z")},
|
||||
"modifyTimestamp": {u.LastUpdated.In(time.UTC).Format("20060102150405Z")},
|
||||
})
|
||||
return &ldap.Entry{DN: dn, Attributes: attrs}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ func (ds *DirectSearcher) SearchSubschema(req *search.Request) (ldap.ServerSearc
|
||||
"( 1.2.840.113556.1.4.44 NAME 'homeDirectory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.4.750 NAME 'groupType' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
|
||||
"( 1.2.840.113556.1.4.782 NAME 'objectCategory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' SINGLE-VALUE )",
|
||||
"( 1.3.6.1.4.1.42.2.27.8.1.16 NAME 'pwdChangedTime' SYNTAX '1.3.6.1.4.1.1466.115.121.1.24' SINGLE-VALUE NO-USER-MODIFICATION )",
|
||||
"( 1.3.6.1.1.1.1.0 NAME 'uidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
|
||||
"( 1.3.6.1.1.1.1.1 NAME 'gidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
|
||||
"( 1.3.6.1.1.1.1.12 NAME 'memberUid' SYNTAX '1.3.6.1.4.1.1466.115.121.1.26' )",
|
||||
|
||||
@@ -57,7 +57,7 @@ func (ms *MemorySearcher) fetch() {
|
||||
Logger: ms.log,
|
||||
})
|
||||
ms.users = users
|
||||
groups, _ := ak.Paginator(ms.si.GetAPIClient().CoreApi.CoreGroupsList(context.TODO()).IncludeUsers(true), ak.PaginatorOptions{
|
||||
groups, _ := ak.Paginator(ms.si.GetAPIClient().CoreApi.CoreGroupsList(context.TODO()).IncludeUsers(true).IncludeChildren(true), ak.PaginatorOptions{
|
||||
PageSize: 100,
|
||||
Logger: ms.log,
|
||||
})
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
set -e -o pipefail
|
||||
MODE_FILE="${TMPDIR}/authentik-mode"
|
||||
|
||||
if [[ -z "${PROMETHEUS_MULTIPROC_DIR}" ]]; then
|
||||
export PROMETHEUS_MULTIPROC_DIR="${TMPDIR:-/tmp}/authentik_prometheus_tmp"
|
||||
fi
|
||||
|
||||
function log {
|
||||
printf '{"event": "%s", "level": "info", "logger": "bootstrap"}\n' "$@" >/dev/stderr
|
||||
}
|
||||
@@ -31,7 +35,7 @@ function check_if_root {
|
||||
GROUP="authentik:${GROUP_NAME}"
|
||||
fi
|
||||
# Fix permissions of certs and media
|
||||
chown -R authentik:authentik /media /certs
|
||||
chown -R authentik:authentik /media /certs "${PROMETHEUS_MULTIPROC_DIR}"
|
||||
chmod ug+rwx /media
|
||||
chmod ug+rx /certs
|
||||
exec chpst -u authentik:$GROUP env HOME=/authentik $1
|
||||
@@ -68,6 +72,8 @@ function prepare_debug {
|
||||
chown authentik:authentik /unittest.xml
|
||||
}
|
||||
|
||||
mkdir -p "${PROMETHEUS_MULTIPROC_DIR}"
|
||||
|
||||
if [[ "$(python -m authentik.lib.config debugger 2>/dev/null)" == "True" ]]; then
|
||||
prepare_debug
|
||||
fi
|
||||
|
||||
@@ -26,7 +26,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2025.8.0-rc1
|
||||
Default: 2025.8.4
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
||||
@@ -33,15 +33,12 @@ wait_for_db()
|
||||
_tmp = Path(gettempdir())
|
||||
worker_class = "lifecycle.worker.DjangoUvicornWorker"
|
||||
worker_tmp_dir = str(_tmp.joinpath("authentik_gunicorn_tmp"))
|
||||
prometheus_tmp_dir = str(_tmp.joinpath("authentik_prometheus_tmp"))
|
||||
|
||||
os.makedirs(worker_tmp_dir, exist_ok=True)
|
||||
os.makedirs(prometheus_tmp_dir, exist_ok=True)
|
||||
|
||||
bind = f"unix://{str(_tmp.joinpath('authentik-core.sock'))}"
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "authentik.root.settings")
|
||||
os.environ.setdefault("PROMETHEUS_MULTIPROC_DIR", prometheus_tmp_dir)
|
||||
|
||||
preload_app = True
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.4",
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -237,6 +237,9 @@ class _PostgresConsumer(Consumer):
|
||||
# Override because dramatiq doesn't allow us setting this manually
|
||||
self.timeout = Conf().worker["consumer_listen_timeout"]
|
||||
|
||||
self.lock_purge_interval = timezone.timedelta(seconds=Conf().lock_purge_interval)
|
||||
self.lock_purge_last_run = timezone.now()
|
||||
|
||||
self.task_purge_interval = timezone.timedelta(seconds=Conf().task_purge_interval)
|
||||
self.task_purge_last_run = timezone.now() - self.task_purge_interval
|
||||
|
||||
@@ -378,9 +381,13 @@ class _PostgresConsumer(Consumer):
|
||||
# Force creation of listen connection
|
||||
_ = self.listen_connection
|
||||
|
||||
self._purge_locks()
|
||||
|
||||
processing = len(self.in_processing)
|
||||
if processing >= self.prefetch:
|
||||
# Wait and don't consume the message, other worker will be faster
|
||||
# If we have too many messages already processing, wait and don't consume a message
|
||||
# straight away, other workers will be faster.
|
||||
# After waiting consume a message regardless.
|
||||
self.misses, backoff_ms = compute_backoff(self.misses, max_backoff=1000)
|
||||
self.logger.debug(
|
||||
"Too many messages in processing, Sleeping",
|
||||
@@ -388,7 +395,8 @@ class _PostgresConsumer(Consumer):
|
||||
backoff_ms=backoff_ms,
|
||||
)
|
||||
time.sleep(backoff_ms / 1000)
|
||||
return None
|
||||
else:
|
||||
self.misses = 0
|
||||
|
||||
if not self.notifies:
|
||||
self.notifies += self._poll_for_notify()
|
||||
@@ -415,24 +423,27 @@ class _PostgresConsumer(Consumer):
|
||||
)
|
||||
|
||||
# No message to process
|
||||
self._purge_locks()
|
||||
self._auto_purge()
|
||||
self._scheduler()
|
||||
|
||||
self.misses = 0
|
||||
return None
|
||||
|
||||
def _purge_locks(self):
|
||||
if timezone.now() - self.lock_purge_last_run < self.lock_purge_interval:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
message_id = self.unlock_queue.get(block=False)
|
||||
except Empty:
|
||||
return
|
||||
break
|
||||
self.logger.debug("Unlocking message", message_id=message_id)
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"SELECT pg_advisory_unlock(%s)", (self._get_message_lock_id(message_id),)
|
||||
)
|
||||
self.unlock_queue.task_done()
|
||||
self.lock_purge_last_run = timezone.now()
|
||||
|
||||
def _auto_purge(self):
|
||||
if timezone.now() - self.task_purge_last_run < self.task_purge_interval:
|
||||
@@ -444,6 +455,7 @@ class _PostgresConsumer(Consumer):
|
||||
result_expiry__lte=timezone.now(),
|
||||
).delete()
|
||||
self.logger.info("Purged messages in all queues", count=count)
|
||||
self.task_purge_last_run = timezone.now()
|
||||
|
||||
def _scheduler(self):
|
||||
if not self.scheduler:
|
||||
@@ -451,6 +463,7 @@ class _PostgresConsumer(Consumer):
|
||||
if timezone.now() - self.scheduler_last_run < self.scheduler_interval:
|
||||
return
|
||||
self.scheduler.run()
|
||||
self.schedule_last_run = timezone.now()
|
||||
|
||||
@raise_connection_error
|
||||
def close(self):
|
||||
@@ -465,4 +478,7 @@ class _PostgresConsumer(Consumer):
|
||||
if self._listen_connection is not None:
|
||||
conn = self._listen_connection
|
||||
self._listen_connection = None
|
||||
conn.close()
|
||||
try:
|
||||
conn.close()
|
||||
except DatabaseError:
|
||||
pass
|
||||
|
||||
@@ -56,6 +56,10 @@ class Conf:
|
||||
def task_model(self) -> str:
|
||||
return self.conf["task_model"]
|
||||
|
||||
@property
|
||||
def lock_purge_interval(self) -> int:
|
||||
return self.conf.get("lock_purge_interval", 60)
|
||||
|
||||
@property
|
||||
def task_purge_interval(self) -> int:
|
||||
# 24 hours
|
||||
|
||||
@@ -26,7 +26,7 @@ class HTTPServer(BaseHTTPServer):
|
||||
self.socket.close()
|
||||
|
||||
host, port = self.server_address[:2]
|
||||
if host == "0.0.0.0": # nosec
|
||||
if host == "0.0.0.0" and socket.has_dualstack_ipv6(): # nosec
|
||||
host = "::" # nosec
|
||||
|
||||
# Strip IPv6 brackets
|
||||
@@ -36,7 +36,9 @@ class HTTPServer(BaseHTTPServer):
|
||||
self.server_address = (host, port)
|
||||
|
||||
self.address_family = (
|
||||
socket.AF_INET6 if isinstance(ip_address(host), IPv6Address) else socket.AF_INET
|
||||
socket.AF_INET6
|
||||
if socket.has_dualstack_ipv6() and isinstance(ip_address(host), IPv6Address)
|
||||
else socket.AF_INET
|
||||
)
|
||||
|
||||
self.socket = socket.create_server(
|
||||
@@ -141,7 +143,6 @@ class MetricsMiddleware(Middleware):
|
||||
def __init__(
|
||||
self,
|
||||
prefix: str,
|
||||
multiproc_dir: str,
|
||||
labels: list[str] | None = None,
|
||||
):
|
||||
super().__init__()
|
||||
@@ -151,9 +152,6 @@ class MetricsMiddleware(Middleware):
|
||||
self.delayed_messages = set()
|
||||
self.message_start_times = {}
|
||||
|
||||
os.makedirs(multiproc_dir, exist_ok=True)
|
||||
os.environ.setdefault("PROMETHEUS_MULTIPROC_DIR", multiproc_dir)
|
||||
|
||||
@property
|
||||
def forks(self):
|
||||
from django_dramatiq_postgres.forks import worker_metrics
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "authentik"
|
||||
version = "2025.8.0-rc1"
|
||||
version = "2025.8.4"
|
||||
description = ""
|
||||
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
||||
requires-python = "==3.13.*"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"dacite==1.9.2",
|
||||
"deepmerge==2.0",
|
||||
"defusedxml==0.7.1",
|
||||
"django==5.1.11",
|
||||
"django==5.1.12",
|
||||
"django-countries==7.6.1",
|
||||
"django-cte==2.0.0",
|
||||
"django-dramatiq-postgres",
|
||||
|
||||
20
schema.yml
20
schema.yml
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: authentik
|
||||
version: 2025.8.0-rc1
|
||||
version: 2025.8.4
|
||||
description: Making authentication simple.
|
||||
contact:
|
||||
email: hello@goauthentik.io
|
||||
@@ -6057,7 +6057,8 @@ paths:
|
||||
/core/users/{id}/recovery_email/:
|
||||
post:
|
||||
operationId: core_users_recovery_email_create
|
||||
description: Send an email with a temporary link that a user can use to recover their account
|
||||
description: Send an email with a temporary link that a user can use to recover
|
||||
their account
|
||||
parameters:
|
||||
- in: query
|
||||
name: email_stage
|
||||
@@ -49344,6 +49345,10 @@ components:
|
||||
nullable: true
|
||||
description: Configure additional headers to be sent. Mapping should return
|
||||
a dictionary of key-value pairs
|
||||
email_subject_prefix:
|
||||
type: string
|
||||
email_template:
|
||||
type: string
|
||||
send_once:
|
||||
type: boolean
|
||||
description: Only send notification once, for example when sending a webhook
|
||||
@@ -49383,6 +49388,11 @@ components:
|
||||
nullable: true
|
||||
description: Configure additional headers to be sent. Mapping should return
|
||||
a dictionary of key-value pairs
|
||||
email_subject_prefix:
|
||||
type: string
|
||||
email_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
send_once:
|
||||
type: boolean
|
||||
description: Only send notification once, for example when sending a webhook
|
||||
@@ -54480,6 +54490,11 @@ components:
|
||||
nullable: true
|
||||
description: Configure additional headers to be sent. Mapping should return
|
||||
a dictionary of key-value pairs
|
||||
email_subject_prefix:
|
||||
type: string
|
||||
email_template:
|
||||
type: string
|
||||
minLength: 1
|
||||
send_once:
|
||||
type: boolean
|
||||
description: Only send notification once, for example when sending a webhook
|
||||
@@ -61864,6 +61879,7 @@ components:
|
||||
- object_pk
|
||||
UserPasswordSetRequest:
|
||||
type: object
|
||||
description: Payload to set a users' password directly
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
|
||||
@@ -241,6 +241,9 @@ class TestProviderLDAP(SeleniumTestCase):
|
||||
"homeDirectory": f"/home/{o_user.username}",
|
||||
"ak-active": True,
|
||||
"ak-superuser": False,
|
||||
"pwdChangedTime": o_user.password_change_date.replace(microsecond=0),
|
||||
"createTimestamp": o_user.date_joined.replace(microsecond=0),
|
||||
"modifyTimestamp": o_user.last_updated.replace(microsecond=0),
|
||||
},
|
||||
"type": "searchResEntry",
|
||||
},
|
||||
@@ -269,6 +272,9 @@ class TestProviderLDAP(SeleniumTestCase):
|
||||
"homeDirectory": f"/home/{embedded_account.username}",
|
||||
"ak-active": True,
|
||||
"ak-superuser": False,
|
||||
"pwdChangedTime": embedded_account.password_change_date.replace(microsecond=0),
|
||||
"createTimestamp": embedded_account.date_joined.replace(microsecond=0),
|
||||
"modifyTimestamp": embedded_account.last_updated.replace(microsecond=0),
|
||||
},
|
||||
"type": "searchResEntry",
|
||||
},
|
||||
@@ -301,6 +307,9 @@ class TestProviderLDAP(SeleniumTestCase):
|
||||
"ak-active": True,
|
||||
"ak-superuser": True,
|
||||
"extraAttribute": ["bar"],
|
||||
"pwdChangedTime": self.user.password_change_date.replace(microsecond=0),
|
||||
"createTimestamp": self.user.date_joined.replace(microsecond=0),
|
||||
"modifyTimestamp": self.user.last_updated.replace(microsecond=0),
|
||||
},
|
||||
"type": "searchResEntry",
|
||||
},
|
||||
@@ -378,6 +387,9 @@ class TestProviderLDAP(SeleniumTestCase):
|
||||
"homeDirectory": f"/home/{user.username}",
|
||||
"ak-active": True,
|
||||
"ak-superuser": False,
|
||||
"pwdChangedTime": user.password_change_date.replace(microsecond=0),
|
||||
"createTimestamp": user.date_joined.replace(microsecond=0),
|
||||
"modifyTimestamp": user.last_updated.replace(microsecond=0),
|
||||
},
|
||||
"type": "searchResEntry",
|
||||
},
|
||||
|
||||
18
uv.lock
generated
18
uv.lock
generated
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = "==3.13.*"
|
||||
|
||||
[manifest]
|
||||
@@ -159,7 +159,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authentik"
|
||||
version = "2025.8.0rc1"
|
||||
version = "2025.8.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "argon2-cffi" },
|
||||
@@ -266,7 +266,7 @@ requires-dist = [
|
||||
{ name = "dacite", specifier = "==1.9.2" },
|
||||
{ name = "deepmerge", specifier = "==2.0" },
|
||||
{ name = "defusedxml", specifier = "==0.7.1" },
|
||||
{ name = "django", specifier = "==5.1.11" },
|
||||
{ name = "django", specifier = "==5.1.12" },
|
||||
{ name = "django-countries", specifier = "==7.6.1" },
|
||||
{ name = "django-cte", specifier = "==2.0.0" },
|
||||
{ name = "django-dramatiq-postgres", editable = "packages/django-dramatiq-postgres" },
|
||||
@@ -899,16 +899,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.1.11"
|
||||
version = "5.1.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/80/bf0f9b0aa434fca2b46fc6a31c39b08ea714b87a0a72a16566f053fb05a8/django-5.1.11.tar.gz", hash = "sha256:3bcdbd40e4d4623b5e04f59c28834323f3086df583058e65ebce99f9982385ce", size = 10734926, upload-time = "2025-06-10T10:12:48.229Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/99/a951d93a27a5bc59fb96edbcdbc03fb9bfac51177f1bc0110888de85af3f/django-5.1.12.tar.gz", hash = "sha256:8a8991b1ec052ef6a44fefd1ef336ab8daa221287bcb91a4a17d5e1abec5bbcc", size = 10737777, upload-time = "2025-09-03T13:09:45.855Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/91/2972ce330c6c0bd5b3200d4c2ad5cbf47eecff5243220c5a56444d3267a0/django-5.1.11-py3-none-any.whl", hash = "sha256:e48091f364007068728aca938e7450fbfe3f2217079bfd2b8af45122585acf64", size = 8277453, upload-time = "2025-06-10T10:12:42.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/1c/a9520c8263e980b0b9933c9b5ce8f22c9ddf007b062e4eb428b557ff0932/django-5.1.12-py3-none-any.whl", hash = "sha256:9eb695636cea3601b65690f1596993c042206729afb320ca0960b55f8ed4477b", size = 8277454, upload-time = "2025-09-03T13:09:30.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1499,15 +1499,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "4.2.0"
|
||||
version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "hpack" },
|
||||
{ name = "hyperframe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/38/d7f80fd13e6582fb8e0df8c9a653dcc02b03ca34f4d72f34869298c5baf8/h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f", size = 2150682, upload-time = "2025-02-02T07:43:51.815Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/9e/984486f2d0a0bd2b024bf4bc1c62688fcafa9e61991f041fb0e2def4a982/h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0", size = 60957, upload-time = "2025-02-01T11:02:26.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
12
web/package-lock.json
generated
12
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.4",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"./packages/*"
|
||||
@@ -23,7 +23,7 @@
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@formatjs/intl-listformat": "^7.7.11",
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@goauthentik/api": "^2025.8.0-rc1-1755026430",
|
||||
"@goauthentik/api": "^2025.10.0-rc1-1755254677",
|
||||
"@goauthentik/core": "^1.0.0",
|
||||
"@goauthentik/esbuild-plugin-live-reload": "^1.1.0",
|
||||
"@goauthentik/eslint-config": "^1.0.5",
|
||||
@@ -1509,9 +1509,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@goauthentik/api": {
|
||||
"version": "2025.8.0-rc1-1755026430",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.8.0-rc1-1755026430.tgz",
|
||||
"integrity": "sha512-nkXlhnI8ILnpnSqseklRHRWsq9kBCT0CPU/7MotO3kQwl54Ze4j8HAMGNJNLr1VjuKuG5m/qt6SQseXLnazb+w=="
|
||||
"version": "2025.10.0-rc1-1755254677",
|
||||
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.10.0-rc1-1755254677.tgz",
|
||||
"integrity": "sha512-hq+xGPtwaeptEDn92Y40Yb4e7yL2KVvuqy2kWAZLPtr/r9ML82vzNYCfW6bFNPnopDRizjOBIzlD3gNP/2rs8Q=="
|
||||
},
|
||||
"node_modules/@goauthentik/core": {
|
||||
"resolved": "packages/core",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/web",
|
||||
"version": "2025.8.0-rc1",
|
||||
"version": "2025.8.4",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -94,7 +94,7 @@
|
||||
"@floating-ui/dom": "^1.7.3",
|
||||
"@formatjs/intl-listformat": "^7.7.11",
|
||||
"@fortawesome/fontawesome-free": "^7.0.0",
|
||||
"@goauthentik/api": "^2025.8.0-rc1-1755026430",
|
||||
"@goauthentik/api": "^2025.10.0-rc1-1755254677",
|
||||
"@goauthentik/core": "^1.0.0",
|
||||
"@goauthentik/esbuild-plugin-live-reload": "^1.1.0",
|
||||
"@goauthentik/eslint-config": "^1.0.5",
|
||||
|
||||
@@ -66,7 +66,7 @@ export class AdminOverviewPage extends AdminOverviewBase {
|
||||
quickActions: QuickAction[] = [
|
||||
[msg("Create a new application"), paramURL("/core/applications", { createWizard: true })],
|
||||
[msg("Check the logs"), paramURL("/events/log")],
|
||||
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
|
||||
[msg("Explore integrations"), "https://integrations.goauthentik.io/", true],
|
||||
[msg("Manage users"), paramURL("/identity/users")],
|
||||
[
|
||||
msg("Check the release notes"),
|
||||
|
||||
@@ -8,15 +8,19 @@ import "#elements/forms/Radio";
|
||||
import "#elements/forms/SearchSelect/index";
|
||||
import "#elements/utils/TimeDeltaHelp";
|
||||
import "./AdminSettingsFooterLinks.js";
|
||||
import "#elements/CodeMirror";
|
||||
|
||||
import { akFooterLinkInput, IFooterLinkInput } from "./AdminSettingsFooterLinks.js";
|
||||
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
import { CodeMirrorMode } from "#elements/CodeMirror";
|
||||
import { Form } from "#elements/forms/Form";
|
||||
|
||||
import { AdminApi, FooterLink, Settings, SettingsRequest } from "@goauthentik/api";
|
||||
|
||||
import YAML from "yaml";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
@@ -29,23 +33,7 @@ const DEFAULT_REPUTATION_UPPER_LIMIT = 5;
|
||||
|
||||
@customElement("ak-admin-settings-form")
|
||||
export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
//
|
||||
// Custom property accessors in Lit 2 require a manual call to requestUpdate(). See:
|
||||
// https://lit.dev/docs/v2/components/properties/#accessors-custom
|
||||
//
|
||||
set settings(value: Settings | undefined) {
|
||||
this._settings = value;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
get settings() {
|
||||
return this._settings;
|
||||
}
|
||||
|
||||
private _settings?: Settings;
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
public static styles: CSSResult[] = [
|
||||
...super.styles,
|
||||
PFList,
|
||||
css`
|
||||
@@ -55,25 +43,35 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
`,
|
||||
];
|
||||
|
||||
@property({ attribute: false })
|
||||
public settings!: Settings;
|
||||
|
||||
getSuccessMessage(): string {
|
||||
return msg("Successfully updated settings.");
|
||||
}
|
||||
|
||||
async send(data: SettingsRequest): Promise<Settings> {
|
||||
async send(settingsRequest: SettingsRequest): Promise<Settings> {
|
||||
settingsRequest.flags ??= this.settings.flags;
|
||||
|
||||
const result = await new AdminApi(DEFAULT_CONFIG).adminSettingsUpdate({
|
||||
settingsRequest: data,
|
||||
settingsRequest,
|
||||
});
|
||||
|
||||
this.dispatchEvent(new CustomEvent("ak-admin-setting-changed"));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
const { settings } = this;
|
||||
|
||||
return html`
|
||||
<ak-text-input
|
||||
name="avatars"
|
||||
label=${msg("Avatars")}
|
||||
value="${ifDefined(this._settings?.avatars)}"
|
||||
value="${ifDefined(settings.avatars)}"
|
||||
input-hint="code"
|
||||
required
|
||||
.bighelp=${html`
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
@@ -133,27 +131,26 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
)}
|
||||
</p>
|
||||
`}
|
||||
required
|
||||
>
|
||||
</ak-text-input>
|
||||
<ak-switch-input
|
||||
name="defaultUserChangeName"
|
||||
label=${msg("Allow users to change name")}
|
||||
?checked="${this._settings?.defaultUserChangeName}"
|
||||
?checked=${settings.defaultUserChangeName}
|
||||
help=${msg("Enable the ability for users to change their name.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
<ak-switch-input
|
||||
name="defaultUserChangeEmail"
|
||||
label=${msg("Allow users to change email")}
|
||||
?checked="${this._settings?.defaultUserChangeEmail}"
|
||||
?checked=${settings.defaultUserChangeEmail}
|
||||
help=${msg("Enable the ability for users to change their email.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
<ak-switch-input
|
||||
name="defaultUserChangeUsername"
|
||||
label=${msg("Allow users to change username")}
|
||||
?checked="${this._settings?.defaultUserChangeUsername}"
|
||||
?checked=${settings.defaultUserChangeUsername}
|
||||
help=${msg("Enable the ability for users to change their username.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
@@ -162,7 +159,7 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
label=${msg("Event retention")}
|
||||
input-hint="code"
|
||||
required
|
||||
value="${ifDefined(this._settings?.eventRetention)}"
|
||||
value="${ifDefined(settings.eventRetention)}"
|
||||
.bighelp=${html`<p class="pf-c-form__helper-text">
|
||||
${msg("Duration after which events will be deleted from the database.")}
|
||||
</p>
|
||||
@@ -184,19 +181,19 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
label=${msg("Reputation: lower limit")}
|
||||
required
|
||||
name="reputationLowerLimit"
|
||||
value="${this._settings?.reputationLowerLimit ?? DEFAULT_REPUTATION_LOWER_LIMIT}"
|
||||
value="${settings.reputationLowerLimit ?? DEFAULT_REPUTATION_LOWER_LIMIT}"
|
||||
help=${msg("Reputation cannot decrease lower than this value. Zero or negative.")}
|
||||
></ak-number-input>
|
||||
<ak-number-input
|
||||
label=${msg("Reputation: upper limit")}
|
||||
required
|
||||
name="reputationUpperLimit"
|
||||
value="${this._settings?.reputationUpperLimit ?? DEFAULT_REPUTATION_UPPER_LIMIT}"
|
||||
value="${settings.reputationUpperLimit ?? DEFAULT_REPUTATION_UPPER_LIMIT}"
|
||||
help=${msg("Reputation cannot increase higher than this value. Zero or positive.")}
|
||||
></ak-number-input>
|
||||
<ak-form-element-horizontal label=${msg("Footer links")} name="footerLinks">
|
||||
<ak-array-input
|
||||
.items=${this._settings?.footerLinks ?? []}
|
||||
.items=${settings.footerLinks ?? []}
|
||||
.newItem=${() => ({ name: "", href: "" })}
|
||||
.row=${(f?: FooterLink) =>
|
||||
akFooterLinkInput({
|
||||
@@ -215,7 +212,7 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
<ak-switch-input
|
||||
name="gdprCompliance"
|
||||
label=${msg("GDPR compliance")}
|
||||
?checked="${this._settings?.gdprCompliance}"
|
||||
?checked=${settings.gdprCompliance}
|
||||
help=${msg(
|
||||
"When enabled, all the events caused by a user will be deleted upon the user's deletion.",
|
||||
)}
|
||||
@@ -224,14 +221,14 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
<ak-switch-input
|
||||
name="impersonation"
|
||||
label=${msg("Impersonation")}
|
||||
?checked="${this._settings?.impersonation}"
|
||||
?checked=${settings.impersonation}
|
||||
help=${msg("Globally enable/disable impersonation.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
<ak-switch-input
|
||||
name="impersonationRequireReason"
|
||||
label=${msg("Require reason for impersonation")}
|
||||
?checked="${this._settings?.impersonationRequireReason}"
|
||||
?checked=${settings.impersonationRequireReason}
|
||||
help=${msg("Require administrators to provide a reason for impersonating a user.")}
|
||||
>
|
||||
</ak-switch-input>
|
||||
@@ -240,7 +237,7 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
label=${msg("Default token duration")}
|
||||
input-hint="code"
|
||||
required
|
||||
value="${ifDefined(this._settings?.defaultTokenDuration)}"
|
||||
value="${ifDefined(settings.defaultTokenDuration)}"
|
||||
.bighelp=${html`<p class="pf-c-form__helper-text">
|
||||
${msg("Default duration for generated tokens")}
|
||||
</p>
|
||||
@@ -251,9 +248,19 @@ export class AdminSettingsForm extends Form<SettingsRequest> {
|
||||
label=${msg("Default token length")}
|
||||
required
|
||||
name="defaultTokenLength"
|
||||
value="${this._settings?.defaultTokenLength ?? 60}"
|
||||
value="${settings.defaultTokenLength ?? 60}"
|
||||
help=${msg("Default length of generated tokens")}
|
||||
></ak-number-input>
|
||||
<ak-form-element-horizontal label=${msg("Flags")} name="flags" required>
|
||||
<ak-codemirror
|
||||
mode=${CodeMirrorMode.YAML}
|
||||
value="${YAML.stringify(settings.flags ?? {})}"
|
||||
>
|
||||
</ak-codemirror>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Modify flags to opt into new authentik behaviours early.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,15 +53,15 @@ export class CertificateKeyPairForm extends ModelForm<CertificateKeyPair, string
|
||||
name="certificateData"
|
||||
input-hint="code"
|
||||
placeholder="-----BEGIN CERTIFICATE-----"
|
||||
required
|
||||
?revealed=${this.instance === undefined}
|
||||
?required=${!this.instance}
|
||||
?revealed=${!this.instance}
|
||||
help=${msg("PEM-encoded Certificate data.")}
|
||||
></ak-secret-textarea-input>
|
||||
<ak-secret-textarea-input
|
||||
label=${msg("Private Key")}
|
||||
name="keyData"
|
||||
input-hint="code"
|
||||
?revealed=${this.instance === undefined}
|
||||
?revealed=${!this.instance}
|
||||
help=${msg(
|
||||
"Optional Private Key. If this is set, you can use this keypair for encryption.",
|
||||
)}
|
||||
|
||||
@@ -66,7 +66,7 @@ export class EnterpriseLicenseForm extends ModelForm<License, string> {
|
||||
</ak-form-element-horizontal>
|
||||
<ak-secret-textarea-input
|
||||
name="key"
|
||||
?revealed=${this.instance === undefined}
|
||||
?revealed=${!this.instance}
|
||||
label=${msg("License key")}
|
||||
input-hint="code"
|
||||
>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
NotificationWebhookMapping,
|
||||
PropertymappingsApi,
|
||||
PropertymappingsNotificationListRequest,
|
||||
StagesApi,
|
||||
TypeCreate,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
@@ -33,10 +35,18 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
return transport;
|
||||
});
|
||||
}
|
||||
async load(): Promise<void> {
|
||||
this.templates = await new StagesApi(DEFAULT_CONFIG).stagesEmailTemplatesList();
|
||||
}
|
||||
|
||||
templates?: TypeCreate[];
|
||||
|
||||
@property({ type: Boolean })
|
||||
showWebhook = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
showEmail = false;
|
||||
|
||||
getSuccessMessage(): string {
|
||||
return this.instance
|
||||
? msg("Successfully updated transport.")
|
||||
@@ -56,18 +66,28 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
}
|
||||
|
||||
onModeChange(mode: string | undefined): void {
|
||||
if (
|
||||
mode === NotificationTransportModeEnum.Webhook ||
|
||||
mode === NotificationTransportModeEnum.WebhookSlack
|
||||
) {
|
||||
this.showWebhook = true;
|
||||
} else {
|
||||
this.showWebhook = false;
|
||||
// Reset all flags
|
||||
this.showWebhook = false;
|
||||
this.showEmail = false;
|
||||
|
||||
switch (mode) {
|
||||
case NotificationTransportModeEnum.Webhook:
|
||||
case NotificationTransportModeEnum.WebhookSlack:
|
||||
this.showWebhook = true;
|
||||
break;
|
||||
case NotificationTransportModeEnum.Email:
|
||||
this.showEmail = true;
|
||||
break;
|
||||
case NotificationTransportModeEnum.Local:
|
||||
default:
|
||||
// Both flags remain false
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html` <ak-form-element-horizontal label=${msg("Name")} required name="name">
|
||||
return html`
|
||||
<ak-form-element-horizontal label=${msg("Name")} required name="name">
|
||||
<input
|
||||
type="text"
|
||||
value="${ifDefined(this.instance?.name)}"
|
||||
@@ -75,6 +95,26 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
required
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="sendOnce">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.instance?.sendOnce ?? false}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Send once")}</span>
|
||||
</label>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Only send notification once, for example when sending a webhook into a chat channel.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Mode")} required name="mode">
|
||||
<ak-radio
|
||||
@change=${(ev: CustomEvent<{ value: NotificationTransportModeEnum }>) => {
|
||||
@@ -109,7 +149,7 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
value="${this.instance?.webhookUrl || ""}"
|
||||
input-hint="code"
|
||||
?hidden=${!this.showWebhook}
|
||||
required
|
||||
?required=${this.showWebhook}
|
||||
>
|
||||
</ak-hidden-text-input>
|
||||
<ak-form-element-horizontal
|
||||
@@ -178,26 +218,39 @@ export class TransportForm extends ModelForm<NotificationTransport, string> {
|
||||
>
|
||||
</ak-search-select>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal name="sendOnce">
|
||||
<label class="pf-c-switch">
|
||||
<input
|
||||
class="pf-c-switch__input"
|
||||
type="checkbox"
|
||||
?checked=${this.instance?.sendOnce ?? false}
|
||||
/>
|
||||
<span class="pf-c-switch__toggle">
|
||||
<span class="pf-c-switch__toggle-icon">
|
||||
<i class="fas fa-check" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
<span class="pf-c-switch__label">${msg("Send once")}</span>
|
||||
</label>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Only send notification once, for example when sending a webhook into a chat channel.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>`;
|
||||
<ak-form-element-horizontal
|
||||
?hidden=${!this.showEmail}
|
||||
?required=${this.showEmail}
|
||||
label=${msg("Email Subject Prefix")}
|
||||
name="emailSubjectPrefix"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value="${this.instance?.emailSubjectPrefix || "authentik Notification: "}"
|
||||
class="pf-c-form-control"
|
||||
?hidden=${!this.showEmail}
|
||||
?required=${this.showEmail}
|
||||
/>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
?hidden=${!this.showEmail}
|
||||
?required=${this.showEmail}
|
||||
label=${msg("Email Template")}
|
||||
name="emailTemplate"
|
||||
>
|
||||
<select name="users" class="pf-c-form-control">
|
||||
${this.templates?.map((template) => {
|
||||
const selected =
|
||||
this.instance?.emailTemplate === template.name ||
|
||||
(!this.instance?.emailTemplate &&
|
||||
template.name === "email/event_notification.html");
|
||||
return html`<option value=${ifDefined(template.name)} ?selected=${selected}>
|
||||
${template.description}
|
||||
</option>`;
|
||||
})}
|
||||
</select>
|
||||
</ak-form-element-horizontal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user