mirror of
https://github.com/goauthentik/authentik
synced 2026-05-12 01:47:06 +02:00
Compare commits
93 Commits
better-bro
...
version/20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f83d3a19d0 | ||
|
|
ef59ff1856 | ||
|
|
4966225282 | ||
|
|
2b8765d0aa | ||
|
|
d60d06f958 | ||
|
|
1a3f268476 | ||
|
|
515a855c40 | ||
|
|
16d65b8d12 | ||
|
|
bfe928df18 | ||
|
|
c447bbe6c8 | ||
|
|
1c0a3f95df | ||
|
|
8a6116ab79 | ||
|
|
430010fbea | ||
|
|
079b575a45 | ||
|
|
b2ca887d59 | ||
|
|
d7b30ad0d7 | ||
|
|
b084ace1dd | ||
|
|
b3e45cdf1a | ||
|
|
8132e1f7d9 | ||
|
|
149dccf244 | ||
|
|
b5e4797761 | ||
|
|
be670d6253 | ||
|
|
71060ea4e7 | ||
|
|
f60f38280c | ||
|
|
418deeb332 | ||
|
|
619c77c27e | ||
|
|
ddfddb49da | ||
|
|
dbbb1870b7 | ||
|
|
5b43301206 | ||
|
|
d915d1a94a | ||
|
|
786497790a | ||
|
|
56c899cf21 | ||
|
|
943f22e5a9 | ||
|
|
11b45689f4 | ||
|
|
87f443532f | ||
|
|
0c672a0c37 | ||
|
|
dfd11ceb57 | ||
|
|
d865b7fd87 | ||
|
|
aa8a6b9c43 | ||
|
|
fe5313f42e | ||
|
|
499f739e2b | ||
|
|
4e0e738823 | ||
|
|
24360bf306 | ||
|
|
6fad3c2bbd | ||
|
|
2cf20de7ec | ||
|
|
3d8d3bb8ce | ||
|
|
80bcbe4885 | ||
|
|
32e4782ed8 | ||
|
|
613a51bdbb | ||
|
|
1c6de43701 | ||
|
|
6771530025 | ||
|
|
5876f367bc | ||
|
|
e263af2dd9 | ||
|
|
3a59911a2b | ||
|
|
bbf31e99c3 | ||
|
|
9d5bd42f3e | ||
|
|
e721dae6da | ||
|
|
af3106b144 | ||
|
|
5b55103575 | ||
|
|
ee4ecf929f | ||
|
|
8336556a6f | ||
|
|
709aad1d3b | ||
|
|
fb7ab4937c | ||
|
|
5df1726d80 | ||
|
|
9fdb568843 | ||
|
|
8e76f56f89 | ||
|
|
05d3791577 | ||
|
|
d00dd7eb90 | ||
|
|
8d2e404017 | ||
|
|
95eb2af25e | ||
|
|
cbc00a501b | ||
|
|
480645d897 | ||
|
|
997c767c95 | ||
|
|
5a54e1dc9a | ||
|
|
49b1952566 | ||
|
|
e73edc2fce | ||
|
|
409652e874 | ||
|
|
1d3fb6431f | ||
|
|
76cfada60f | ||
|
|
ac45f80551 | ||
|
|
5ea85f086a | ||
|
|
e3f657746c | ||
|
|
001b56e2cc | ||
|
|
ecbfd2f0de | ||
|
|
45753397e1 | ||
|
|
dc6fe1dafe | ||
|
|
d5e8f2f416 | ||
|
|
d73af5a2b4 | ||
|
|
7042f2bba8 | ||
|
|
efeb260fa8 | ||
|
|
29e90092ea | ||
|
|
0abe865023 | ||
|
|
220c65a41a |
6
.github/actions/setup/action.yml
vendored
6
.github/actions/setup/action.yml
vendored
@@ -22,7 +22,7 @@ runs:
|
||||
sudo rm -rf /usr/local/lib/android
|
||||
- name: Install uv
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v5
|
||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v5
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Setup python
|
||||
@@ -51,13 +51,13 @@ runs:
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
|
||||
with:
|
||||
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
|
||||
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
|
||||
- name: Setup dependencies
|
||||
if: ${{ contains(inputs.dependencies, 'runtime') }}
|
||||
shell: bash
|
||||
run: |
|
||||
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||
docker compose -f .github/actions/setup/compose.yml up -d
|
||||
docker compose -f .github/actions/setup/docker-compose.yml up -d
|
||||
cd web && npm i
|
||||
- name: Generate config
|
||||
if: ${{ contains(inputs.dependencies, 'python') }}
|
||||
|
||||
@@ -11,6 +11,11 @@ services:
|
||||
ports:
|
||||
- 5432:5432
|
||||
restart: always
|
||||
redis:
|
||||
image: docker.io/library/redis:7
|
||||
ports:
|
||||
- 6379:6379
|
||||
restart: always
|
||||
s3:
|
||||
container_name: s3
|
||||
image: docker.io/zenko/cloudserver
|
||||
4
.github/actions/test-results/action.yml
vendored
4
.github/actions/test-results/action.yml
vendored
@@ -12,11 +12,11 @@ runs:
|
||||
with:
|
||||
flags: ${{ inputs.flags }}
|
||||
use_oidc: true
|
||||
- uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
|
||||
- uses: codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1
|
||||
with:
|
||||
flags: ${{ inputs.flags }}
|
||||
file: unittest.xml
|
||||
use_oidc: true
|
||||
report_type: test_results
|
||||
- name: PostgreSQL Logs
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -85,7 +85,6 @@ jobs:
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: lifecycle/container/Dockerfile
|
||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
secrets: |
|
||||
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
|
||||
@@ -96,7 +95,7 @@ jobs:
|
||||
platforms: linux/${{ inputs.image_arch }}
|
||||
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
|
||||
cache-to: ${{ steps.ev.outputs.cacheTo }}
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
|
||||
4
.github/workflows/_reusable-docker-build.yml
vendored
4
.github/workflows/_reusable-docker-build.yml
vendored
@@ -90,14 +90,14 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: int128/docker-manifest-create-action@6cdd53a8337cd50bc3ef8c7016579d8d460edd94 # v2
|
||||
- uses: int128/docker-manifest-create-action@b60433fd4312d7a64a56d769b76ebe3f45cf36b4 # v2
|
||||
id: build
|
||||
with:
|
||||
tags: ${{ matrix.tag }}
|
||||
sources: |
|
||||
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
|
||||
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
|
||||
6
.github/workflows/ci-api-docs.yml
vendored
6
.github/workflows/ci-api-docs.yml
vendored
@@ -41,7 +41,7 @@ jobs:
|
||||
- working-directory: website/
|
||||
name: Install Dependencies
|
||||
run: npm ci
|
||||
- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
|
||||
- uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
|
||||
with:
|
||||
path: |
|
||||
${{ github.workspace }}/website/api/.docusaurus
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: npm run build -w api
|
||||
- uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v4
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v4
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- build
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v5
|
||||
- uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v5
|
||||
with:
|
||||
name: api-docs
|
||||
path: website/api/build
|
||||
|
||||
4
.github/workflows/ci-docs.yml
vendored
4
.github/workflows/ci-docs.yml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
context: .
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
|
||||
2
.github/workflows/ci-main-daily.yml
vendored
2
.github/workflows/ci-main-daily.yml
vendored
@@ -24,5 +24,5 @@ jobs:
|
||||
dir="/tmp/authentik/${{ matrix.version }}"
|
||||
mkdir -p $dir
|
||||
cd $dir
|
||||
wget https://${{ matrix.version }}.goauthentik.io/compose.yml
|
||||
wget https://${{ matrix.version }}.goauthentik.io/docker-compose.yml
|
||||
${current}/scripts/test_docker.sh
|
||||
|
||||
54
.github/workflows/ci-main.yml
vendored
54
.github/workflows/ci-main.yml
vendored
@@ -193,17 +193,15 @@ jobs:
|
||||
glob: tests/e2e/test_source_scim*
|
||||
- name: flows
|
||||
glob: tests/e2e/test_flows*
|
||||
- name: endpoints
|
||||
glob: tests/e2e/test_endpoints_*
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup e2e env (chrome, etc)
|
||||
run: |
|
||||
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
|
||||
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
|
||||
uses: actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
@@ -223,54 +221,6 @@ jobs:
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
flags: e2e
|
||||
test-openid-conformance:
|
||||
name: test-openid-conformance (${{ matrix.job.name }})
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
job:
|
||||
- name: basic
|
||||
glob: tests/openid_conformance/test_basic.py
|
||||
- name: implicit
|
||||
glob: tests/openid_conformance/test_implicit.py
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5
|
||||
- name: Setup authentik env
|
||||
uses: ./.github/actions/setup
|
||||
- name: Setup e2e env (chrome, etc)
|
||||
run: |
|
||||
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
|
||||
- name: Setup conformance suite
|
||||
run: |
|
||||
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
|
||||
- id: cache-web
|
||||
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v4
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
make -C .. gen-client-ts
|
||||
npm run build
|
||||
npm run build:sfe
|
||||
- name: run conformance
|
||||
run: |
|
||||
uv run coverage run manage.py test ${{ matrix.job.glob }}
|
||||
uv run coverage xml
|
||||
- uses: ./.github/actions/test-results
|
||||
if: ${{ always() }}
|
||||
with:
|
||||
flags: conformance
|
||||
- if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
|
||||
with:
|
||||
name: conformance-certification-${{ matrix.job.name }}
|
||||
path: tests/openid_conformance/exports/
|
||||
ci-core-mark:
|
||||
if: always()
|
||||
needs:
|
||||
|
||||
6
.github/workflows/ci-outpost.yml
vendored
6
.github/workflows/ci-outpost.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
with:
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: lifecycle/container/${{ matrix.type }}.Dockerfile
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
context: .
|
||||
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
|
||||
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
|
||||
with:
|
||||
|
||||
12
.github/workflows/release-publish.yml
vendored
12
.github/workflows/release-publish.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
if: true
|
||||
with:
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
@@ -121,10 +121,10 @@ jobs:
|
||||
build-args: |
|
||||
VERSION=${{ github.ref }}
|
||||
tags: ${{ steps.ev.outputs.imageTags }}
|
||||
file: lifecycle/container/${{ matrix.type }}.Dockerfile
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
- uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
|
||||
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ${{ steps.ev.outputs.attestImageNames }}
|
||||
@@ -232,7 +232,7 @@ jobs:
|
||||
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
|
||||
docker cp ${container}:web/ .
|
||||
- name: Create a Sentry.io release
|
||||
uses: getsentry/action-release@dab6548b3c03c4717878099e43782cf5be654289 # v3
|
||||
uses: getsentry/action-release@128c5058bbbe93c8e02147fe0a9c713f166259a6 # v3
|
||||
continue-on-error: true
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -211,5 +211,4 @@ source_docs/
|
||||
/vendor/
|
||||
|
||||
### Docker ###
|
||||
tests/openid_conformance/exports/*.zip
|
||||
compose.override.yml
|
||||
docker-compose.override.yml
|
||||
|
||||
@@ -16,8 +16,10 @@ go.sum @goauthentik/backend
|
||||
# Infrastructure
|
||||
.github/ @goauthentik/infrastructure
|
||||
lifecycle/aws/ @goauthentik/infrastructure
|
||||
lifecycle/container/ @goauthentik/infrastructure
|
||||
Dockerfile @goauthentik/infrastructure
|
||||
*Dockerfile @goauthentik/infrastructure
|
||||
.dockerignore @goauthentik/infrastructure
|
||||
docker-compose.yml @goauthentik/infrastructure
|
||||
Makefile @goauthentik/infrastructure
|
||||
.editorconfig @goauthentik/infrastructure
|
||||
CODEOWNERS @goauthentik/infrastructure
|
||||
|
||||
@@ -26,7 +26,7 @@ RUN npm run build && \
|
||||
npm run build:sfe
|
||||
|
||||
# Stage 2: Build go proxy
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS go-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS go-builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -76,7 +76,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
|
||||
|
||||
# Stage 4: Download uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 AS uv
|
||||
FROM ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 AS uv
|
||||
# Stage 5: Base python image
|
||||
FROM ghcr.io/goauthentik/fips-python:3.13.9-slim-trixie-fips@sha256:700fc8c1e290bd14e5eaca50b1d8e8c748c820010559cbfb4c4f8dfbe2c4c9ff AS python-base
|
||||
|
||||
29
Makefile
29
Makefile
@@ -9,13 +9,6 @@ NPM_VERSION = $(shell python -m scripts.generate_semver)
|
||||
PY_SOURCES = authentik packages tests scripts lifecycle .github
|
||||
DOCKER_IMAGE ?= "authentik:test"
|
||||
|
||||
UNAME_S := $(shell uname -s)
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
SED_INPLACE = sed -i ''
|
||||
else
|
||||
SED_INPLACE = sed -i
|
||||
endif
|
||||
|
||||
GEN_API_TS = gen-ts-api
|
||||
GEN_API_PY = gen-py-api
|
||||
GEN_API_GO = gen-go-api
|
||||
@@ -126,8 +119,8 @@ bump: ## Bump authentik version. Usage: make bump version=20xx.xx.xx
|
||||
ifndef version
|
||||
$(error Usage: make bump version=20xx.xx.xx )
|
||||
endif
|
||||
$(SED_INPLACE) 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||
$(SED_INPLACE) 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||
sed -i 's/^version = ".*"/version = "$(version)"/' pyproject.toml
|
||||
sed -i 's/^VERSION = ".*"/VERSION = "$(version)"/' authentik/__init__.py
|
||||
$(MAKE) gen-build gen-compose aws-cfn
|
||||
npm version --no-git-tag-version --allow-same-version $(version)
|
||||
cd ${PWD}/web && npm version --no-git-tag-version --allow-same-version $(version)
|
||||
@@ -141,10 +134,14 @@ gen-build: ## Extract the schema from the database
|
||||
AUTHENTIK_DEBUG=true \
|
||||
AUTHENTIK_TENANTS__ENABLED=true \
|
||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||
uv run ak build_schema
|
||||
uv run ak make_blueprint_schema --file blueprints/schema.json
|
||||
AUTHENTIK_DEBUG=true \
|
||||
AUTHENTIK_TENANTS__ENABLED=true \
|
||||
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
|
||||
uv run ak spectacular --file schema.yml
|
||||
|
||||
gen-compose:
|
||||
uv run scripts/generate_compose.py
|
||||
uv run scripts/generate_docker_compose.py
|
||||
|
||||
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
|
||||
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
|
||||
@@ -152,14 +149,14 @@ gen-changelog: ## (Release) generate the changelog based from the commits since
|
||||
|
||||
gen-diff: ## (Release) generate the changelog diff between the current schema and the last tag
|
||||
git show $(shell git describe --tags $(shell git rev-list --tags --max-count=1)):schema.yml > schema-old.yml
|
||||
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" diff \
|
||||
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" diff \
|
||||
--markdown \
|
||||
/local/diff.md \
|
||||
/local/schema-old.yml \
|
||||
/local/schema.yml
|
||||
rm schema-old.yml
|
||||
$(SED_INPLACE) 's/{/{/g' diff.md
|
||||
$(SED_INPLACE) 's/}/}/g' diff.md
|
||||
sed -i 's/{/{/g' diff.md
|
||||
sed -i 's/}/}/g' diff.md
|
||||
npx prettier --write diff.md
|
||||
|
||||
gen-clean-ts: ## Remove generated API client for TypeScript
|
||||
@@ -175,7 +172,7 @@ gen-clean-go: ## Remove generated API client for Go
|
||||
gen-clean: gen-clean-ts gen-clean-go gen-clean-py ## Remove generated API clients
|
||||
|
||||
gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescript into the authentik UI Application
|
||||
docker compose -f scripts/api/compose.yml run --rm --user "${UID}:${GID}" gen \
|
||||
docker compose -f scripts/api/docker-compose.yml run --rm --user "${UID}:${GID}" gen \
|
||||
generate \
|
||||
-i /local/schema.yml \
|
||||
-g typescript-fetch \
|
||||
@@ -296,7 +293,7 @@ docs-api-clean: ## Clean generated API documentation
|
||||
|
||||
docker: ## Build a docker image of the current source tree
|
||||
mkdir -p ${GEN_API_TS}
|
||||
DOCKER_BUILDKIT=1 docker build . -f lifecycle/container/Dockerfile --progress plain --tag ${DOCKER_IMAGE}
|
||||
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
|
||||
|
||||
test-docker:
|
||||
BUILD=true ${PWD}/scripts/test_docker.sh
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from functools import lru_cache
|
||||
from os import environ
|
||||
|
||||
VERSION = "2026.2.0-rc1"
|
||||
VERSION = "2025.12.0-rc3"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
||||
@@ -62,10 +62,10 @@ class TestSanitizeFilePath(TestCase):
|
||||
"test@file.png", # @
|
||||
"test#file.png", # #
|
||||
"test$file.png", # $
|
||||
"test%file.png", # %
|
||||
"test%file.png", # % (but %(theme)s is allowed)
|
||||
"test&file.png", # &
|
||||
"test*file.png", # *
|
||||
"test(file).png", # parentheses
|
||||
"test(file).png", # parentheses (but %(theme)s is allowed)
|
||||
"test[file].png", # brackets
|
||||
"test{file}.png", # braces
|
||||
]
|
||||
@@ -108,3 +108,30 @@ class TestSanitizeFilePath(TestCase):
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
|
||||
def test_sanitize_theme_variable_valid(self):
|
||||
"""Test sanitizing filename with %(theme)s variable"""
|
||||
# These should all be valid
|
||||
validate_file_name("logo-%(theme)s.png")
|
||||
validate_file_name("brand/logo-%(theme)s.svg")
|
||||
validate_file_name("images/icon-%(theme)s.png")
|
||||
validate_file_name("%(theme)s/logo.png")
|
||||
validate_file_name("brand/%(theme)s/logo.png")
|
||||
|
||||
def test_sanitize_theme_variable_multiple(self):
|
||||
"""Test sanitizing filename with multiple %(theme)s variables"""
|
||||
validate_file_name("%(theme)s/logo-%(theme)s.png")
|
||||
|
||||
def test_sanitize_theme_variable_invalid_format(self):
|
||||
"""Test that partial or malformed theme variables are rejected"""
|
||||
invalid_paths = [
|
||||
"test%(theme.png", # missing )s
|
||||
"test%theme)s.png", # missing (
|
||||
"test%(themes).png", # wrong variable name
|
||||
"test%(THEME)s.png", # wrong case
|
||||
"test%()s.png", # empty variable name
|
||||
]
|
||||
|
||||
for path in invalid_paths:
|
||||
with self.assertRaises(ValidationError):
|
||||
validate_file_name(path)
|
||||
|
||||
@@ -12,6 +12,10 @@ from authentik.admin.files.usage import FileUsage
|
||||
MAX_FILE_NAME_LENGTH = 1024
|
||||
MAX_PATH_COMPONENT_LENGTH = 255
|
||||
|
||||
# Theme variable placeholder that can be used in file paths
|
||||
# This allows for theme-specific files like logo-%(theme)s.png
|
||||
THEME_VARIABLE = "%(theme)s"
|
||||
|
||||
|
||||
def validate_file_name(name: str) -> None:
|
||||
if PassthroughBackend(FileUsage.MEDIA).supports_file(name) or StaticBackend(
|
||||
@@ -39,12 +43,17 @@ def validate_upload_file_name(
|
||||
if not name:
|
||||
raise ValidationError(_("File name cannot be empty"))
|
||||
|
||||
# Same regex is used in the frontend as well
|
||||
if not re.match(r"^[a-zA-Z0-9._/-]+$", name):
|
||||
# Allow %(theme)s placeholder for theme-specific files
|
||||
# We temporarily replace it for validation, then check the result
|
||||
name_for_validation = name.replace(THEME_VARIABLE, "theme")
|
||||
|
||||
# Same regex is used in the frontend as well (without %(theme)s handling there)
|
||||
if not re.match(r"^[a-zA-Z0-9._/-]+$", name_for_validation):
|
||||
raise ValidationError(
|
||||
_(
|
||||
"File name can only contain letters (a-z, A-Z), numbers (0-9), "
|
||||
"dots (.), hyphens (-), underscores (_), and forward slashes (/)"
|
||||
"dots (.), hyphens (-), underscores (_), forward slashes (/), "
|
||||
"and the special placeholder %(theme)s for theme-specific files"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
from json import dumps
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from drf_spectacular.drainage import GENERATOR_STATS
|
||||
from drf_spectacular.generators import SchemaGenerator
|
||||
from drf_spectacular.renderers import OpenApiYamlRenderer
|
||||
from drf_spectacular.validation import validate_schema
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.v1.schema import SchemaBuilder
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.logger = get_logger()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--blueprint-file", type=str, default="blueprints/schema.json")
|
||||
parser.add_argument("--api-file", type=str, default="schema.yml")
|
||||
|
||||
@no_translations
|
||||
def handle(self, *args, blueprint_file: str, api_file: str, **options):
|
||||
self.build_blueprint(blueprint_file)
|
||||
self.build_api(api_file)
|
||||
|
||||
def build_blueprint(self, file: str):
|
||||
self.logger.debug("Building blueprint schema...", file=file)
|
||||
blueprint_builder = SchemaBuilder()
|
||||
blueprint_builder.build()
|
||||
with open(file, "w") as _schema:
|
||||
_schema.write(
|
||||
dumps(blueprint_builder.schema, indent=4, default=SchemaBuilder.json_default)
|
||||
)
|
||||
|
||||
def build_api(self, file: str):
|
||||
self.logger.debug("Building API schema...", file=file)
|
||||
generator = SchemaGenerator()
|
||||
schema = generator.get_schema(request=None, public=True)
|
||||
GENERATOR_STATS.emit_summary()
|
||||
validate_schema(schema)
|
||||
output = OpenApiYamlRenderer().render(schema, renderer_context={})
|
||||
with open(file, "wb") as f:
|
||||
f.write(output)
|
||||
@@ -1,14 +1,9 @@
|
||||
"""Schema generation tests"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
from yaml import safe_load
|
||||
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
|
||||
class TestSchemaGeneration(APITestCase):
|
||||
"""Generic admin tests"""
|
||||
@@ -26,18 +21,3 @@ class TestSchemaGeneration(APITestCase):
|
||||
reverse("authentik_api:schema-browser"),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_build_schema(self):
|
||||
"""Test schema build command"""
|
||||
blueprint_file = Path("blueprints/schema.json")
|
||||
api_file = Path("schema.yml")
|
||||
blueprint_file.unlink()
|
||||
api_file.unlink()
|
||||
with (
|
||||
CONFIG.patch("debug", True),
|
||||
CONFIG.patch("tenants.enabled", True),
|
||||
CONFIG.patch("outposts.disable_embedded_outpost", True),
|
||||
):
|
||||
call_command("build_schema")
|
||||
self.assertTrue(blueprint_file.exists())
|
||||
self.assertTrue(api_file.exists())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Generate JSON Schema for blueprints"""
|
||||
|
||||
from json import dumps
|
||||
from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from django.db.models import Model, fields
|
||||
from django.db.models.fields.related import OneToOneField
|
||||
from drf_jsonschema_serializer.convert import converter, field_to_converter
|
||||
@@ -38,12 +40,13 @@ class PrimaryKeyRelatedFieldConverter:
|
||||
return {"type": "integer"}
|
||||
|
||||
|
||||
class SchemaBuilder:
|
||||
class Command(BaseCommand):
|
||||
"""Generate JSON Schema for blueprints"""
|
||||
|
||||
schema: dict
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.schema = {
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
@@ -90,6 +93,16 @@ class SchemaBuilder:
|
||||
"$defs": {"blueprint_entry": {"oneOf": []}},
|
||||
}
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--file", type=str)
|
||||
|
||||
@no_translations
|
||||
def handle(self, *args, file: str, **options):
|
||||
"""Generate JSON Schema for blueprints"""
|
||||
self.build()
|
||||
with open(file, "w") as _schema:
|
||||
_schema.write(dumps(self.schema, indent=4, default=Command.json_default))
|
||||
|
||||
@staticmethod
|
||||
def json_default(value: Any) -> Any:
|
||||
"""Helper that handles gettext_lazy strings that JSON doesn't handle"""
|
||||
@@ -111,7 +124,7 @@ class SchemaBuilder:
|
||||
try:
|
||||
serializer_class = model_instance.serializer
|
||||
except NotImplementedError as exc:
|
||||
raise ValueError(f"SerializerModel not implemented by {model}") from exc
|
||||
raise NotImplementedError(model_instance) from exc
|
||||
serializer = serializer_class(
|
||||
context={
|
||||
SERIALIZER_CONTEXT_BLUEPRINT: False,
|
||||
@@ -15,6 +15,7 @@ from django.db.models import Model
|
||||
from django.db.models.query_utils import Q
|
||||
from django.db.transaction import atomic
|
||||
from django.db.utils import IntegrityError
|
||||
from django_channels_postgres.models import GroupChannel, Message
|
||||
from guardian.models import RoleObjectPermission, UserObjectPermission
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
@@ -40,17 +41,55 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.endpoints.models import Connector
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
AgentDeviceConnection,
|
||||
AppleNonce,
|
||||
DeviceAuthenticationToken,
|
||||
)
|
||||
from authentik.endpoints.connectors.agent.models import (
|
||||
DeviceToken as EndpointDeviceToken,
|
||||
)
|
||||
from authentik.endpoints.models import Connector, Device, DeviceConnection, DeviceFactSnapshot
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
from authentik.enterprise.models import LicenseUsage
|
||||
from authentik.enterprise.providers.google_workspace.models import (
|
||||
GoogleWorkspaceProviderGroup,
|
||||
GoogleWorkspaceProviderUser,
|
||||
)
|
||||
from authentik.enterprise.providers.microsoft_entra.models import (
|
||||
MicrosoftEntraProviderGroup,
|
||||
MicrosoftEntraProviderUser,
|
||||
)
|
||||
from authentik.enterprise.providers.ssf.models import StreamEvent
|
||||
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
|
||||
EndpointDevice,
|
||||
EndpointDeviceConnection,
|
||||
)
|
||||
from authentik.events.logs import LogEvent, capture_logs
|
||||
from authentik.events.utils import cleanse_dict
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.flows.models import FlowToken, Stage
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.reflection import get_apps
|
||||
from authentik.outposts.models import OutpostServiceConnection
|
||||
from authentik.policies.models import Policy, PolicyBindingModel
|
||||
from authentik.policies.reputation.models import Reputation
|
||||
from authentik.providers.oauth2.models import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
DeviceToken,
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.proxy.models import ProxySession
|
||||
from authentik.providers.rac.models import ConnectionToken
|
||||
from authentik.providers.saml.models import SAMLSession
|
||||
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
|
||||
from authentik.rbac.models import Role
|
||||
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
|
||||
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
|
||||
from authentik.stages.consent.models import UserConsent
|
||||
from authentik.tasks.models import Task, TaskLog
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
# Context set when the serializer is created in a blueprint context
|
||||
# Update website/docs/customize/blueprints/v1/models.md when used
|
||||
@@ -86,16 +125,49 @@ def excluded_models() -> list[type[Model]]:
|
||||
# Classes that have other dependencies
|
||||
Session,
|
||||
AuthenticatedSession,
|
||||
# Classes which are only internally managed
|
||||
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
|
||||
FlowToken,
|
||||
LicenseUsage,
|
||||
SCIMProviderGroup,
|
||||
SCIMProviderUser,
|
||||
Tenant,
|
||||
Task,
|
||||
TaskLog,
|
||||
ConnectionToken,
|
||||
AuthorizationCode,
|
||||
AccessToken,
|
||||
RefreshToken,
|
||||
ProxySession,
|
||||
Reputation,
|
||||
WebAuthnDeviceType,
|
||||
SCIMSourceUser,
|
||||
SCIMSourceGroup,
|
||||
GoogleWorkspaceProviderUser,
|
||||
GoogleWorkspaceProviderGroup,
|
||||
MicrosoftEntraProviderUser,
|
||||
MicrosoftEntraProviderGroup,
|
||||
EndpointDevice,
|
||||
EndpointDeviceConnection,
|
||||
EndpointDeviceToken,
|
||||
Device,
|
||||
DeviceConnection,
|
||||
DeviceAuthenticationToken,
|
||||
AppleNonce,
|
||||
AgentDeviceConnection,
|
||||
DeviceFactSnapshot,
|
||||
DeviceToken,
|
||||
StreamEvent,
|
||||
UserConsent,
|
||||
SAMLSession,
|
||||
Message,
|
||||
GroupChannel,
|
||||
)
|
||||
|
||||
|
||||
def is_model_allowed(model: type[Model]) -> bool:
|
||||
"""Check if model is allowed"""
|
||||
return (
|
||||
model not in excluded_models()
|
||||
and issubclass(model, SerializerModel | BaseMetaModel)
|
||||
and not issubclass(model, InternallyManagedMixin)
|
||||
)
|
||||
return model not in excluded_models() and issubclass(model, SerializerModel | BaseMetaModel)
|
||||
|
||||
|
||||
class DoRollback(SentryIgnoredException):
|
||||
|
||||
@@ -85,6 +85,7 @@ class GroupSerializer(ModelSerializer):
|
||||
source="roles",
|
||||
required=False,
|
||||
)
|
||||
inherited_roles_obj = SerializerMethodField(allow_null=True)
|
||||
num_pk = IntegerField(read_only=True)
|
||||
|
||||
@property
|
||||
@@ -108,6 +109,13 @@ class GroupSerializer(ModelSerializer):
|
||||
return True
|
||||
return str(request.query_params.get("include_parents", "false")).lower() == "true"
|
||||
|
||||
@property
|
||||
def _should_include_inherited_roles(self) -> bool:
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return True
|
||||
return str(request.query_params.get("include_inherited_roles", "false")).lower() == "true"
|
||||
|
||||
@extend_schema_field(PartialUserSerializer(many=True))
|
||||
def get_users_obj(self, instance: Group) -> list[PartialUserSerializer] | None:
|
||||
if not self._should_include_users:
|
||||
@@ -126,6 +134,15 @@ class GroupSerializer(ModelSerializer):
|
||||
return None
|
||||
return RelatedGroupSerializer(instance.parents, many=True).data
|
||||
|
||||
@extend_schema_field(RoleSerializer(many=True))
|
||||
def get_inherited_roles_obj(self, instance: Group) -> list | None:
|
||||
"""Return only inherited roles from ancestor groups (excludes direct roles)"""
|
||||
if not self._should_include_inherited_roles:
|
||||
return None
|
||||
direct_role_pks = instance.roles.values_list("pk", flat=True)
|
||||
inherited_roles = instance.all_roles().exclude(pk__in=direct_role_pks)
|
||||
return RoleSerializer(inherited_roles, many=True).data
|
||||
|
||||
def validate_is_superuser(self, superuser: bool):
|
||||
"""Ensure that the user creating this group has permissions to set the superuser flag"""
|
||||
request: Request = self.context.get("request", None)
|
||||
@@ -167,6 +184,7 @@ class GroupSerializer(ModelSerializer):
|
||||
"attributes",
|
||||
"roles",
|
||||
"roles_obj",
|
||||
"inherited_roles_obj",
|
||||
"children",
|
||||
"children_obj",
|
||||
]
|
||||
@@ -289,6 +307,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
OpenApiParameter("include_parents", bool, default=False),
|
||||
OpenApiParameter("include_inherited_roles", bool, default=False),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
@@ -299,6 +318,7 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||
OpenApiParameter("include_users", bool, default=True),
|
||||
OpenApiParameter("include_children", bool, default=False),
|
||||
OpenApiParameter("include_parents", bool, default=False),
|
||||
OpenApiParameter("include_inherited_roles", bool, default=False),
|
||||
]
|
||||
)
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
|
||||
from django.utils.timezone import now
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import CharField
|
||||
@@ -144,6 +145,12 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
||||
owner_field = "user"
|
||||
rbac_allow_create_without_perm = True
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user if self.request else get_anonymous_user()
|
||||
if user.is_superuser:
|
||||
return super().get_queryset()
|
||||
return super().get_queryset().filter(user=user.pk)
|
||||
|
||||
def perform_create(self, serializer: TokenSerializer):
|
||||
if not self.request.user.is_superuser:
|
||||
instance = serializer.save(
|
||||
|
||||
@@ -5,10 +5,9 @@ from django.test import TestCase
|
||||
from authentik.core.models import Group, PropertyMapping, Source, User
|
||||
from authentik.core.sources.mapper import SourceMapper
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.lib.models import InternallyManagedMixin
|
||||
|
||||
|
||||
class ProxySource(InternallyManagedMixin, Source):
|
||||
class ProxySource(Source):
|
||||
@property
|
||||
def property_mapping_type(self):
|
||||
return PropertyMapping
|
||||
|
||||
@@ -183,16 +183,16 @@ class TestTokenAPI(APITestCase):
|
||||
self.assertEqual(len(body["results"]), 1)
|
||||
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
|
||||
|
||||
def test_list_with_permission(self):
|
||||
"""Test Token List (Test with `view_token` permission)"""
|
||||
def test_list_admin(self):
|
||||
"""Test Token List (Test with admin auth)"""
|
||||
Token.objects.all().delete()
|
||||
self.client.force_login(self.admin)
|
||||
token_should: Token = Token.objects.create(
|
||||
identifier="test", expiring=False, user=self.user
|
||||
)
|
||||
token_should_not: Token = Token.objects.create(
|
||||
identifier="test-2", expiring=False, user=get_anonymous_user()
|
||||
)
|
||||
self.user.assign_perms_to_managed_role("authentik_core.view_token")
|
||||
response = self.client.get(reverse("authentik_api:token-list"))
|
||||
body = loads(response.content)
|
||||
self.assertEqual(len(body["results"]), 2)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Crypto API Views"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.x509 import load_pem_x509_certificate
|
||||
@@ -13,12 +15,14 @@ from drf_spectacular.utils import (
|
||||
OpenApiParameter,
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
extend_schema_field,
|
||||
)
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.fields import (
|
||||
CharField,
|
||||
ChoiceField,
|
||||
DateTimeField,
|
||||
IntegerField,
|
||||
SerializerMethodField,
|
||||
)
|
||||
@@ -47,15 +51,59 @@ LOGGER = get_logger()
|
||||
class CertificateKeyPairSerializer(ModelSerializer):
|
||||
"""CertificateKeyPair Serializer"""
|
||||
|
||||
fingerprint_sha256 = SerializerMethodField()
|
||||
fingerprint_sha1 = SerializerMethodField()
|
||||
|
||||
cert_expiry = SerializerMethodField()
|
||||
cert_subject = SerializerMethodField()
|
||||
private_key_available = SerializerMethodField()
|
||||
key_type = SerializerMethodField()
|
||||
|
||||
certificate_download_url = SerializerMethodField()
|
||||
private_key_download_url = SerializerMethodField()
|
||||
|
||||
@property
|
||||
def _should_include_details(self) -> bool:
|
||||
request: Request = self.context.get("request", None)
|
||||
if not request:
|
||||
return True
|
||||
return str(request.query_params.get("include_details", "true")).lower() == "true"
|
||||
|
||||
def get_fingerprint_sha256(self, instance: CertificateKeyPair) -> str | None:
|
||||
"Get certificate Hash (SHA256)"
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return instance.fingerprint_sha256
|
||||
|
||||
def get_fingerprint_sha1(self, instance: CertificateKeyPair) -> str | None:
|
||||
"Get certificate Hash (SHA1)"
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return instance.fingerprint_sha1
|
||||
|
||||
def get_cert_expiry(self, instance: CertificateKeyPair) -> datetime | None:
|
||||
"Get certificate expiry"
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return DateTimeField().to_representation(instance.certificate.not_valid_after_utc)
|
||||
|
||||
def get_cert_subject(self, instance: CertificateKeyPair) -> str | None:
|
||||
"""Get certificate subject as full rfc4514"""
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return instance.certificate.subject.rfc4514_string()
|
||||
|
||||
def get_private_key_available(self, instance: CertificateKeyPair) -> bool:
|
||||
"""Show if this keypair has a private key configured or not"""
|
||||
return instance.key_data != "" and instance.key_data is not None
|
||||
|
||||
@extend_schema_field(ChoiceField(choices=KeyType.choices, allow_null=True))
|
||||
def get_key_type(self, instance: CertificateKeyPair) -> str | None:
|
||||
"""Get the key algorithm type from the certificate's public key"""
|
||||
if not self._should_include_details:
|
||||
return None
|
||||
return instance.key_type
|
||||
|
||||
def get_certificate_download_url(self, instance: CertificateKeyPair) -> str:
|
||||
"""Get URL to download certificate"""
|
||||
return (
|
||||
@@ -127,11 +175,6 @@ class CertificateKeyPairSerializer(ModelSerializer):
|
||||
"managed": {"read_only": True},
|
||||
"key_data": {"write_only": True},
|
||||
"certificate_data": {"write_only": True},
|
||||
"fingerprint_sha256": {"read_only": True},
|
||||
"fingerprint_sha1": {"read_only": True},
|
||||
"cert_expiry": {"read_only": True},
|
||||
"cert_subject": {"read_only": True},
|
||||
"key_type": {"read_only": True},
|
||||
}
|
||||
|
||||
|
||||
@@ -173,12 +216,17 @@ class CertificateKeyPairFilter(FilterSet):
|
||||
return queryset.exclude(key_data__exact="")
|
||||
|
||||
def filter_key_type(self, queryset, name, value): # pragma: no cover
|
||||
"""Filter certificates by key type using the stored database field"""
|
||||
"""Filter certificates by key type using the public key from the certificate"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
# value is a list of KeyType enum values from MultipleChoiceFilter
|
||||
return queryset.filter(key_type__in=value)
|
||||
filtered_pks = []
|
||||
for cert in queryset:
|
||||
if cert.key_type in value:
|
||||
filtered_pks.append(cert.pk)
|
||||
|
||||
return queryset.filter(pk__in=filtered_pks)
|
||||
|
||||
class Meta:
|
||||
model = CertificateKeyPair
|
||||
@@ -215,6 +263,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||
"Can be specified multiple times (e.g. '?key_type=rsa&key_type=ec')"
|
||||
),
|
||||
),
|
||||
OpenApiParameter("include_details", bool, default=True),
|
||||
]
|
||||
)
|
||||
def list(self, request, *args, **kwargs):
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-09 06:22
|
||||
|
||||
from hashlib import md5
|
||||
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.x509 import load_pem_x509_certificate
|
||||
from django.db import migrations, models
|
||||
|
||||
from authentik.crypto.signals import extract_certificate_metadata
|
||||
from authentik.lib.migrations import progress_bar
|
||||
|
||||
|
||||
def backfill_certificate_metadata(apps, schema_editor): # noqa: ARG001
|
||||
"""Backfill certificate metadata and kid for existing records."""
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
|
||||
|
||||
print("\nStoring extra data about certificates, this might take a couple of minutes...")
|
||||
for cert in progress_bar(CertificateKeyPair.objects.using(db_alias).all()):
|
||||
updated_fields = []
|
||||
|
||||
if cert.certificate_data:
|
||||
try:
|
||||
certificate = load_pem_x509_certificate(
|
||||
cert.certificate_data.encode("utf-8"), default_backend()
|
||||
)
|
||||
metadata = extract_certificate_metadata(certificate)
|
||||
|
||||
cert.key_type = metadata["key_type"]
|
||||
cert.cert_expiry = metadata["cert_expiry"]
|
||||
cert.cert_subject = metadata["cert_subject"]
|
||||
cert.fingerprint_sha256 = metadata["fingerprint_sha256"]
|
||||
cert.fingerprint_sha1 = metadata["fingerprint_sha1"]
|
||||
updated_fields.extend(
|
||||
[
|
||||
"key_type",
|
||||
"cert_expiry",
|
||||
"cert_subject",
|
||||
"fingerprint_sha256",
|
||||
"fingerprint_sha1",
|
||||
]
|
||||
)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
# Backfill kid with MD5 for backwards compatibility
|
||||
if cert.key_data:
|
||||
cert.kid = md5(cert.key_data.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
updated_fields.append("kid")
|
||||
|
||||
if updated_fields:
|
||||
cert.save(update_fields=updated_fields, using=db_alias)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0005_alter_certificatekeypair_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="cert_expiry",
|
||||
field=models.DateTimeField(blank=True, help_text="Certificate expiry date", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="cert_subject",
|
||||
field=models.TextField(
|
||||
blank=True, help_text="Certificate subject as RFC4514 string", null=True
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="fingerprint_sha1",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="SHA1 fingerprint of the certificate",
|
||||
max_length=59,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="fingerprint_sha256",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="SHA256 fingerprint of the certificate",
|
||||
max_length=95,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="key_type",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("rsa", "RSA"),
|
||||
("ec", "Elliptic Curve"),
|
||||
("dsa", "DSA"),
|
||||
("ed25519", "Ed25519"),
|
||||
("ed448", "Ed448"),
|
||||
],
|
||||
help_text="Key algorithm type detected from the certificate's public key",
|
||||
max_length=16,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="certificatekeypair",
|
||||
name="kid",
|
||||
field=models.CharField(
|
||||
blank=True, help_text="Key ID generated from private key", max_length=128, null=True
|
||||
),
|
||||
),
|
||||
migrations.RunPython(backfill_certificate_metadata, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -1,8 +1,7 @@
|
||||
"""authentik crypto models"""
|
||||
|
||||
from base64 import urlsafe_b64encode
|
||||
from binascii import hexlify
|
||||
from hashlib import md5, sha512
|
||||
from hashlib import md5
|
||||
from ssl import PEM_FOOTER, PEM_HEADER
|
||||
from textwrap import wrap
|
||||
from uuid import uuid4
|
||||
@@ -48,39 +47,6 @@ def fingerprint_sha256(cert: Certificate) -> str:
|
||||
return hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8")
|
||||
|
||||
|
||||
def detect_key_type(certificate: Certificate) -> str | None:
|
||||
"""Detect the key algorithm type by parsing the certificate's public key"""
|
||||
try:
|
||||
public_key = certificate.public_key()
|
||||
if isinstance(public_key, RSAPublicKey):
|
||||
return KeyType.RSA
|
||||
if isinstance(public_key, EllipticCurvePublicKey):
|
||||
return KeyType.EC
|
||||
if isinstance(public_key, DSAPublicKey):
|
||||
return KeyType.DSA
|
||||
if isinstance(public_key, Ed25519PublicKey):
|
||||
return KeyType.ED25519
|
||||
if isinstance(public_key, Ed448PublicKey):
|
||||
return KeyType.ED448
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("Failed to detect key type", exc=exc)
|
||||
return None
|
||||
|
||||
|
||||
def generate_key_id(key_data: str) -> str:
|
||||
"""Generate Key ID using SHA512 + urlsafe_b64encode."""
|
||||
if not key_data:
|
||||
return ""
|
||||
return urlsafe_b64encode(sha512(key_data.encode("utf-8")).digest()).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def generate_key_id_legacy(key_data: str) -> str:
|
||||
"""Generate Key ID using MD5 (legacy format for backwards compatibility)."""
|
||||
if not key_data:
|
||||
return ""
|
||||
return md5(key_data.encode("utf-8")).hexdigest() # nosec
|
||||
|
||||
|
||||
class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
"""CertificateKeyPair that can be used for signing or encrypting if `key_data`
|
||||
is set, otherwise it can be used to verify remote data."""
|
||||
@@ -96,41 +62,6 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
key_type = models.CharField(
|
||||
max_length=16,
|
||||
choices=KeyType.choices,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Key algorithm type detected from the certificate's public key"),
|
||||
)
|
||||
cert_expiry = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Certificate expiry date"),
|
||||
)
|
||||
cert_subject = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Certificate subject as RFC4514 string"),
|
||||
)
|
||||
fingerprint_sha256 = models.CharField(
|
||||
max_length=95,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("SHA256 fingerprint of the certificate"),
|
||||
)
|
||||
fingerprint_sha1 = models.CharField(
|
||||
max_length=59,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("SHA1 fingerprint of the certificate"),
|
||||
)
|
||||
kid = models.CharField(
|
||||
max_length=128,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Key ID generated from private key"),
|
||||
)
|
||||
|
||||
_cert: Certificate | None = None
|
||||
_private_key: PrivateKeyTypes | None = None
|
||||
@@ -175,6 +106,41 @@ class CertificateKeyPair(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
||||
return None
|
||||
return self._private_key
|
||||
|
||||
@property
|
||||
def fingerprint_sha256(self) -> str:
|
||||
"""Get SHA256 Fingerprint of certificate_data"""
|
||||
return fingerprint_sha256(self.certificate)
|
||||
|
||||
@property
|
||||
def fingerprint_sha1(self) -> str:
|
||||
"""Get SHA1 Fingerprint of certificate_data"""
|
||||
return hexlify(self.certificate.fingerprint(hashes.SHA1()), ":").decode("utf-8") # nosec
|
||||
|
||||
@property
|
||||
def kid(self):
|
||||
"""Get Key ID used for JWKS"""
|
||||
return (
|
||||
md5(self.key_data.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||
if self.key_data
|
||||
else ""
|
||||
) # nosec
|
||||
|
||||
@property
|
||||
def key_type(self) -> str | None:
|
||||
"""Get the key algorithm type from the certificate's public key"""
|
||||
public_key = self.certificate.public_key()
|
||||
if isinstance(public_key, RSAPublicKey):
|
||||
return KeyType.RSA
|
||||
if isinstance(public_key, EllipticCurvePublicKey):
|
||||
return KeyType.EC
|
||||
if isinstance(public_key, DSAPublicKey):
|
||||
return KeyType.DSA
|
||||
if isinstance(public_key, Ed25519PublicKey):
|
||||
return KeyType.ED25519
|
||||
if isinstance(public_key, Ed448PublicKey):
|
||||
return KeyType.ED448
|
||||
return None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Certificate-Key Pair {self.name}"
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""authentik crypto signals"""
|
||||
|
||||
from binascii import hexlify
|
||||
from datetime import datetime
|
||||
from ssl import CertificateError
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.x509 import Certificate
|
||||
from django.db.models.signals import pre_save
|
||||
from django.dispatch import receiver
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.crypto.models import (
|
||||
CertificateKeyPair,
|
||||
detect_key_type,
|
||||
fingerprint_sha256,
|
||||
generate_key_id,
|
||||
generate_key_id_legacy,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
def extract_certificate_metadata(certificate: Certificate) -> dict[str, str | datetime]:
|
||||
"""Extract all metadata fields from a certificate."""
|
||||
metadata = {}
|
||||
|
||||
try:
|
||||
metadata["key_type"] = detect_key_type(certificate)
|
||||
metadata["cert_expiry"] = certificate.not_valid_after_utc
|
||||
metadata["cert_subject"] = certificate.subject.rfc4514_string()
|
||||
metadata["fingerprint_sha256"] = fingerprint_sha256(certificate)
|
||||
metadata["fingerprint_sha1"] = hexlify(
|
||||
certificate.fingerprint(hashes.SHA1()), ":" # nosec
|
||||
).decode("utf-8")
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
raise CertificateError(f"Invalid certificate metadata: {exc}") from exc
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
@receiver(pre_save, sender="authentik_crypto.CertificateKeyPair")
|
||||
def certificate_key_pair_pre_save(
|
||||
sender: type[CertificateKeyPair], instance: CertificateKeyPair, **_
|
||||
):
|
||||
"""Automatically populate certificate metadata fields before saving"""
|
||||
|
||||
# Only extract metadata if certificate_data is present
|
||||
if not instance.certificate_data:
|
||||
return
|
||||
|
||||
try:
|
||||
metadata = extract_certificate_metadata(instance.certificate)
|
||||
except (CertificateError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("Failed to extract certificate metadata", exc=exc)
|
||||
return
|
||||
|
||||
instance.key_type = metadata["key_type"]
|
||||
instance.cert_expiry = metadata["cert_expiry"]
|
||||
instance.cert_subject = metadata["cert_subject"]
|
||||
instance.fingerprint_sha256 = metadata["fingerprint_sha256"]
|
||||
instance.fingerprint_sha1 = metadata["fingerprint_sha1"]
|
||||
|
||||
# Generate kid if not set, or regenerate if key_data has changed
|
||||
# Preserve existing kid (MD5 or SHA512) if it matches the current key_data
|
||||
if instance.key_data:
|
||||
new_kid = generate_key_id(instance.key_data)
|
||||
legacy_kid = generate_key_id_legacy(instance.key_data)
|
||||
if instance.kid not in (new_kid, legacy_kid):
|
||||
instance.kid = new_kid
|
||||
@@ -20,7 +20,7 @@ from authentik.core.tests.utils import (
|
||||
)
|
||||
from authentik.crypto.api import CertificateKeyPairSerializer
|
||||
from authentik.crypto.builder import CertificateBuilder
|
||||
from authentik.crypto.models import CertificateKeyPair, generate_key_id, generate_key_id_legacy
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
@@ -173,24 +173,21 @@ class TestCrypto(APITestCase):
|
||||
self.assertEqual(api_cert["fingerprint_sha1"], cert.fingerprint_sha1)
|
||||
self.assertEqual(api_cert["fingerprint_sha256"], cert.fingerprint_sha256)
|
||||
|
||||
def test_list_always_includes_details(self):
|
||||
"""Test API List always includes certificate details"""
|
||||
def test_list_without_details(self):
|
||||
"""Test API List (no details)"""
|
||||
cert = create_test_cert()
|
||||
self.client.force_login(create_test_admin_user())
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"authentik_api:certificatekeypair-list",
|
||||
),
|
||||
data={"name": cert.name},
|
||||
data={"name": cert.name, "include_details": False},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
api_cert = [x for x in body["results"] if x["name"] == cert.name][0]
|
||||
# All details should now always be included
|
||||
self.assertEqual(api_cert["fingerprint_sha1"], cert.fingerprint_sha1)
|
||||
self.assertEqual(api_cert["fingerprint_sha256"], cert.fingerprint_sha256)
|
||||
self.assertIsNotNone(api_cert["cert_expiry"])
|
||||
self.assertIsNotNone(api_cert["cert_subject"])
|
||||
self.assertEqual(api_cert["fingerprint_sha1"], None)
|
||||
self.assertEqual(api_cert["fingerprint_sha256"], None)
|
||||
|
||||
def test_certificate_download(self):
|
||||
"""Test certificate export (download)"""
|
||||
@@ -429,114 +426,3 @@ class TestCrypto(APITestCase):
|
||||
self.assertEqual(
|
||||
1, final_count, "Should not create duplicate cert for same private key"
|
||||
)
|
||||
|
||||
def test_metadata_extraction_with_cert_and_key(self):
|
||||
"""Test that metadata is extracted when creating keypair with certificate and key"""
|
||||
cert = create_test_cert()
|
||||
|
||||
# Verify all metadata fields are populated
|
||||
self.assertIsNotNone(cert.key_type)
|
||||
self.assertIsNotNone(cert.cert_expiry)
|
||||
self.assertIsNotNone(cert.cert_subject)
|
||||
self.assertIsNotNone(cert.fingerprint_sha256)
|
||||
self.assertIsNotNone(cert.fingerprint_sha1)
|
||||
|
||||
# Verify kid is generated using SHA512 for new records
|
||||
self.assertIsNotNone(cert.kid)
|
||||
self.assertEqual(cert.kid, generate_key_id(cert.key_data))
|
||||
|
||||
def test_metadata_extraction_without_key(self):
|
||||
"""Test that metadata is extracted when creating keypair without private key"""
|
||||
builder = CertificateBuilder(generate_id())
|
||||
builder.build(subject_alt_names=[], validity_days=3)
|
||||
|
||||
# Create keypair with only certificate, no key
|
||||
cert = CertificateKeyPair.objects.create(
|
||||
name=generate_id(),
|
||||
certificate_data=builder.certificate,
|
||||
key_data="",
|
||||
)
|
||||
|
||||
# Verify certificate metadata fields are populated
|
||||
self.assertIsNotNone(cert.key_type)
|
||||
self.assertIsNotNone(cert.cert_expiry)
|
||||
self.assertIsNotNone(cert.cert_subject)
|
||||
self.assertIsNotNone(cert.fingerprint_sha256)
|
||||
self.assertIsNotNone(cert.fingerprint_sha1)
|
||||
|
||||
# Verify kid is empty when no key_data
|
||||
self.assertEqual(cert.kid, None)
|
||||
|
||||
def test_metadata_extraction_invalid_cert(self):
|
||||
"""Test that invalid certificate data doesn't crash, just skips metadata"""
|
||||
cert = CertificateKeyPair.objects.create(
|
||||
name=generate_id(),
|
||||
certificate_data="invalid certificate data",
|
||||
key_data="",
|
||||
)
|
||||
|
||||
# Verify metadata fields are None for invalid cert
|
||||
self.assertIsNone(cert.key_type)
|
||||
self.assertIsNone(cert.cert_expiry)
|
||||
self.assertIsNone(cert.cert_subject)
|
||||
self.assertIsNone(cert.fingerprint_sha256)
|
||||
self.assertIsNone(cert.fingerprint_sha1)
|
||||
self.assertIsNone(cert.kid)
|
||||
|
||||
def test_kid_legacy_preservation(self):
|
||||
"""Test that legacy MD5 kid is preserved when key_data hasn't changed"""
|
||||
cert = create_test_cert()
|
||||
|
||||
# Simulate a legacy MD5 kid (as if backfilled from old system)
|
||||
legacy_kid = generate_key_id_legacy(cert.key_data)
|
||||
CertificateKeyPair.objects.filter(pk=cert.pk).update(kid=legacy_kid)
|
||||
cert.refresh_from_db()
|
||||
self.assertEqual(cert.kid, legacy_kid)
|
||||
|
||||
# Save the cert again (e.g., name change) - kid should be preserved
|
||||
cert.name = generate_id()
|
||||
cert.save()
|
||||
cert.refresh_from_db()
|
||||
|
||||
self.assertEqual(cert.kid, legacy_kid)
|
||||
|
||||
def test_kid_regenerated_on_key_change(self):
|
||||
"""Test that kid is regenerated when key_data changes"""
|
||||
cert = create_test_cert()
|
||||
original_kid = cert.kid
|
||||
|
||||
# Generate a new key and update the keypair
|
||||
builder = CertificateBuilder(generate_id())
|
||||
builder.build(subject_alt_names=[], validity_days=3)
|
||||
|
||||
cert.key_data = builder.private_key
|
||||
cert.certificate_data = builder.certificate
|
||||
cert.save()
|
||||
cert.refresh_from_db()
|
||||
|
||||
# Kid should be regenerated for the new key
|
||||
self.assertNotEqual(cert.kid, original_kid)
|
||||
self.assertEqual(cert.kid, generate_key_id(cert.key_data))
|
||||
|
||||
def test_kid_regenerated_on_key_change_from_legacy(self):
|
||||
"""Test that kid is regenerated from legacy MD5 when key_data changes"""
|
||||
cert = create_test_cert()
|
||||
|
||||
# Simulate a legacy MD5 kid
|
||||
legacy_kid = generate_key_id_legacy(cert.key_data)
|
||||
CertificateKeyPair.objects.filter(pk=cert.pk).update(kid=legacy_kid)
|
||||
cert.refresh_from_db()
|
||||
self.assertEqual(cert.kid, legacy_kid)
|
||||
|
||||
# Generate a new key and update the keypair
|
||||
builder = CertificateBuilder(generate_id())
|
||||
builder.build(subject_alt_names=[], validity_days=3)
|
||||
|
||||
cert.key_data = builder.private_key
|
||||
cert.certificate_data = builder.certificate
|
||||
cert.save()
|
||||
cert.refresh_from_db()
|
||||
|
||||
# Kid should now be SHA512 for the new key
|
||||
self.assertNotEqual(cert.kid, legacy_kid)
|
||||
self.assertEqual(cert.kid, generate_key_id(cert.key_data))
|
||||
|
||||
@@ -16,7 +16,7 @@ from authentik.endpoints.models import (
|
||||
)
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.lib.generators import generate_key
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -97,7 +97,7 @@ class AgentDeviceUserBinding(DeviceUserBinding):
|
||||
apple_enclave_key_id = models.TextField()
|
||||
|
||||
|
||||
class DeviceToken(InternallyManagedMixin, ExpiringModel):
|
||||
class DeviceToken(ExpiringModel):
|
||||
"""Per-device token used for authentication."""
|
||||
|
||||
token_uuid = models.UUIDField(primary_key=True, default=uuid4)
|
||||
@@ -143,7 +143,7 @@ class EnrollmentToken(ExpiringModel, SerializerModel):
|
||||
]
|
||||
|
||||
|
||||
class DeviceAuthenticationToken(InternallyManagedMixin, ExpiringModel):
|
||||
class DeviceAuthenticationToken(ExpiringModel):
|
||||
|
||||
identifier = models.UUIDField(default=uuid4, primary_key=True)
|
||||
device = models.ForeignKey(Device, on_delete=models.CASCADE)
|
||||
@@ -160,7 +160,7 @@ class DeviceAuthenticationToken(InternallyManagedMixin, ExpiringModel):
|
||||
verbose_name_plural = _("Device authentication tokens")
|
||||
|
||||
|
||||
class AppleNonce(InternallyManagedMixin, ExpiringModel):
|
||||
class AppleNonce(ExpiringModel):
|
||||
nonce = models.TextField()
|
||||
device_token = models.ForeignKey(DeviceToken, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from authentik.core.models import AttributesMixin, ExpiringModel
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.lib.merge import MERGE_LIST_UNIQUE
|
||||
from authentik.lib.models import InheritanceForeignKey, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.policies.models import PolicyBinding, PolicyBindingModel
|
||||
from authentik.tasks.schedules.common import ScheduleSpec
|
||||
@@ -28,7 +28,7 @@ LOGGER = get_logger()
|
||||
DEVICE_FACTS_CACHE_TIMEOUT = 3600
|
||||
|
||||
|
||||
class Device(InternallyManagedMixin, ExpiringModel, AttributesMixin, PolicyBindingModel):
|
||||
class Device(ExpiringModel, AttributesMixin, PolicyBindingModel):
|
||||
device_uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
@@ -86,7 +86,7 @@ class DeviceUserBinding(PolicyBinding):
|
||||
verbose_name_plural = _("Device User bindings")
|
||||
|
||||
|
||||
class DeviceConnection(InternallyManagedMixin, SerializerModel):
|
||||
class DeviceConnection(SerializerModel):
|
||||
device_connection_uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
device = models.ForeignKey("Device", on_delete=models.CASCADE)
|
||||
connector = models.ForeignKey("Connector", on_delete=models.CASCADE)
|
||||
@@ -115,7 +115,7 @@ class DeviceConnection(InternallyManagedMixin, SerializerModel):
|
||||
verbose_name_plural = _("Device connections")
|
||||
|
||||
|
||||
class DeviceFactSnapshot(InternallyManagedMixin, ExpiringModel, SerializerModel):
|
||||
class DeviceFactSnapshot(ExpiringModel, SerializerModel):
|
||||
snapshot_id = models.UUIDField(primary_key=True, default=uuid4)
|
||||
connection = models.ForeignKey(DeviceConnection, on_delete=models.CASCADE)
|
||||
data = models.JSONField(default=dict)
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.utils.translation import gettext as _
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
|
||||
from authentik.core.models import ExpiringModel
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.enterprise.license import LicenseKey
|
||||
@@ -81,7 +81,7 @@ class LicenseUsageStatus(models.TextChoices):
|
||||
return self in [LicenseUsageStatus.VALID, LicenseUsageStatus.EXPIRY_SOON]
|
||||
|
||||
|
||||
class LicenseUsage(InternallyManagedMixin, ExpiringModel):
|
||||
class LicenseUsage(ExpiringModel):
|
||||
"""a single license usage record"""
|
||||
|
||||
expires = models.DateTimeField(default=usage_expiry)
|
||||
|
||||
@@ -18,7 +18,7 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserTypes,
|
||||
)
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
|
||||
|
||||
@@ -32,7 +32,7 @@ def default_scopes() -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
class GoogleWorkspaceProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
class GoogleWorkspaceProviderUser(SerializerModel):
|
||||
"""Mapping of a user and provider to a Google user ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
@@ -58,7 +58,7 @@ class GoogleWorkspaceProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
return f"Google Workspace Provider User {self.user_id} to {self.provider_id}"
|
||||
|
||||
|
||||
class GoogleWorkspaceProviderGroup(InternallyManagedMixin, SerializerModel):
|
||||
class GoogleWorkspaceProviderGroup(SerializerModel):
|
||||
"""Mapping of a group and provider to a Google group ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
@@ -18,12 +18,12 @@ from authentik.core.models import (
|
||||
User,
|
||||
UserTypes,
|
||||
)
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncDeleteAction, OutgoingSyncProvider
|
||||
|
||||
|
||||
class MicrosoftEntraProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
class MicrosoftEntraProviderUser(SerializerModel):
|
||||
"""Mapping of a user and provider to a Microsoft user ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
@@ -49,7 +49,7 @@ class MicrosoftEntraProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
return f"Microsoft Entra Provider User {self.user_id} to {self.provider_id}"
|
||||
|
||||
|
||||
class MicrosoftEntraProviderGroup(InternallyManagedMixin, SerializerModel):
|
||||
class MicrosoftEntraProviderGroup(SerializerModel):
|
||||
"""Mapping of a group and provider to a Microsoft group ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
@@ -14,7 +14,7 @@ from jwt import encode
|
||||
|
||||
from authentik.core.models import BackchannelProvider, ExpiringModel, Token
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import CreatedUpdatedModel, InternallyManagedMixin
|
||||
from authentik.lib.models import CreatedUpdatedModel
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider
|
||||
from authentik.tasks.models import TasksModel
|
||||
@@ -153,7 +153,7 @@ class Stream(models.Model):
|
||||
return encode(data, key, algorithm=alg, headers=headers)
|
||||
|
||||
|
||||
class StreamEvent(InternallyManagedMixin, CreatedUpdatedModel, ExpiringModel):
|
||||
class StreamEvent(CreatedUpdatedModel, ExpiringModel):
|
||||
"""Single stream event to be sent"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, primary_key=True, editable=False)
|
||||
|
||||
@@ -11,7 +11,7 @@ from rest_framework.serializers import BaseSerializer, Serializer
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.flows.stage import StageView
|
||||
from authentik.lib.models import DeprecatedMixin, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import DeprecatedMixin, SerializerModel
|
||||
from authentik.stages.authenticator.models import Device
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ class AuthenticatorEndpointGDTCStage(DeprecatedMixin, ConfigurableStage, Friendl
|
||||
verbose_name_plural = _("Endpoint Authenticator Google Device Trust Connector Stages")
|
||||
|
||||
|
||||
class EndpointDevice(InternallyManagedMixin, SerializerModel, Device):
|
||||
class EndpointDevice(SerializerModel, Device):
|
||||
"""Endpoint Device for a single user"""
|
||||
|
||||
uuid = models.UUIDField(primary_key=True, default=uuid4)
|
||||
@@ -91,7 +91,7 @@ class EndpointDevice(InternallyManagedMixin, SerializerModel, Device):
|
||||
verbose_name_plural = _("Endpoint Devices")
|
||||
|
||||
|
||||
class EndpointDeviceConnection(InternallyManagedMixin, models.Model):
|
||||
class EndpointDeviceConnection(models.Model):
|
||||
device = models.ForeignKey(EndpointDevice, on_delete=models.CASCADE)
|
||||
stage = models.ForeignKey(AuthenticatorEndpointGDTCStage, on_delete=models.CASCADE)
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from authentik.blueprints.v1.importer import excluded_models
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.events.models import Event, EventAction, Notification
|
||||
from authentik.events.utils import model_to_dict
|
||||
from authentik.lib.models import InternallyManagedMixin
|
||||
from authentik.lib.sentry import should_ignore_exception
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.stages.authenticator_static.models import StaticToken
|
||||
@@ -41,7 +40,7 @@ _CTX_REQUEST = ContextVar[HttpRequest | None]("authentik_events_log_request", de
|
||||
|
||||
def should_log_model(model: Model) -> bool:
|
||||
"""Return true if operation on `model` should be logged"""
|
||||
return model.__class__ not in IGNORED_MODELS and not isinstance(model, InternallyManagedMixin)
|
||||
return model.__class__ not in IGNORED_MODELS
|
||||
|
||||
|
||||
def should_log_m2m(model: Model) -> bool:
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
from typing import cast
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.request import Request
|
||||
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
|
||||
|
||||
class FlowActive(BaseAuthentication):
|
||||
"""Authenticate requests when a flow is currently active"""
|
||||
|
||||
def authenticate(self, request: Request):
|
||||
plan = cast(FlowPlan | None, request.session.get(SESSION_KEY_PLAN))
|
||||
if not plan:
|
||||
return None
|
||||
return (plan.context.get(PLAN_CONTEXT_PENDING_USER, AnonymousUser()), plan)
|
||||
@@ -20,7 +20,7 @@ from authentik.core.models import Token
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.flows.challenge import FlowLayout
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import InheritanceForeignKey, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
|
||||
@@ -301,7 +301,7 @@ class FriendlyNamedStage(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
class FlowToken(InternallyManagedMixin, Token):
|
||||
class FlowToken(Token):
|
||||
"""Subclass of a standard Token, stores the currently active flow plan upon creation.
|
||||
Can be used to later resume a flow."""
|
||||
|
||||
|
||||
@@ -48,9 +48,6 @@ class FlowTestCase(APITestCase):
|
||||
self.assertEqual(raw_response[key], expected)
|
||||
return raw_response
|
||||
|
||||
def get_flow_plan(self) -> FlowPlan | None:
|
||||
return self.client.session.get(SESSION_KEY_PLAN)
|
||||
|
||||
def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]:
|
||||
"""Wrapper around assertStageResponse that checks for a redirect"""
|
||||
return self.assertStageResponse(response, component="xak-flow-redirect", to=to)
|
||||
|
||||
@@ -147,8 +147,6 @@ class FlowExecutorView(APIView):
|
||||
token.delete()
|
||||
if not isinstance(plan, FlowPlan):
|
||||
return None
|
||||
if existing_plan := self.request.session.get(SESSION_KEY_PLAN):
|
||||
plan.context.update(existing_plan.context)
|
||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
||||
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
|
||||
return plan
|
||||
|
||||
@@ -111,7 +111,6 @@ def get_logger_config():
|
||||
"hpack": "WARNING",
|
||||
"httpx": "WARNING",
|
||||
"azure": "WARNING",
|
||||
"httpcore": "WARNING",
|
||||
}
|
||||
for handler_name, level in handler_level_map.items():
|
||||
base_config["loggers"][handler_name] = {
|
||||
|
||||
@@ -64,10 +64,6 @@ class DeprecatedMixin:
|
||||
"""Mixin for classes that are deprecated"""
|
||||
|
||||
|
||||
class InternallyManagedMixin:
|
||||
"""Mixin for models that should _not_ be manageable via blueprint."""
|
||||
|
||||
|
||||
class DomainlessURLValidator(URLValidator):
|
||||
"""Subclass of URLValidator which doesn't check the domain
|
||||
(to allow hostnames without domain)"""
|
||||
|
||||
@@ -1,61 +1,25 @@
|
||||
from json import JSONDecodeError
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
|
||||
class BaseSyncException(SentryIgnoredException):
|
||||
"""Base class for all sync exceptions"""
|
||||
|
||||
error_prefix = "Sync error"
|
||||
error_default = "Error communicating with remote system"
|
||||
|
||||
def __init__(self, response=None):
|
||||
super().__init__()
|
||||
self.response = response
|
||||
|
||||
def __str__(self):
|
||||
if self.response is not None:
|
||||
if hasattr(self.response, "json"):
|
||||
try:
|
||||
return f"{self.error_prefix}: {self.response.json()}"
|
||||
except JSONDecodeError:
|
||||
pass
|
||||
if hasattr(self.response, "text"):
|
||||
return f"{self.error_prefix}: {self.response.text}"
|
||||
return f"{self.error_prefix}: {self.response}"
|
||||
return self.error_default
|
||||
|
||||
def __repr__(self):
|
||||
return self.__str__()
|
||||
|
||||
|
||||
class TransientSyncException(BaseSyncException):
|
||||
"""Transient sync exception which may be caused by network blips, etc"""
|
||||
|
||||
error_prefix = "Network error"
|
||||
error_default = "Network error communicating with remote system"
|
||||
|
||||
|
||||
class NotFoundSyncException(BaseSyncException):
|
||||
"""Exception when an object was not found in the remote system"""
|
||||
|
||||
error_prefix = "Object not found"
|
||||
error_default = "Object not found in remote system"
|
||||
|
||||
|
||||
class ObjectExistsSyncException(BaseSyncException):
|
||||
"""Exception when an object already exists in the remote system"""
|
||||
|
||||
error_prefix = "Object exists"
|
||||
error_default = "Object exists in remote system"
|
||||
|
||||
|
||||
class BadRequestSyncException(BaseSyncException):
|
||||
"""Exception when invalid data was sent to the remote system"""
|
||||
|
||||
error_prefix = "Bad request"
|
||||
error_default = "Bad request to remote system"
|
||||
|
||||
|
||||
class DryRunRejected(BaseSyncException):
|
||||
"""When dry_run is enabled and a provider dropped a mutating request"""
|
||||
|
||||
@@ -9,7 +9,6 @@ from rest_framework.serializers import BaseSerializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.v1.importer import is_model_allowed
|
||||
from authentik.blueprints.v1.meta.registry import BaseMetaModel
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.policies.models import Policy
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
@@ -32,7 +31,7 @@ def model_choices() -> list[tuple[str, str]]:
|
||||
Returns a list of tuples containing (dotted.model.path, name)"""
|
||||
choices = []
|
||||
for model in apps.get_models():
|
||||
if not is_model_allowed(model) or issubclass(model, BaseMetaModel):
|
||||
if not is_model_allowed(model):
|
||||
continue
|
||||
name = f"{model._meta.app_label}.{model._meta.model_name}"
|
||||
choices.append((name, model._meta.verbose_name))
|
||||
|
||||
@@ -14,7 +14,7 @@ from structlog import get_logger
|
||||
|
||||
from authentik.core.models import ExpiringModel
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.policies.models import Policy
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
from authentik.root.middleware import ClientIPMiddleware
|
||||
@@ -69,7 +69,7 @@ class ReputationPolicy(Policy):
|
||||
verbose_name_plural = _("Reputation Policies")
|
||||
|
||||
|
||||
class Reputation(InternallyManagedMixin, ExpiringModel, SerializerModel):
|
||||
class Reputation(ExpiringModel, SerializerModel):
|
||||
"""Reputation for user and or IP."""
|
||||
|
||||
objects = PostgresManager()
|
||||
|
||||
@@ -42,7 +42,7 @@ from authentik.core.models import (
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.generators import generate_code_fixed_length, generate_id, generate_key
|
||||
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import DomainlessURLValidator, SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.providers.oauth2.constants import SubModes
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
@@ -462,7 +462,7 @@ class BaseGrantModel(models.Model):
|
||||
self._scope = " ".join(value)
|
||||
|
||||
|
||||
class AuthorizationCode(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
class AuthorizationCode(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Authorization Code"""
|
||||
|
||||
code = models.CharField(max_length=255, unique=True, verbose_name=_("Code"))
|
||||
@@ -497,7 +497,7 @@ class AuthorizationCode(InternallyManagedMixin, SerializerModel, ExpiringModel,
|
||||
)
|
||||
|
||||
|
||||
class AccessToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
class AccessToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 access token, non-opaque using a JWT as identifier"""
|
||||
|
||||
token = models.TextField()
|
||||
@@ -545,7 +545,7 @@ class AccessToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGr
|
||||
return TokenModelSerializer
|
||||
|
||||
|
||||
class RefreshToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
class RefreshToken(SerializerModel, ExpiringModel, BaseGrantModel):
|
||||
"""OAuth2 Refresh Token, opaque"""
|
||||
|
||||
token = models.TextField(default=generate_client_secret)
|
||||
@@ -585,7 +585,7 @@ class RefreshToken(InternallyManagedMixin, SerializerModel, ExpiringModel, BaseG
|
||||
return TokenModelSerializer
|
||||
|
||||
|
||||
class DeviceToken(InternallyManagedMixin, ExpiringModel):
|
||||
class DeviceToken(ExpiringModel):
|
||||
"""Temporary device token for OAuth device flow"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
|
||||
@@ -13,7 +13,7 @@ from rest_framework.serializers import Serializer
|
||||
|
||||
from authentik.core.models import ExpiringModel
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.providers.oauth2.models import (
|
||||
ClientTypes,
|
||||
@@ -27,7 +27,7 @@ SCOPE_AK_PROXY = "ak_proxy"
|
||||
OUTPOST_CALLBACK_SIGNATURE = "X-authentik-auth-callback"
|
||||
|
||||
|
||||
class ProxySession(InternallyManagedMixin, ExpiringModel):
|
||||
class ProxySession(ExpiringModel):
|
||||
"""Session storage for proxyv2 outposts using PostgreSQL"""
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
@@ -15,7 +15,7 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.expression.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import ExpiringModel, PropertyMapping, Provider, User, default_token_key
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
@@ -145,7 +145,7 @@ class RACPropertyMapping(PropertyMapping):
|
||||
verbose_name_plural = _("RAC Provider Property Mappings")
|
||||
|
||||
|
||||
class ConnectionToken(InternallyManagedMixin, ExpiringModel):
|
||||
class ConnectionToken(ExpiringModel):
|
||||
"""Token for a single connection to a specified endpoint"""
|
||||
|
||||
connection_token_uuid = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
@@ -18,7 +18,7 @@ from authentik.core.models import (
|
||||
User,
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator, InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import DomainlessURLValidator, SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
@@ -303,7 +303,7 @@ class SAMLProviderImportModel(CreatableType, Provider):
|
||||
verbose_name_plural = _("SAML Providers from Metadata")
|
||||
|
||||
|
||||
class SAMLSession(InternallyManagedMixin, SerializerModel, ExpiringModel):
|
||||
class SAMLSession(SerializerModel, ExpiringModel):
|
||||
"""Track active SAML sessions for Single Logout support"""
|
||||
|
||||
saml_session_id = models.UUIDField(default=uuid4, primary_key=True)
|
||||
|
||||
@@ -14,7 +14,6 @@ class SCIMRequestException(TransientSyncException):
|
||||
_message: str | None
|
||||
|
||||
def __init__(self, response: Response | None = None, message: str | None = None) -> None:
|
||||
super().__init__(response)
|
||||
self._response = response
|
||||
self._message = message
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import BackchannelProvider, Group, PropertyMapping, User, UserTypes
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.sync.outgoing.base import BaseOutgoingSyncClient
|
||||
from authentik.lib.sync.outgoing.models import OutgoingSyncProvider
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
@@ -22,7 +22,7 @@ from authentik.providers.scim.clients.auth import SCIMTokenAuth
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class SCIMProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
class SCIMProviderUser(SerializerModel):
|
||||
"""Mapping of a user and provider to a SCIM user ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
@@ -44,7 +44,7 @@ class SCIMProviderUser(InternallyManagedMixin, SerializerModel):
|
||||
return f"SCIM Provider User {self.user_id} to {self.provider_id}"
|
||||
|
||||
|
||||
class SCIMProviderGroup(InternallyManagedMixin, SerializerModel):
|
||||
class SCIMProviderGroup(SerializerModel):
|
||||
"""Mapping of a group and provider to a SCIM user ID"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.http import Http404
|
||||
from django_filters.filters import AllValuesMultipleFilter, BooleanFilter
|
||||
from django_filters.filters import AllValuesMultipleFilter, BooleanFilter, CharFilter, NumberFilter
|
||||
from django_filters.filterset import FilterSet
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_field
|
||||
@@ -22,7 +22,7 @@ from authentik.blueprints.api import ManagedSerializer
|
||||
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.rbac.models import Role, get_permission_choices
|
||||
|
||||
@@ -65,15 +65,63 @@ class RoleSerializer(ManagedSerializer, ModelSerializer):
|
||||
|
||||
|
||||
class RoleFilterSet(FilterSet):
|
||||
"""Filter for PropertyMapping"""
|
||||
"""Filter for Role"""
|
||||
|
||||
managed = extend_schema_field(OpenApiTypes.STR)(AllValuesMultipleFilter(field_name="managed"))
|
||||
|
||||
managed__isnull = BooleanFilter(field_name="managed", lookup_expr="isnull")
|
||||
|
||||
inherited = BooleanFilter(
|
||||
method="filter_inherited",
|
||||
label="Include inherited roles (requires users or ak_groups filter)",
|
||||
)
|
||||
|
||||
users = extend_schema_field(OpenApiTypes.INT)(
|
||||
NumberFilter(
|
||||
method="filter_users",
|
||||
label="Filter by user (use with inherited=true for all roles)",
|
||||
)
|
||||
)
|
||||
|
||||
ak_groups = extend_schema_field(OpenApiTypes.UUID)(
|
||||
CharFilter(
|
||||
method="filter_ak_groups",
|
||||
label="Filter by group (use with inherited=true for all roles)",
|
||||
)
|
||||
)
|
||||
|
||||
def filter_inherited(self, queryset, name, value):
|
||||
"""This filter is handled by filter_users and filter_ak_groups"""
|
||||
return queryset
|
||||
|
||||
def filter_users(self, queryset, name, value):
|
||||
"""Filter roles by user, optionally including inherited roles"""
|
||||
user = User.objects.filter(pk=value).first()
|
||||
if not user:
|
||||
return queryset.none()
|
||||
|
||||
include_inherited = self.data.get("inherited", "").lower() == "true"
|
||||
if include_inherited:
|
||||
return user.all_roles()
|
||||
return queryset.filter(users=user)
|
||||
|
||||
def filter_ak_groups(self, queryset, name, value):
|
||||
"""Filter roles by group, optionally including inherited roles"""
|
||||
group = Group.objects.filter(pk=value).first()
|
||||
if not group:
|
||||
return queryset.none()
|
||||
|
||||
include_inherited = self.data.get("inherited", "").lower() == "true"
|
||||
if include_inherited:
|
||||
return group.all_roles()
|
||||
return queryset.filter(ak_groups=group)
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ["name", "users", "managed"]
|
||||
fields = [
|
||||
"name",
|
||||
"managed",
|
||||
]
|
||||
|
||||
|
||||
class RoleViewSet(UsedByMixin, ModelViewSet):
|
||||
|
||||
@@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.serializers import BaseSerializer, Serializer
|
||||
|
||||
from authentik.core.models import Group, PropertyMapping, Source, Token, User
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
|
||||
|
||||
class SCIMSource(Source):
|
||||
@@ -101,7 +101,7 @@ class SCIMSourcePropertyMapping(PropertyMapping):
|
||||
verbose_name_plural = _("SCIM Source Property Mappings")
|
||||
|
||||
|
||||
class SCIMSourceUser(InternallyManagedMixin, SerializerModel):
|
||||
class SCIMSourceUser(SerializerModel):
|
||||
"""Mapping of a user and source to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True, default=uuid4)
|
||||
@@ -127,7 +127,7 @@ class SCIMSourceUser(InternallyManagedMixin, SerializerModel):
|
||||
return f"SCIM User {self.user_id} to {self.source_id}"
|
||||
|
||||
|
||||
class SCIMSourceGroup(InternallyManagedMixin, SerializerModel):
|
||||
class SCIMSourceGroup(SerializerModel):
|
||||
"""Mapping of a group and source to a SCIM user ID"""
|
||||
|
||||
id = models.TextField(primary_key=True, default=uuid4)
|
||||
|
||||
@@ -9,7 +9,7 @@ from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, ChoiceField, IntegerField
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet
|
||||
@@ -21,11 +21,9 @@ from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import ModelSerializer
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.api.stages import StageSerializer
|
||||
from authentik.flows.auth import FlowActive
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.rbac.decorators import permission_required
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
from authentik.stages.authenticator_duo.stage import PLAN_CONTEXT_DUO_ENROLL
|
||||
from authentik.stages.authenticator_duo.stage import SESSION_KEY_DUO_ENROLL
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -86,20 +84,14 @@ class AuthenticatorDuoStageViewSet(UsedByMixin, ModelViewSet):
|
||||
),
|
||||
},
|
||||
)
|
||||
@action(
|
||||
methods=["POST"],
|
||||
detail=True,
|
||||
authentication_classes=[FlowActive],
|
||||
permission_classes=[AllowAny],
|
||||
)
|
||||
@action(methods=["POST"], detail=True, permission_classes=[IsAuthenticated])
|
||||
def enrollment_status(self, request: Request, pk: str) -> Response:
|
||||
"""Check enrollment status of user details in current session"""
|
||||
stage: AuthenticatorDuoStage = AuthenticatorDuoStage.objects.filter(pk=pk).first()
|
||||
if not stage:
|
||||
raise Http404
|
||||
client = stage.auth_client()
|
||||
plan: FlowPlan = request.auth
|
||||
enroll = plan.context.get(PLAN_CONTEXT_DUO_ENROLL)
|
||||
enroll = self.request.session.get(SESSION_KEY_DUO_ENROLL)
|
||||
if not enroll:
|
||||
return Response(status=400)
|
||||
status = client.enroll_status(enroll["user_id"], enroll["activation_code"])
|
||||
|
||||
@@ -14,7 +14,7 @@ from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.views.executor import InvalidStageError
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
|
||||
PLAN_CONTEXT_DUO_ENROLL = "goauthentik.io/stages/authenticator_duo/enroll"
|
||||
SESSION_KEY_DUO_ENROLL = "authentik/stages/authenticator_duo/enroll"
|
||||
|
||||
|
||||
class AuthenticatorDuoChallenge(WithUserInfoChallenge):
|
||||
@@ -50,14 +50,14 @@ class AuthenticatorDuoStageView(ChallengeStageView):
|
||||
user=user,
|
||||
).from_http(self.request, user)
|
||||
raise InvalidStageError(str(exc)) from exc
|
||||
self.executor.plan.context[PLAN_CONTEXT_DUO_ENROLL] = enroll
|
||||
self.request.session[SESSION_KEY_DUO_ENROLL] = enroll
|
||||
return enroll
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
stage: AuthenticatorDuoStage = self.executor.current_stage
|
||||
if PLAN_CONTEXT_DUO_ENROLL not in self.executor.plan.context:
|
||||
if SESSION_KEY_DUO_ENROLL not in self.request.session:
|
||||
self.duo_enroll()
|
||||
enroll = self.executor.plan.context[PLAN_CONTEXT_DUO_ENROLL]
|
||||
enroll = self.request.session[SESSION_KEY_DUO_ENROLL]
|
||||
return AuthenticatorDuoChallenge(
|
||||
data={
|
||||
"activation_barcode": enroll["activation_barcode"],
|
||||
@@ -69,14 +69,14 @@ class AuthenticatorDuoStageView(ChallengeStageView):
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
# Duo Challenge has already been validated
|
||||
stage: AuthenticatorDuoStage = self.executor.current_stage
|
||||
enroll = self.executor.plan.context.get(PLAN_CONTEXT_DUO_ENROLL)
|
||||
enroll = self.request.session.get(SESSION_KEY_DUO_ENROLL)
|
||||
enroll_status = stage.auth_client().enroll_status(
|
||||
enroll["user_id"], enroll["activation_code"]
|
||||
)
|
||||
if enroll_status != "success":
|
||||
return self.executor.stage_invalid(f"Invalid enrollment status: {enroll_status}.")
|
||||
existing_device = DuoDevice.objects.filter(duo_user_id=enroll["user_id"]).first()
|
||||
self.executor.plan.context.pop(PLAN_CONTEXT_DUO_ENROLL)
|
||||
self.request.session.pop(SESSION_KEY_DUO_ENROLL)
|
||||
if not existing_device:
|
||||
DuoDevice.objects.create(
|
||||
name="Duo Authenticator",
|
||||
@@ -88,3 +88,6 @@ class AuthenticatorDuoStageView(ChallengeStageView):
|
||||
else:
|
||||
return self.executor.stage_invalid("Device with Credential ID already exists.")
|
||||
return self.executor.stage_ok()
|
||||
|
||||
def cleanup(self):
|
||||
self.request.session.pop(SESSION_KEY_DUO_ENROLL, None)
|
||||
|
||||
@@ -11,6 +11,7 @@ from authentik.flows.models import FlowStageBinding
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice
|
||||
from authentik.stages.authenticator_duo.stage import SESSION_KEY_DUO_ENROLL
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
|
||||
@@ -50,6 +51,42 @@ class AuthenticatorDuoStageTests(FlowTestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_api_enrollment(self):
|
||||
"""Test `enrollment_status`"""
|
||||
self.client.force_login(self.user)
|
||||
stage = AuthenticatorDuoStage.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_id(),
|
||||
api_hostname=generate_id(),
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:authenticatorduostage-enrollment-status",
|
||||
kwargs={
|
||||
"pk": str(stage.pk),
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
session = self.client.session
|
||||
session[SESSION_KEY_DUO_ENROLL] = {"user_id": "foo", "activation_code": "bar"}
|
||||
session.save()
|
||||
|
||||
with patch("duo_client.auth.Auth.enroll_status", MagicMock(return_value="foo")):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:authenticatorduostage-enrollment-status",
|
||||
kwargs={
|
||||
"pk": str(stage.pk),
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content.decode(), '{"duo_response":"foo"}')
|
||||
|
||||
def test_api_import_manual_invalid_username(self):
|
||||
"""Test `import_device_manual`"""
|
||||
self.client.force_login(self.user)
|
||||
@@ -277,17 +314,6 @@ class AuthenticatorDuoStageTests(FlowTestCase):
|
||||
self.assertEqual(enroll_mock.call_count, 1)
|
||||
|
||||
with patch("duo_client.auth.Auth.enroll_status", MagicMock(return_value="success")):
|
||||
response = self.client.post(
|
||||
reverse(
|
||||
"authentik_api:authenticatorduostage-enrollment-status",
|
||||
kwargs={
|
||||
"pk": str(stage.pk),
|
||||
},
|
||||
)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(response.content, {"duo_response": "success"})
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), {}
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ from authentik.stages.email.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
PLAN_CONTEXT_EMAIL_DEVICE = "goauthentik.io/stages/authenticator_email/email_device"
|
||||
SESSION_KEY_EMAIL_DEVICE = "authentik/stages/authenticator_email/email_device"
|
||||
PLAN_CONTEXT_EMAIL = "email"
|
||||
PLAN_CONTEXT_EMAIL_SENT = "email_sent"
|
||||
PLAN_CONTEXT_EMAIL_OVERRIDE = "email"
|
||||
@@ -79,7 +79,7 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
if EmailDevice.objects.filter(Q(email=email), stage=stage.pk).exists():
|
||||
raise ValidationError(_("Invalid email"))
|
||||
|
||||
device: EmailDevice = self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
|
||||
try:
|
||||
message = TemplateEmailMessage(
|
||||
@@ -116,9 +116,9 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
self.logger.debug("got email from plan context")
|
||||
return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_EMAIL)
|
||||
# Check device for email
|
||||
if PLAN_CONTEXT_EMAIL_DEVICE in self.executor.plan.context:
|
||||
if SESSION_KEY_EMAIL_DEVICE in self.request.session:
|
||||
self.logger.debug("got email from device in session")
|
||||
device: EmailDevice = self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
if device.email == "":
|
||||
return None
|
||||
return device.email
|
||||
@@ -135,7 +135,7 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
|
||||
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
|
||||
response = super().get_response_instance(data)
|
||||
response.device = self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
response.device = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
return response
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
@@ -147,11 +147,11 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
return self.executor.stage_invalid(
|
||||
_("The user already has an email address registered for MFA.")
|
||||
)
|
||||
if PLAN_CONTEXT_EMAIL_DEVICE not in self.executor.plan.context:
|
||||
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()
|
||||
device.generate_token(valid_secs=valid_secs, commit=False)
|
||||
self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE] = device
|
||||
self.request.session[SESSION_KEY_EMAIL_DEVICE] = device
|
||||
if email := self._has_email():
|
||||
device.email = email
|
||||
try:
|
||||
@@ -165,16 +165,16 @@ class AuthenticatorEmailStageView(ChallengeStageView):
|
||||
self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop(
|
||||
PLAN_CONTEXT_EMAIL, None
|
||||
)
|
||||
self.executor.plan.context.pop(PLAN_CONTEXT_EMAIL_DEVICE, None)
|
||||
self.request.session.pop(SESSION_KEY_EMAIL_DEVICE, None)
|
||||
self.logger.warning("failed to send email to pre-set address", exc=exc)
|
||||
return self.get(request, *args, **kwargs)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
"""Email Token is validated by challenge"""
|
||||
device: EmailDevice = self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
device: EmailDevice = self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
if not device.confirmed:
|
||||
return self.challenge_invalid(response)
|
||||
device.save()
|
||||
del self.executor.plan.context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
del self.request.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
return self.executor.stage_ok()
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.template.exceptions import TemplateDoesNotExist
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
|
||||
from authentik.core.tests.utils import create_test_flow, create_test_user
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user
|
||||
from authentik.flows.models import FlowStageBinding
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.lib.config import CONFIG
|
||||
@@ -21,7 +21,9 @@ from authentik.stages.authenticator_email.api import (
|
||||
EmailDeviceSerializer,
|
||||
)
|
||||
from authentik.stages.authenticator_email.models import AuthenticatorEmailStage, EmailDevice
|
||||
from authentik.stages.authenticator_email.stage import PLAN_CONTEXT_EMAIL_DEVICE
|
||||
from authentik.stages.authenticator_email.stage import (
|
||||
SESSION_KEY_EMAIL_DEVICE,
|
||||
)
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
|
||||
|
||||
@@ -31,7 +33,7 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.flow = create_test_flow()
|
||||
self.user = create_test_user()
|
||||
self.user = create_test_admin_user()
|
||||
self.user_noemail = create_test_user(email="")
|
||||
self.stage = AuthenticatorEmailStage.objects.create(
|
||||
name="email-authenticator",
|
||||
@@ -211,26 +213,20 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email"},
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
response_errors={"non_field_errors": [{"code": "invalid", "string": "email required"}]},
|
||||
)
|
||||
self.assertIn("email required", str(response.content))
|
||||
|
||||
# Test invalid code
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email", "code": "000000"},
|
||||
)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
response_errors={
|
||||
"non_field_errors": [{"code": "invalid", "string": "Code does not match"}]
|
||||
},
|
||||
)
|
||||
self.assertIn("Code does not match", str(response.content))
|
||||
|
||||
# Test valid code
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
device = self.device
|
||||
token = device.token
|
||||
response = self.client.post(
|
||||
@@ -289,7 +285,8 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
)
|
||||
device = self.get_flow_plan().context[PLAN_CONTEXT_EMAIL_DEVICE]
|
||||
self.assertIn(SESSION_KEY_EMAIL_DEVICE, self.client.session)
|
||||
device = self.client.session[SESSION_KEY_EMAIL_DEVICE]
|
||||
self.assertIsInstance(device, EmailDevice)
|
||||
self.assertFalse(device.confirmed)
|
||||
self.assertEqual(device.user, self.user)
|
||||
@@ -297,6 +294,8 @@ class TestAuthenticatorEmailStage(FlowTestCase):
|
||||
# Test device confirmation and cleanup
|
||||
device.confirmed = True
|
||||
device.email = "new_test@authentik.local" # Use a different email
|
||||
self.client.session[SESSION_KEY_EMAIL_DEVICE] = device
|
||||
self.client.session.save()
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
data={"component": "ak-stage-authenticator-email", "code": device.token},
|
||||
|
||||
@@ -20,7 +20,7 @@ from authentik.stages.authenticator_sms.models import (
|
||||
)
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
PLAN_CONTEXT_SMS_DEVICE = "goauthentik.io/stages/authenticator_sms/sms_device"
|
||||
SESSION_KEY_SMS_DEVICE = "authentik/stages/authenticator_sms/sms_device"
|
||||
PLAN_CONTEXT_PHONE = "phone"
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
if SMSDevice.objects.filter(query, stage=stage.pk).exists():
|
||||
raise ValidationError(_("Invalid phone number"))
|
||||
# No code yet, but we have a phone number, so send a verification message
|
||||
device: SMSDevice = self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
stage.send(self.request, device.token, device)
|
||||
|
||||
def _has_phone_number(self) -> str | None:
|
||||
@@ -78,9 +78,9 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
if PLAN_CONTEXT_PHONE in context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||
self.logger.debug("got phone number from plan context")
|
||||
return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_PHONE)
|
||||
if PLAN_CONTEXT_SMS_DEVICE in self.executor.plan.context:
|
||||
if SESSION_KEY_SMS_DEVICE in self.request.session:
|
||||
self.logger.debug("got phone number from device in session")
|
||||
device: SMSDevice = self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
if device.phone_number == "":
|
||||
return None
|
||||
return device.phone_number
|
||||
@@ -95,7 +95,7 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
|
||||
def get_response_instance(self, data: QueryDict) -> ChallengeResponse:
|
||||
response = super().get_response_instance(data)
|
||||
response.device = self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
response.device = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
return response
|
||||
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
@@ -103,10 +103,10 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
|
||||
stage: AuthenticatorSMSStage = self.executor.current_stage
|
||||
|
||||
if PLAN_CONTEXT_SMS_DEVICE not in self.executor.plan.context:
|
||||
if SESSION_KEY_SMS_DEVICE not in self.request.session:
|
||||
device = SMSDevice(user=user, confirmed=False, stage=stage, name="SMS Device")
|
||||
device.generate_token(commit=False)
|
||||
self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE] = device
|
||||
self.request.session[SESSION_KEY_SMS_DEVICE] = device
|
||||
if phone_number := self._has_phone_number():
|
||||
device.phone_number = phone_number
|
||||
try:
|
||||
@@ -120,14 +120,14 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop(
|
||||
PLAN_CONTEXT_PHONE, None
|
||||
)
|
||||
self.executor.plan.context.pop(PLAN_CONTEXT_SMS_DEVICE, None)
|
||||
self.request.session.pop(SESSION_KEY_SMS_DEVICE, None)
|
||||
self.logger.warning("failed to send SMS message to pre-set number", exc=exc)
|
||||
return self.get(request, *args, **kwargs)
|
||||
return super().get(request, *args, **kwargs)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
"""SMS Token is validated by challenge"""
|
||||
device: SMSDevice = self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
if not device.confirmed:
|
||||
return self.challenge_invalid(response)
|
||||
stage: AuthenticatorSMSStage = self.executor.current_stage
|
||||
@@ -135,5 +135,5 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||
self.logger.debug("Hashing number on device")
|
||||
device.set_hashed_number()
|
||||
device.save()
|
||||
del self.executor.plan.context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
del self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||
return self.executor.stage_ok()
|
||||
|
||||
@@ -18,7 +18,7 @@ from authentik.stages.authenticator_sms.models import (
|
||||
SMSProviders,
|
||||
hash_phone_number,
|
||||
)
|
||||
from authentik.stages.authenticator_sms.stage import PLAN_CONTEXT_PHONE, PLAN_CONTEXT_SMS_DEVICE
|
||||
from authentik.stages.authenticator_sms.stage import PLAN_CONTEXT_PHONE, SESSION_KEY_SMS_DEVICE
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ class AuthenticatorSMSStageTests(FlowTestCase):
|
||||
self.assertEqual(mocker.call_count, 1)
|
||||
self.assertEqual(mocker.request_history[0].method, "POST")
|
||||
request_body = dict(parse_qsl(mocker.request_history[0].body))
|
||||
device: SMSDevice = self.get_flow_plan().context[PLAN_CONTEXT_SMS_DEVICE]
|
||||
device: SMSDevice = self.client.session[SESSION_KEY_SMS_DEVICE]
|
||||
self.assertEqual(
|
||||
request_body,
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@ from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.stages.authenticator.models import Device
|
||||
|
||||
UNKNOWN_DEVICE_TYPE_AAGUID = "00000000-0000-0000-0000-000000000000"
|
||||
@@ -164,7 +164,7 @@ class WebAuthnDevice(SerializerModel, Device):
|
||||
verbose_name_plural = _("WebAuthn Devices")
|
||||
|
||||
|
||||
class WebAuthnDeviceType(InternallyManagedMixin, SerializerModel):
|
||||
class WebAuthnDeviceType(SerializerModel):
|
||||
"""WebAuthn device type, used to restrict which device types are allowed"""
|
||||
|
||||
aaguid = models.UUIDField(primary_key=True, unique=True)
|
||||
|
||||
@@ -7,7 +7,7 @@ from rest_framework.serializers import BaseSerializer, Serializer
|
||||
|
||||
from authentik.core.models import Application, ExpiringModel, User
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class ConsentStage(Stage):
|
||||
verbose_name_plural = _("Consent Stages")
|
||||
|
||||
|
||||
class UserConsent(InternallyManagedMixin, SerializerModel, ExpiringModel):
|
||||
class UserConsent(SerializerModel, ExpiringModel):
|
||||
"""Consent given by a user for an application"""
|
||||
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""authentik consent stage"""
|
||||
|
||||
from hmac import compare_digest
|
||||
from uuid import uuid4
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
@@ -24,7 +23,7 @@ PLAN_CONTEXT_CONSENT = "consent"
|
||||
PLAN_CONTEXT_CONSENT_HEADER = "consent_header"
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS = "consent_permissions"
|
||||
PLAN_CONTEXT_CONSENT_EXTRA_PERMISSIONS = "consent_additional_permissions"
|
||||
PLAN_CONTEXT_CONSENT_TOKEN = "goauthentik.io/stages/consent/token" # nosec
|
||||
SESSION_KEY_CONSENT_TOKEN = "authentik/stages/consent/token" # nosec
|
||||
|
||||
|
||||
class ConsentPermissionSerializer(PassiveSerializer):
|
||||
@@ -51,9 +50,7 @@ class ConsentChallengeResponse(ChallengeResponse):
|
||||
token = CharField(required=True)
|
||||
|
||||
def validate_token(self, token: str):
|
||||
if not compare_digest(
|
||||
token, self.stage.executor.plan.context.get(PLAN_CONTEXT_CONSENT_TOKEN, "")
|
||||
):
|
||||
if token != self.stage.executor.request.session[SESSION_KEY_CONSENT_TOKEN]:
|
||||
raise ValidationError(_("Invalid consent token, re-showing prompt"))
|
||||
return token
|
||||
|
||||
@@ -65,7 +62,7 @@ class ConsentStageView(ChallengeStageView):
|
||||
|
||||
def get_challenge(self) -> Challenge:
|
||||
token = str(uuid4())
|
||||
self.executor.plan.context[PLAN_CONTEXT_CONSENT_TOKEN] = token
|
||||
self.request.session[SESSION_KEY_CONSENT_TOKEN] = token
|
||||
data = {
|
||||
"permissions": self.executor.plan.context.get(PLAN_CONTEXT_CONSENT_PERMISSIONS, []),
|
||||
"additional_permissions": self.executor.plan.context.get(
|
||||
|
||||
@@ -19,7 +19,7 @@ from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConse
|
||||
from authentik.stages.consent.stage import (
|
||||
PLAN_CONTEXT_CONSENT_HEADER,
|
||||
PLAN_CONTEXT_CONSENT_PERMISSIONS,
|
||||
PLAN_CONTEXT_CONSENT_TOKEN,
|
||||
SESSION_KEY_CONSENT_TOKEN,
|
||||
)
|
||||
|
||||
|
||||
@@ -83,10 +83,11 @@ class TestConsentStage(FlowTestCase):
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
session = self.client.session
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
{
|
||||
"token": self.get_flow_plan().context[PLAN_CONTEXT_CONSENT_TOKEN],
|
||||
"token": session[SESSION_KEY_CONSENT_TOKEN],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -121,7 +122,7 @@ class TestConsentStage(FlowTestCase):
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
{
|
||||
"token": self.get_flow_plan().context[PLAN_CONTEXT_CONSENT_TOKEN],
|
||||
"token": session[SESSION_KEY_CONSENT_TOKEN],
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@@ -19,7 +19,7 @@ from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN, FlowExecutorView
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TOKEN
|
||||
from authentik.stages.consent.stage import SESSION_KEY_CONSENT_TOKEN
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, EmailStageView
|
||||
|
||||
@@ -174,7 +174,7 @@ class TestEmailStage(FlowTestCase):
|
||||
kwargs={"flow_slug": self.flow.slug},
|
||||
),
|
||||
data={
|
||||
"token": self.get_flow_plan().context[PLAN_CONTEXT_CONSENT_TOKEN],
|
||||
"token": self.client.session[SESSION_KEY_CONSENT_TOKEN],
|
||||
},
|
||||
follow=True,
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from django_dramatiq_postgres.models import TaskBase, TaskState
|
||||
|
||||
from authentik.events.logs import LogEvent
|
||||
from authentik.events.utils import sanitize_item
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.errors import exception_to_dict
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@@ -30,7 +30,7 @@ class TaskStatus(models.TextChoices):
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class Task(InternallyManagedMixin, SerializerModel, TaskBase):
|
||||
class Task(SerializerModel, TaskBase):
|
||||
tenant = models.ForeignKey(
|
||||
Tenant,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -144,7 +144,7 @@ class Task(InternallyManagedMixin, SerializerModel, TaskBase):
|
||||
self.log(self.uid, TaskStatus.ERROR, message, **attributes)
|
||||
|
||||
|
||||
class TaskLog(InternallyManagedMixin, models.Model):
|
||||
class TaskLog(models.Model):
|
||||
id = models.UUIDField(default=uuid4, primary_key=True, editable=False)
|
||||
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="tasklogs")
|
||||
|
||||
@@ -17,7 +17,7 @@ from rest_framework.serializers import Serializer
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.blueprints.apps import ManagedAppConfig
|
||||
from authentik.lib.models import InternallyManagedMixin, SerializerModel
|
||||
from authentik.lib.models import SerializerModel
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
LOGGER = get_logger()
|
||||
@@ -41,7 +41,7 @@ def _validate_schema_name(name):
|
||||
)
|
||||
|
||||
|
||||
class Tenant(InternallyManagedMixin, TenantMixin, SerializerModel):
|
||||
class Tenant(TenantMixin, SerializerModel):
|
||||
"""Tenant"""
|
||||
|
||||
tenant_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "https://goauthentik.io/blueprints/schema.json",
|
||||
"type": "object",
|
||||
"title": "authentik 2026.2.0-rc1 Blueprint schema",
|
||||
"title": "authentik 2025.12.0-rc3 Blueprint schema",
|
||||
"required": [
|
||||
"version",
|
||||
"entries"
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc3}
|
||||
ports:
|
||||
- ${COMPOSE_PORT_HTTP:-9000}:9000
|
||||
- ${COMPOSE_PORT_HTTPS:-9443}:9443
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
|
||||
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
|
||||
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.2.0-rc1}
|
||||
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.12.0-rc3}
|
||||
restart: unless-stopped
|
||||
user: root
|
||||
volumes:
|
||||
4
go.mod
4
go.mod
@@ -19,7 +19,7 @@ require (
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/grafana/pyroscope-go v1.2.7
|
||||
github.com/jackc/pgx/v5 v5.8.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/jellydator/ttlcache/v3 v3.4.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
|
||||
@@ -30,7 +30,7 @@ require (
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wwt/guac v1.3.2
|
||||
goauthentik.io/api/v3 v3.2026020.6
|
||||
goauthentik.io/api/v3 v3.2025120.26
|
||||
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sync v0.19.0
|
||||
|
||||
8
go.sum
8
go.sum
@@ -117,8 +117,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -214,8 +214,8 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
goauthentik.io/api/v3 v3.2026020.6 h1:ww545OfZAS0OayLkMQGheR3AsgQ2rc61vhRwSo9dBco=
|
||||
goauthentik.io/api/v3 v3.2026020.6/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
goauthentik.io/api/v3 v3.2025120.26 h1:2lTMtjCWtdOeQe7kwjpGUx39qUEpcxcxTirIqMvn0Os=
|
||||
goauthentik.io/api/v3 v3.2025120.26/go.mod h1:82lqAz4jxzl6Cg0YDbhNtvvTG2rm6605ZhdJFnbbsl8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
|
||||
@@ -1 +1 @@
|
||||
2026.2.0-rc1
|
||||
2025.12.0-rc3
|
||||
@@ -165,7 +165,7 @@ func (ms *MemorySearcher) Search(req *search.Request) (ldap.ServerSearchResult,
|
||||
for _, u := range g.UsersObj {
|
||||
if flag.UserPk == u.Pk {
|
||||
// TODO: Is there a better way to clone this object?
|
||||
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, []api.RelatedGroup{}, []api.PartialUser{u}, []api.Role{}, []string{}, []api.RelatedGroup{})
|
||||
fg := api.NewGroup(g.Pk, g.NumPk, g.Name, []api.RelatedGroup{}, []api.PartialUser{u}, []api.Role{}, nil, []string{}, []api.RelatedGroup{})
|
||||
fg.SetUsers([]int32{flag.UserPk})
|
||||
fg.SetAttributes(g.Attributes)
|
||||
fg.SetIsSuperuser(*g.IsSuperuser)
|
||||
|
||||
@@ -234,8 +234,8 @@ func NewPostgresStore(log *log.Entry) (*PostgresStore, error) {
|
||||
}
|
||||
|
||||
// Determine connection pool settings
|
||||
maxIdleConns := 10
|
||||
maxOpenConns := 100
|
||||
maxIdleConns := 4
|
||||
maxOpenConns := 4
|
||||
var connMaxLifetime time.Duration
|
||||
if cfg.ConnMaxAge > 0 {
|
||||
connMaxLifetime = time.Duration(cfg.ConnMaxAge) * time.Second
|
||||
|
||||
@@ -28,7 +28,7 @@ func (ps *ProxyServer) Refresh() error {
|
||||
return err
|
||||
}
|
||||
ps.log.WithField("count", len(providers)).Debug("Fetched providers")
|
||||
if len(providers) == 0 && !ps.akAPI.IsEmbedded() {
|
||||
if len(providers) == 0 {
|
||||
ps.log.Warning("No providers assigned to this outpost, check outpost configuration in authentik")
|
||||
}
|
||||
for i, p := range providers {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-http-utils/etag"
|
||||
@@ -17,11 +18,44 @@ import (
|
||||
staticWeb "goauthentik.io/web"
|
||||
)
|
||||
|
||||
// Theme variable placeholder that can be used in file paths
|
||||
// This allows for theme-specific files like logo-%(theme)s.png
|
||||
const themeVariable = "%(theme)s"
|
||||
|
||||
// Valid themes that can be substituted for %(theme)s
|
||||
var validThemes = []string{"light", "dark"}
|
||||
|
||||
type StorageClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Path string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// pathMatchesWithTheme checks if the requested path matches the JWT path,
|
||||
// accounting for theme variable substitution.
|
||||
// If the JWT path contains %(theme)s, it will match the requested path
|
||||
// if substituting %(theme)s with any valid theme produces the requested path.
|
||||
func pathMatchesWithTheme(jwtPath, requestedPath string) bool {
|
||||
// Direct match (no theme variable)
|
||||
if jwtPath == requestedPath {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if JWT path contains theme variable
|
||||
if !strings.Contains(jwtPath, themeVariable) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try substituting each valid theme and check for a match
|
||||
for _, theme := range validThemes {
|
||||
substituted := strings.ReplaceAll(jwtPath, themeVariable, theme)
|
||||
if substituted == requestedPath {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func storageTokenIsValid(usage string, r *http.Request) bool {
|
||||
tokenString := r.URL.Query().Get("token")
|
||||
if tokenString == "" {
|
||||
@@ -51,11 +85,8 @@ func storageTokenIsValid(usage string, r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if claims.Path != fmt.Sprintf("%s/%s", usage, r.URL.Path) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
requestedPath := fmt.Sprintf("%s/%s", usage, r.URL.Path)
|
||||
return pathMatchesWithTheme(claims.Path, requestedPath)
|
||||
}
|
||||
|
||||
func (ws *WebServer) configureStatic() {
|
||||
|
||||
95
internal/web/static_test.go
Normal file
95
internal/web/static_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPathMatchesWithTheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jwtPath string
|
||||
requestedPath string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match without theme variable",
|
||||
jwtPath: "media/public/logo.png",
|
||||
requestedPath: "media/public/logo.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match without theme variable",
|
||||
jwtPath: "media/public/logo.png",
|
||||
requestedPath: "media/public/other.png",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "theme variable matches light theme",
|
||||
jwtPath: "media/public/logo-%(theme)s.png",
|
||||
requestedPath: "media/public/logo-light.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "theme variable matches dark theme",
|
||||
jwtPath: "media/public/logo-%(theme)s.png",
|
||||
requestedPath: "media/public/logo-dark.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "theme variable does not match invalid theme",
|
||||
jwtPath: "media/public/logo-%(theme)s.png",
|
||||
requestedPath: "media/public/logo-blue.png",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "theme variable in directory path",
|
||||
jwtPath: "media/%(theme)s/logo.png",
|
||||
requestedPath: "media/light/logo.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "multiple theme variables",
|
||||
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
|
||||
requestedPath: "media/light/logo-light.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "multiple theme variables with dark",
|
||||
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
|
||||
requestedPath: "media/dark/logo-dark.png",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "multiple theme variables mixed themes should not match",
|
||||
jwtPath: "media/%(theme)s/logo-%(theme)s.png",
|
||||
requestedPath: "media/light/logo-dark.png",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "theme variable with nested path",
|
||||
jwtPath: "media/public/brand/logo-%(theme)s.svg",
|
||||
requestedPath: "media/public/brand/logo-dark.svg",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "empty paths",
|
||||
jwtPath: "",
|
||||
requestedPath: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "theme variable only",
|
||||
jwtPath: "%(theme)s",
|
||||
requestedPath: "light",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := pathMatchesWithTheme(tt.jwtPath, tt.requestedPath)
|
||||
if got != tt.want {
|
||||
t.Errorf("pathMatchesWithTheme(%q, %q) = %v, want %v",
|
||||
tt.jwtPath, tt.requestedPath, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -31,7 +31,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
go build -o /go/ldap ./cmd/ldap
|
||||
|
||||
# Stage 2: Run
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:10dadf1df1337e8eb4218acef6a3027abebf0a155a95d792af736f85ab0e9588
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:07f41ce3f15b2bb5eb5bcd4e6efc0cb42bb7e5609e7244f636da1a91166817ca
|
||||
|
||||
ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
8
lifecycle/aws/package-lock.json
generated
8
lifecycle/aws/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1100.1",
|
||||
"aws-cdk": "^2.1033.0",
|
||||
"cross-env": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -25,9 +25,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aws-cdk": {
|
||||
"version": "2.1100.1",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1100.1.tgz",
|
||||
"integrity": "sha512-q2poFrQh90TK6eqeI0zznA8r1JkDI63WVOSqC7gFGo6qjQjAnvFk/utxHoNRgAC0RL0CLd19uCcHh3jfX9NiSg==",
|
||||
"version": "2.1033.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1033.0.tgz",
|
||||
"integrity": "sha512-Pit2k7cVAwxoYI7RMVsOyltuy7/HGENLupJ4KAm/d8mGzOfX+SLOo9YQsx5CKY9J6ErCZ1ViLerklTfjytvQww==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"aws-cfn": "cross-env CI=false cdk synth --version-reporting=false > template.yaml"
|
||||
},
|
||||
"devDependencies": {
|
||||
"aws-cdk": "^2.1100.1",
|
||||
"aws-cdk": "^2.1033.0",
|
||||
"cross-env": "^10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -18,7 +18,7 @@ Parameters:
|
||||
Description: authentik Docker image
|
||||
AuthentikVersion:
|
||||
Type: String
|
||||
Default: 2026.2.0-rc1
|
||||
Default: 2025.12.0-rc3
|
||||
Description: authentik Docker image tag
|
||||
AuthentikServerCPU:
|
||||
Type: Number
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-28 00:13+0000\n"
|
||||
"POT-Creation-Date: 2025-12-12 15:51+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -558,30 +558,6 @@ msgid ""
|
||||
"encryption."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Key algorithm type detected from the certificate's public key"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Certificate expiry date"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Certificate subject as RFC4514 string"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "SHA256 fingerprint of the certificate"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "SHA1 fingerprint of the certificate"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Key ID generated from private key"
|
||||
msgstr ""
|
||||
|
||||
#: authentik/crypto/models.py
|
||||
msgid "Certificate-Key Pair"
|
||||
msgstr ""
|
||||
@@ -712,10 +688,6 @@ msgstr ""
|
||||
msgid "Enterprise is required to create/update this object."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/api.py
|
||||
msgid "Enterprise is required to use this endpoint."
|
||||
msgstr ""
|
||||
|
||||
#: authentik/enterprise/models.py
|
||||
msgid "License"
|
||||
msgstr ""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2026.2.0-rc1",
|
||||
"version": "2025.12.0-rc3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2026.2.0-rc1",
|
||||
"version": "2025.12.0-rc3",
|
||||
"dependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@goauthentik/eslint-config": "./packages/eslint-config",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@goauthentik/authentik",
|
||||
"version": "2026.2.0-rc1",
|
||||
"version": "2025.12.0-rc3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
6
packages/docusaurus-config/package-lock.json
generated
6
packages/docusaurus-config/package-lock.json
generated
@@ -16336,9 +16336,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
|
||||
@@ -17,7 +17,7 @@ COPY web .
|
||||
RUN npm run build-proxy
|
||||
|
||||
# Stage 2: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -47,7 +47,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
go build -o /go/proxy ./cmd/proxy
|
||||
|
||||
# Stage 3: Run
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:10dadf1df1337e8eb4218acef6a3027abebf0a155a95d792af736f85ab0e9588
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:07f41ce3f15b2bb5eb5bcd4e6efc0cb42bb7e5609e7244f636da1a91166817ca
|
||||
|
||||
ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "authentik"
|
||||
version = "2026.2.0-rc1"
|
||||
version = "2025.12.0-rc3"
|
||||
description = ""
|
||||
authors = [{ name = "authentik Team", email = "hello@goauthentik.io" }]
|
||||
requires-python = "==3.13.*"
|
||||
@@ -146,33 +146,30 @@ exclude_dirs = ["**/node_modules/**"]
|
||||
|
||||
[tool.codespell]
|
||||
skip = [
|
||||
"**/.env", # Environment files
|
||||
"**/.venv", # Python virtual environment
|
||||
"**/node_modules", # Node modules
|
||||
"**/package-lock.json", # NPM package lock
|
||||
"schema.yml", # OpenAPI schema
|
||||
"./blueprints/schema.json", # Generated blueprint schema
|
||||
"go.sum", # Go module file
|
||||
"locale", # Django locale files
|
||||
"**/web/src/locales", # Generated TypeScript locale
|
||||
"**/web/xliff", # XLIFF translation files
|
||||
"**/custom-elements.json", # TypeScript custom element definitions
|
||||
"**/storybook-static", # Storybook build output
|
||||
"**/playwright-report", # Playwright test output
|
||||
"unittest.xml", # Pytest output
|
||||
"./htmlcov", # Coverage HTML output
|
||||
"**/out", # TypeScript type-checking output
|
||||
"**/node_modules",
|
||||
"**/package-lock.json",
|
||||
"schema.yml",
|
||||
"unittest.xml",
|
||||
"./blueprints/schema.json",
|
||||
"go.sum",
|
||||
"locale",
|
||||
"**/web/src/locales",
|
||||
"**/dist", # Distributed build output
|
||||
"**/storybook-static",
|
||||
"**/web/xliff",
|
||||
"**/playwright-report", # Playwright test output
|
||||
"**/out", # TypeScript type-checking output
|
||||
"./web/custom-elements.json", # TypeScript custom element definitions
|
||||
"./website/build", # TODO: Remove this after moving website to docs
|
||||
"./website/**/build", # TODO: Remove this after moving website to docs
|
||||
"./docs/build", # Docusaurus Topic docs build output
|
||||
"./docs/**/build", # Docusaurus workspaces output
|
||||
"*.api.mdx", # Generated API docs
|
||||
"./gen-ts-api", # Generated TypeScript API
|
||||
"./gen-py-api", # Generated Python API
|
||||
"./gen-go-api", # Generated Go API
|
||||
"./data", # Media files
|
||||
"./media", # Legacy media files
|
||||
"./gen-ts-api",
|
||||
"./gen-py-api",
|
||||
"./gen-go-api",
|
||||
"./htmlcov",
|
||||
"./media",
|
||||
]
|
||||
dictionary = ".github/codespell-dictionary.txt,-"
|
||||
ignore-words = ".github/codespell-words.txt"
|
||||
@@ -287,7 +284,6 @@ module = [
|
||||
"lifecycle.*",
|
||||
"tests.e2e.*",
|
||||
"tests.integration.*",
|
||||
"tests.openid_conformance.*",
|
||||
]
|
||||
ignore_errors = true
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -1,7 +1,7 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Stage 1: Build
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:8e8f9c84609b6005af0a4a8227cee53d6226aab1c6dcb22daf5aeeb8b05480e1 AS builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.25.5-trixie@sha256:5d35fb8d28b9095d123b7d96095bbf3750ff18be0a87e5a21c9cffc4351fbf96 AS builder
|
||||
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
@@ -31,7 +31,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||
go build -o /go/radius ./cmd/radius
|
||||
|
||||
# Stage 2: Run
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:10dadf1df1337e8eb4218acef6a3027abebf0a155a95d792af736f85ab0e9588
|
||||
FROM ghcr.io/goauthentik/fips-debian:trixie-slim-fips@sha256:07f41ce3f15b2bb5eb5bcd4e6efc0cb42bb7e5609e7244f636da1a91166817ca
|
||||
|
||||
ARG VERSION
|
||||
ARG GIT_BUILD_HASH
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user