Compare commits
167 commits
root/repla
...
trustchain
Author | SHA1 | Date | |
---|---|---|---|
1cd000dfe2 | |||
00ae97944a | |||
9f3ccfb7c7 | |||
9ed9c39ac8 | |||
30b6eeee9f | |||
afe2621783 | |||
8b12c6a01a | |||
f63adfed96 | |||
9c8fec21cf | |||
4776d2bcc5 | |||
a15a040362 | |||
fcd6dc1d60 | |||
acc3b59869 | |||
d9d5ac10e6 | |||
750669dcab | |||
88a3eed67e | |||
6c214fffc4 | |||
70100fc105 | |||
3c1163fabd | |||
539e8242ff | |||
2648333590 | |||
fe828ef993 | |||
29a6530742 | |||
a6b9274c4f | |||
a2a67161ac | |||
2e8263a99b | |||
6b9afed21f | |||
1eb1f4e0b8 | |||
7c3d60ec3a | |||
a494c6b6e8 | |||
6604d3577f | |||
f8bfa7e16a | |||
ea6cf6eabf | |||
769ce3ce7b | |||
3891fb3fa8 | |||
41eb965350 | |||
8d95612287 | |||
82b5274b15 | |||
af56ce3d78 | |||
f5c6e7aeb0 | |||
3809400e93 | |||
1def9865cf | |||
3716298639 | |||
c16317d7cf | |||
bbb8fa8269 | |||
e4c251a178 | |||
0fefd5f522 | |||
88057db0b0 | |||
91cb6c9beb | |||
8e72fcab59 | |||
261879022d | |||
2a47ff2977 | |||
c3a81a1cce | |||
220d739fef | |||
4a57c6f230 | |||
4a93b97bec | |||
ac2bbd7e2f | |||
ad9f500ad1 | |||
15d7175750 | |||
41d372a340 | |||
83b84e8d26 | |||
f22daca091 | |||
ae4d5a30f2 | |||
9708481005 | |||
1c32c9e06d | |||
7a3d92ffdb | |||
a72b36d94d | |||
6b25f6f592 | |||
7d91842e8a | |||
2b4b1d2f76 | |||
2ce5c74f33 | |||
168fabfc70 | |||
eb53c28352 | |||
64c38909ff | |||
940492a5e1 | |||
134799c734 | |||
e086da68cd | |||
ed46fd629e | |||
12bb1554f6 | |||
263d9128c4 | |||
8c6aaf4a2d | |||
d9b3e307e3 | |||
709fd716d8 | |||
28053059ff | |||
94ad839437 | |||
84fdd3c750 | |||
3f5d5e0408 | |||
309c390154 | |||
32493c6102 | |||
7569314a24 | |||
e640eab229 | |||
848fe3e428 | |||
a52e4a3262 | |||
7f2d03dcd0 | |||
484cbc8c73 | |||
f6118ec876 | |||
488420eeb6 | |||
a8f066eb51 | |||
7673e5a297 | |||
add873ca9b | |||
f12a533e91 | |||
18766d6d41 | |||
3dd1094c50 | |||
9b2ed944dc | |||
a445463a92 | |||
cefdf98b22 | |||
f675c25b83 | |||
aa954c4807 | |||
87f2b37348 | |||
1d36d84229 | |||
0091690863 | |||
a289125108 | |||
e26019b14b | |||
60981228d4 | |||
03a0914553 | |||
959fce2198 | |||
1c171e4e55 | |||
50780b62a7 | |||
e60820a9d1 | |||
c0fe99714f | |||
6f67c1a277 | |||
04d27cbcb6 | |||
e3ac774a18 | |||
75aedb4d3f | |||
f3e02f9281 | |||
1e254b63a0 | |||
09ba0c470a | |||
549d6d7c8e | |||
616f0b8b4f | |||
52a452488c | |||
b13f7158c7 | |||
9582cd4599 | |||
7e213f3ca6 | |||
cc781cad00 | |||
cbbb638ca7 | |||
63426bc9a8 | |||
63c52fd936 | |||
5e5bc5cd49 | |||
6fb7586b00 | |||
e0da3bf2b4 | |||
db76446099 | |||
20e003656a | |||
ce4c654209 | |||
1dedfaeaac | |||
5b2a1f9fb4 | |||
2cf337f731 | |||
9dbc3777a0 | |||
8c277033cf | |||
7e536515c6 | |||
92d170d065 | |||
41464aec18 | |||
4686796543 | |||
f036820fd8 | |||
acad3c4d5c | |||
34367a7481 | |||
48e8c568e2 | |||
44a0df5cf8 | |||
a72b1f99ab | |||
d48342f90b | |||
3a7283c670 | |||
3beb421e50 | |||
d0fca8272c | |||
8aafa06259 | |||
3c734da86a | |||
a60f3b4b81 | |||
4262bd6ace | |||
6ba4f4df46 |
|
@ -1,5 +1,5 @@
|
||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2023.8.3
|
current_version = 2023.10.6
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||||
|
|
21
.github/actions/setup/action.yml
vendored
21
.github/actions/setup/action.yml
vendored
|
@ -2,36 +2,39 @@ name: "Setup authentik testing environment"
|
||||||
description: "Setup authentik testing environment"
|
description: "Setup authentik testing environment"
|
||||||
|
|
||||||
inputs:
|
inputs:
|
||||||
postgresql_tag:
|
postgresql_version:
|
||||||
description: "Optional postgresql image tag"
|
description: "Optional postgresql image tag"
|
||||||
default: "12"
|
default: "12"
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
- name: Install poetry
|
- name: Install poetry & deps
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
pipx install poetry || true
|
pipx install poetry || true
|
||||||
sudo apt update
|
sudo apt-get update
|
||||||
sudo apt install -y libpq-dev openssl libxmlsec1-dev pkg-config gettext
|
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext
|
||||||
- name: Setup python and restore poetry
|
- name: Setup python and restore poetry
|
||||||
uses: actions/setup-python@v3
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version-file: 'pyproject.toml'
|
||||||
cache: "poetry"
|
cache: "poetry"
|
||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- name: Setup go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version-file: "go.mod"
|
||||||
- name: Setup dependencies
|
- name: Setup dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
export PSQL_TAG=${{ inputs.postgresql_tag }}
|
export PSQL_TAG=${{ inputs.postgresql_version }}
|
||||||
docker-compose -f .github/actions/setup/docker-compose.yml up -d
|
docker-compose -f .github/actions/setup/docker-compose.yml up -d
|
||||||
poetry env use python3.11
|
|
||||||
poetry install
|
poetry install
|
||||||
cd web && npm ci
|
cd web && npm ci
|
||||||
- name: Generate config
|
- name: Generate config
|
||||||
|
|
30
.github/workflows/ci-main.yml
vendored
30
.github/workflows/ci-main.yml
vendored
|
@ -11,6 +11,7 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- version-*
|
||||||
|
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: authentik
|
POSTGRES_DB: authentik
|
||||||
|
@ -47,25 +48,38 @@ jobs:
|
||||||
- name: run migrations
|
- name: run migrations
|
||||||
run: poetry run python -m lifecycle.migrate
|
run: poetry run python -m lifecycle.migrate
|
||||||
test-migrations-from-stable:
|
test-migrations-from-stable:
|
||||||
|
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
continue-on-error: true
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
psql:
|
||||||
|
- 12-alpine
|
||||||
|
- 15-alpine
|
||||||
|
- 16-alpine
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
postgresql_version: ${{ matrix.psql }}
|
||||||
- name: checkout stable
|
- name: checkout stable
|
||||||
run: |
|
run: |
|
||||||
|
# Delete all poetry envs
|
||||||
|
rm -rf /home/runner/.cache/pypoetry
|
||||||
# Copy current, latest config to local
|
# Copy current, latest config to local
|
||||||
cp authentik/lib/default.yml local.env.yml
|
cp authentik/lib/default.yml local.env.yml
|
||||||
cp -R .github ..
|
cp -R .github ..
|
||||||
cp -R scripts ..
|
cp -R scripts ..
|
||||||
git checkout $(git describe --tags $(git rev-list --tags --max-count=1))
|
git checkout version/$(python -c "from authentik import __version__; print(__version__)")
|
||||||
rm -rf .github/ scripts/
|
rm -rf .github/ scripts/
|
||||||
mv ../.github ../scripts .
|
mv ../.github ../scripts .
|
||||||
- name: Setup authentik env (ensure stable deps are installed)
|
- name: Setup authentik env (ensure stable deps are installed)
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
postgresql_version: ${{ matrix.psql }}
|
||||||
- name: run migrations to stable
|
- name: run migrations to stable
|
||||||
run: poetry run python -m lifecycle.migrate
|
run: poetry run python -m lifecycle.migrate
|
||||||
- name: checkout current code
|
- name: checkout current code
|
||||||
|
@ -75,9 +89,13 @@ jobs:
|
||||||
git reset --hard HEAD
|
git reset --hard HEAD
|
||||||
git clean -d -fx .
|
git clean -d -fx .
|
||||||
git checkout $GITHUB_SHA
|
git checkout $GITHUB_SHA
|
||||||
|
# Delete previous poetry env
|
||||||
|
rm -rf $(poetry env info --path)
|
||||||
poetry install
|
poetry install
|
||||||
- name: Setup authentik env (ensure latest deps are installed)
|
- name: Setup authentik env (ensure latest deps are installed)
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
|
with:
|
||||||
|
postgresql_version: ${{ matrix.psql }}
|
||||||
- name: migrate to latest
|
- name: migrate to latest
|
||||||
run: poetry run python -m lifecycle.migrate
|
run: poetry run python -m lifecycle.migrate
|
||||||
test-unittest:
|
test-unittest:
|
||||||
|
@ -96,7 +114,7 @@ jobs:
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
uses: ./.github/actions/setup
|
uses: ./.github/actions/setup
|
||||||
with:
|
with:
|
||||||
postgresql_tag: ${{ matrix.psql }}
|
postgresql_version: ${{ matrix.psql }}
|
||||||
- name: run unittest
|
- name: run unittest
|
||||||
run: |
|
run: |
|
||||||
poetry run make test
|
poetry run make test
|
||||||
|
@ -185,6 +203,9 @@ jobs:
|
||||||
build:
|
build:
|
||||||
needs: ci-core-mark
|
needs: ci-core-mark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# Needed to upload contianer images to ghcr.io
|
||||||
|
packages: write
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
@ -235,6 +256,9 @@ jobs:
|
||||||
build-arm64:
|
build-arm64:
|
||||||
needs: ci-core-mark
|
needs: ci-core-mark
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# Needed to upload contianer images to ghcr.io
|
||||||
|
packages: write
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
8
.github/workflows/ci-outpost.yml
vendored
8
.github/workflows/ci-outpost.yml
vendored
|
@ -9,6 +9,7 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- version-*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-golint:
|
lint-golint:
|
||||||
|
@ -65,6 +66,9 @@ jobs:
|
||||||
- ldap
|
- ldap
|
||||||
- radius
|
- radius
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# Needed to upload contianer images to ghcr.io
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
|
@ -124,9 +128,9 @@ jobs:
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
|
|
21
.github/workflows/ci-web.yml
vendored
21
.github/workflows/ci-web.yml
vendored
|
@ -9,6 +9,7 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- version-*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-eslint:
|
lint-eslint:
|
||||||
|
@ -21,9 +22,9 @@ jobs:
|
||||||
- tests/wdio
|
- tests/wdio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: ${{ matrix.project }}/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: ${{ matrix.project }}/package-lock.json
|
cache-dependency-path: ${{ matrix.project }}/package-lock.json
|
||||||
- working-directory: ${{ matrix.project }}/
|
- working-directory: ${{ matrix.project }}/
|
||||||
|
@ -37,9 +38,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- working-directory: web/
|
- working-directory: web/
|
||||||
|
@ -59,9 +60,9 @@ jobs:
|
||||||
- tests/wdio
|
- tests/wdio
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: ${{ matrix.project }}/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: ${{ matrix.project }}/package-lock.json
|
cache-dependency-path: ${{ matrix.project }}/package-lock.json
|
||||||
- working-directory: ${{ matrix.project }}/
|
- working-directory: ${{ matrix.project }}/
|
||||||
|
@ -75,9 +76,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- working-directory: web/
|
- working-directory: web/
|
||||||
|
@ -107,9 +108,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- working-directory: web/
|
- working-directory: web/
|
||||||
|
|
13
.github/workflows/ci-website.yml
vendored
13
.github/workflows/ci-website.yml
vendored
|
@ -9,15 +9,16 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- version-*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: website/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: website/package-lock.json
|
cache-dependency-path: website/package-lock.json
|
||||||
- working-directory: website/
|
- working-directory: website/
|
||||||
|
@ -29,9 +30,9 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: website/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: website/package-lock.json
|
cache-dependency-path: website/package-lock.json
|
||||||
- working-directory: website/
|
- working-directory: website/
|
||||||
|
@ -50,9 +51,9 @@ jobs:
|
||||||
- build-docs-only
|
- build-docs-only
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: website/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: website/package-lock.json
|
cache-dependency-path: website/package-lock.json
|
||||||
- working-directory: website/
|
- working-directory: website/
|
||||||
|
|
1
.github/workflows/release-next-branch.yml
vendored
1
.github/workflows/release-next-branch.yml
vendored
|
@ -6,6 +6,7 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
# Needed to be able to push to the next branch
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
24
.github/workflows/release-publish.yml
vendored
24
.github/workflows/release-publish.yml
vendored
|
@ -7,6 +7,9 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
build-server:
|
build-server:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# Needed to upload contianer images to ghcr.io
|
||||||
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
|
@ -27,8 +30,10 @@ jobs:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: make empty ts client
|
- name: make empty clients
|
||||||
run: mkdir -p ./gen-ts-client
|
run: |
|
||||||
|
mkdir -p ./gen-ts-api
|
||||||
|
mkdir -p ./gen-go-api
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
|
@ -50,6 +55,9 @@ jobs:
|
||||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
build-outpost:
|
build-outpost:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# Needed to upload contianer images to ghcr.io
|
||||||
|
packages: write
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
@ -69,6 +77,10 @@ jobs:
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
uses: ./.github/actions/docker-push-variables
|
uses: ./.github/actions/docker-push-variables
|
||||||
id: ev
|
id: ev
|
||||||
|
- name: make empty clients
|
||||||
|
run: |
|
||||||
|
mkdir -p ./gen-ts-api
|
||||||
|
mkdir -p ./gen-go-api
|
||||||
- name: Docker Login Registry
|
- name: Docker Login Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
|
@ -93,12 +105,16 @@ jobs:
|
||||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||||
file: ${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
context: .
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ steps.ev.outputs.version }}
|
VERSION=${{ steps.ev.outputs.version }}
|
||||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||||
build-outpost-binary:
|
build-outpost-binary:
|
||||||
timeout-minutes: 120
|
timeout-minutes: 120
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# Needed to upload binaries to the release
|
||||||
|
contents: write
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
|
@ -113,9 +129,9 @@ jobs:
|
||||||
- uses: actions/setup-go@v4
|
- uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
go-version-file: "go.mod"
|
go-version-file: "go.mod"
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: web/package.json
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Build web
|
- name: Build web
|
||||||
|
|
1
.github/workflows/release-tag.yml
vendored
1
.github/workflows/release-tag.yml
vendored
|
@ -16,6 +16,7 @@ jobs:
|
||||||
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
|
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
|
||||||
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
|
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
|
||||||
docker buildx install
|
docker buildx install
|
||||||
|
mkdir -p ./gen-ts-api
|
||||||
docker build -t testing:latest .
|
docker build -t testing:latest .
|
||||||
echo "AUTHENTIK_IMAGE=testing" >> .env
|
echo "AUTHENTIK_IMAGE=testing" >> .env
|
||||||
echo "AUTHENTIK_TAG=latest" >> .env
|
echo "AUTHENTIK_TAG=latest" >> .env
|
||||||
|
|
2
.github/workflows/repo-stale.yml
vendored
2
.github/workflows/repo-stale.yml
vendored
|
@ -6,8 +6,8 @@ on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
# Needed to update issues and PRs
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
|
|
4
.github/workflows/web-api-publish.yml
vendored
4
.github/workflows/web-api-publish.yml
vendored
|
@ -17,9 +17,9 @@ jobs:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.generate_token.outputs.token }}
|
token: ${{ steps.generate_token.outputs.token }}
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version-file: web/package.json
|
||||||
registry-url: "https://registry.npmjs.org"
|
registry-url: "https://registry.npmjs.org"
|
||||||
- name: Generate API Client
|
- name: Generate API Client
|
||||||
run: make gen-client-ts
|
run: make gen-client-ts
|
||||||
|
|
31
Dockerfile
31
Dockerfile
|
@ -1,5 +1,7 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# Stage 1: Build website
|
# Stage 1: Build website
|
||||||
FROM --platform=${BUILDPLATFORM} docker.io/node:20 as website-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/node:21 as website-builder
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
@ -7,7 +9,7 @@ WORKDIR /work/website
|
||||||
|
|
||||||
RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.json \
|
RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.json \
|
||||||
--mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \
|
--mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \
|
||||||
--mount=type=cache,target=/root/.npm \
|
--mount=type=cache,id=npm-website,sharing=shared,target=/root/.npm \
|
||||||
npm ci --include=dev
|
npm ci --include=dev
|
||||||
|
|
||||||
COPY ./website /work/website/
|
COPY ./website /work/website/
|
||||||
|
@ -17,7 +19,7 @@ COPY ./SECURITY.md /work/
|
||||||
RUN npm run build-docs-only
|
RUN npm run build-docs-only
|
||||||
|
|
||||||
# Stage 2: Build webui
|
# Stage 2: Build webui
|
||||||
FROM --platform=${BUILDPLATFORM} docker.io/node:20 as web-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/node:21 as web-builder
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
@ -25,7 +27,7 @@ WORKDIR /work/web
|
||||||
|
|
||||||
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
|
RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
|
||||||
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
|
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
|
||||||
--mount=type=cache,target=/root/.npm \
|
--mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
|
||||||
npm ci --include=dev
|
npm ci --include=dev
|
||||||
|
|
||||||
COPY ./web /work/web/
|
COPY ./web /work/web/
|
||||||
|
@ -35,7 +37,14 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 3: Build go proxy
|
# Stage 3: Build go proxy
|
||||||
FROM docker.io/golang:1.21.3-bookworm AS go-builder
|
FROM --platform=${BUILDPLATFORM} docker.io/golang:1.21.4-bookworm AS go-builder
|
||||||
|
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
ARG TARGETVARIANT
|
||||||
|
|
||||||
|
ARG GOOS=$TARGETOS
|
||||||
|
ARG GOARCH=$TARGETARCH
|
||||||
|
|
||||||
WORKDIR /go/src/goauthentik.io
|
WORKDIR /go/src/goauthentik.io
|
||||||
|
|
||||||
|
@ -55,12 +64,12 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum
|
||||||
|
|
||||||
ENV CGO_ENABLED=0
|
ENV CGO_ENABLED=0
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
|
||||||
go build -o /go/authentik ./cmd/server
|
GOARM="${TARGETVARIANT#v}" go build -o /go/authentik ./cmd/server
|
||||||
|
|
||||||
# Stage 4: MaxMind GeoIP
|
# Stage 4: MaxMind GeoIP
|
||||||
FROM ghcr.io/maxmind/geoipupdate:v6.0 as geoip
|
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v6.0 as geoip
|
||||||
|
|
||||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
||||||
ENV GEOIPUPDATE_VERBOSE="true"
|
ENV GEOIPUPDATE_VERBOSE="true"
|
||||||
|
@ -82,7 +91,9 @@ ENV VENV_PATH="/ak-root/venv" \
|
||||||
POETRY_VIRTUALENVS_CREATE=false \
|
POETRY_VIRTUALENVS_CREATE=false \
|
||||||
PATH="/ak-root/venv/bin:$PATH"
|
PATH="/ak-root/venv/bin:$PATH"
|
||||||
|
|
||||||
RUN --mount=type=cache,target=/var/cache/apt \
|
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
|
||||||
|
|
||||||
|
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
# Required for installing pip packages
|
# Required for installing pip packages
|
||||||
apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev zlib1g-dev libpq-dev
|
apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev zlib1g-dev libpq-dev
|
||||||
|
|
6
Makefile
6
Makefile
|
@ -56,9 +56,9 @@ test: ## Run the server tests and produce a coverage report (locally)
|
||||||
coverage report
|
coverage report
|
||||||
|
|
||||||
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
lint-fix: ## Lint and automatically fix errors in the python source code. Reports spelling errors.
|
||||||
isort authentik $(PY_SOURCES)
|
isort $(PY_SOURCES)
|
||||||
black authentik $(PY_SOURCES)
|
black $(PY_SOURCES)
|
||||||
ruff authentik $(PY_SOURCES)
|
ruff $(PY_SOURCES)
|
||||||
codespell -w $(CODESPELL_ARGS)
|
codespell -w $(CODESPELL_ARGS)
|
||||||
|
|
||||||
lint: ## Lint the python and golang sources
|
lint: ## Lint the python and golang sources
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
from os import environ
|
from os import environ
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
__version__ = "2023.8.3"
|
__version__ = "2023.10.6"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,9 @@ _other_urls = []
|
||||||
for _authentik_app in get_apps():
|
for _authentik_app in get_apps():
|
||||||
try:
|
try:
|
||||||
api_urls = import_module(f"{_authentik_app.name}.urls")
|
api_urls = import_module(f"{_authentik_app.name}.urls")
|
||||||
except (ModuleNotFoundError, ImportError) as exc:
|
except ModuleNotFoundError:
|
||||||
|
continue
|
||||||
|
except ImportError as exc:
|
||||||
LOGGER.warning("Could not import app's URLs", app_name=_authentik_app.name, exc=exc)
|
LOGGER.warning("Could not import app's URLs", app_name=_authentik_app.name, exc=exc)
|
||||||
continue
|
continue
|
||||||
if not hasattr(api_urls, "api_urlpatterns"):
|
if not hasattr(api_urls, "api_urlpatterns"):
|
||||||
|
|
|
@ -40,7 +40,7 @@ class ManagedAppConfig(AppConfig):
|
||||||
meth()
|
meth()
|
||||||
self._logger.debug("Successfully reconciled", name=name)
|
self._logger.debug("Successfully reconciled", name=name)
|
||||||
except (DatabaseError, ProgrammingError, InternalError) as exc:
|
except (DatabaseError, ProgrammingError, InternalError) as exc:
|
||||||
self._logger.debug("Failed to run reconcile", name=name, exc=exc)
|
self._logger.warning("Failed to run reconcile", name=name, exc=exc)
|
||||||
|
|
||||||
|
|
||||||
class AuthentikBlueprintsConfig(ManagedAppConfig):
|
class AuthentikBlueprintsConfig(ManagedAppConfig):
|
||||||
|
|
|
@ -584,12 +584,17 @@ class EntryInvalidError(SentryIgnoredException):
|
||||||
entry_model: Optional[str]
|
entry_model: Optional[str]
|
||||||
entry_id: Optional[str]
|
entry_id: Optional[str]
|
||||||
validation_error: Optional[ValidationError]
|
validation_error: Optional[ValidationError]
|
||||||
|
serializer: Optional[Serializer] = None
|
||||||
|
|
||||||
def __init__(self, *args: object, validation_error: Optional[ValidationError] = None) -> None:
|
def __init__(
|
||||||
|
self, *args: object, validation_error: Optional[ValidationError] = None, **kwargs
|
||||||
|
) -> None:
|
||||||
super().__init__(*args)
|
super().__init__(*args)
|
||||||
self.entry_model = None
|
self.entry_model = None
|
||||||
self.entry_id = None
|
self.entry_id = None
|
||||||
self.validation_error = validation_error
|
self.validation_error = validation_error
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_entry(
|
def from_entry(
|
||||||
|
|
|
@ -255,7 +255,10 @@ class Importer:
|
||||||
try:
|
try:
|
||||||
full_data = self.__update_pks_for_attrs(entry.get_attrs(self._import))
|
full_data = self.__update_pks_for_attrs(entry.get_attrs(self._import))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise EntryInvalidError.from_entry(exc, entry) from exc
|
raise EntryInvalidError.from_entry(
|
||||||
|
exc,
|
||||||
|
entry,
|
||||||
|
) from exc
|
||||||
always_merger.merge(full_data, updated_identifiers)
|
always_merger.merge(full_data, updated_identifiers)
|
||||||
serializer_kwargs["data"] = full_data
|
serializer_kwargs["data"] = full_data
|
||||||
|
|
||||||
|
@ -272,6 +275,7 @@ class Importer:
|
||||||
f"Serializer errors {serializer.errors}",
|
f"Serializer errors {serializer.errors}",
|
||||||
validation_error=exc,
|
validation_error=exc,
|
||||||
entry=entry,
|
entry=entry,
|
||||||
|
serializer=serializer,
|
||||||
) from exc
|
) from exc
|
||||||
return serializer
|
return serializer
|
||||||
|
|
||||||
|
@ -300,16 +304,18 @@ class Importer:
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
# Validate each single entry
|
# Validate each single entry
|
||||||
|
serializer = None
|
||||||
try:
|
try:
|
||||||
serializer = self._validate_single(entry)
|
serializer = self._validate_single(entry)
|
||||||
except EntryInvalidError as exc:
|
except EntryInvalidError as exc:
|
||||||
# For deleting objects we don't need the serializer to be valid
|
# For deleting objects we don't need the serializer to be valid
|
||||||
if entry.get_state(self._import) == BlueprintEntryDesiredState.ABSENT:
|
if entry.get_state(self._import) == BlueprintEntryDesiredState.ABSENT:
|
||||||
continue
|
serializer = exc.serializer
|
||||||
self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc)
|
else:
|
||||||
if raise_errors:
|
self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc)
|
||||||
raise exc
|
if raise_errors:
|
||||||
return False
|
raise exc
|
||||||
|
return False
|
||||||
if not serializer:
|
if not serializer:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
|
@ -75,14 +75,14 @@ class BlueprintEventHandler(FileSystemEventHandler):
|
||||||
return
|
return
|
||||||
if event.is_directory:
|
if event.is_directory:
|
||||||
return
|
return
|
||||||
|
root = Path(CONFIG.get("blueprints_dir")).absolute()
|
||||||
|
path = Path(event.src_path).absolute()
|
||||||
|
rel_path = str(path.relative_to(root))
|
||||||
if isinstance(event, FileCreatedEvent):
|
if isinstance(event, FileCreatedEvent):
|
||||||
LOGGER.debug("new blueprint file created, starting discovery")
|
LOGGER.debug("new blueprint file created, starting discovery", path=rel_path)
|
||||||
blueprints_discovery.delay()
|
blueprints_discovery.delay(rel_path)
|
||||||
if isinstance(event, FileModifiedEvent):
|
if isinstance(event, FileModifiedEvent):
|
||||||
path = Path(event.src_path)
|
for instance in BlueprintInstance.objects.filter(path=rel_path, enabled=True):
|
||||||
root = Path(CONFIG.get("blueprints_dir")).absolute()
|
|
||||||
rel_path = str(path.relative_to(root))
|
|
||||||
for instance in BlueprintInstance.objects.filter(path=rel_path):
|
|
||||||
LOGGER.debug("modified blueprint file, starting apply", instance=instance)
|
LOGGER.debug("modified blueprint file, starting apply", instance=instance)
|
||||||
apply_blueprint.delay(instance.pk.hex)
|
apply_blueprint.delay(instance.pk.hex)
|
||||||
|
|
||||||
|
@ -98,39 +98,32 @@ def blueprints_find_dict():
|
||||||
return blueprints
|
return blueprints
|
||||||
|
|
||||||
|
|
||||||
def blueprints_find():
|
def blueprints_find() -> list[BlueprintFile]:
|
||||||
"""Find blueprints and return valid ones"""
|
"""Find blueprints and return valid ones"""
|
||||||
blueprints = []
|
blueprints = []
|
||||||
root = Path(CONFIG.get("blueprints_dir"))
|
root = Path(CONFIG.get("blueprints_dir"))
|
||||||
for path in root.rglob("**/*.yaml"):
|
for path in root.rglob("**/*.yaml"):
|
||||||
|
rel_path = path.relative_to(root)
|
||||||
# Check if any part in the path starts with a dot and assume a hidden file
|
# Check if any part in the path starts with a dot and assume a hidden file
|
||||||
if any(part for part in path.parts if part.startswith(".")):
|
if any(part for part in path.parts if part.startswith(".")):
|
||||||
continue
|
continue
|
||||||
LOGGER.debug("found blueprint", path=str(path))
|
|
||||||
with open(path, "r", encoding="utf-8") as blueprint_file:
|
with open(path, "r", encoding="utf-8") as blueprint_file:
|
||||||
try:
|
try:
|
||||||
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
|
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
|
||||||
except YAMLError as exc:
|
except YAMLError as exc:
|
||||||
raw_blueprint = None
|
raw_blueprint = None
|
||||||
LOGGER.warning("failed to parse blueprint", exc=exc, path=str(path))
|
LOGGER.warning("failed to parse blueprint", exc=exc, path=str(rel_path))
|
||||||
if not raw_blueprint:
|
if not raw_blueprint:
|
||||||
continue
|
continue
|
||||||
metadata = raw_blueprint.get("metadata", None)
|
metadata = raw_blueprint.get("metadata", None)
|
||||||
version = raw_blueprint.get("version", 1)
|
version = raw_blueprint.get("version", 1)
|
||||||
if version != 1:
|
if version != 1:
|
||||||
LOGGER.warning("invalid blueprint version", version=version, path=str(path))
|
LOGGER.warning("invalid blueprint version", version=version, path=str(rel_path))
|
||||||
continue
|
continue
|
||||||
file_hash = sha512(path.read_bytes()).hexdigest()
|
file_hash = sha512(path.read_bytes()).hexdigest()
|
||||||
blueprint = BlueprintFile(
|
blueprint = BlueprintFile(str(rel_path), version, file_hash, int(path.stat().st_mtime))
|
||||||
str(path.relative_to(root)), version, file_hash, int(path.stat().st_mtime)
|
|
||||||
)
|
|
||||||
blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None
|
blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None
|
||||||
blueprints.append(blueprint)
|
blueprints.append(blueprint)
|
||||||
LOGGER.debug(
|
|
||||||
"parsed & loaded blueprint",
|
|
||||||
hash=file_hash,
|
|
||||||
path=str(path),
|
|
||||||
)
|
|
||||||
return blueprints
|
return blueprints
|
||||||
|
|
||||||
|
|
||||||
|
@ -138,10 +131,12 @@ def blueprints_find():
|
||||||
throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True
|
throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True
|
||||||
)
|
)
|
||||||
@prefill_task
|
@prefill_task
|
||||||
def blueprints_discovery(self: MonitoredTask):
|
def blueprints_discovery(self: MonitoredTask, path: Optional[str] = None):
|
||||||
"""Find blueprints and check if they need to be created in the database"""
|
"""Find blueprints and check if they need to be created in the database"""
|
||||||
count = 0
|
count = 0
|
||||||
for blueprint in blueprints_find():
|
for blueprint in blueprints_find():
|
||||||
|
if path and blueprint.path != path:
|
||||||
|
continue
|
||||||
check_blueprint_v1_file(blueprint)
|
check_blueprint_v1_file(blueprint)
|
||||||
count += 1
|
count += 1
|
||||||
self.set_status(
|
self.set_status(
|
||||||
|
@ -171,7 +166,11 @@ def check_blueprint_v1_file(blueprint: BlueprintFile):
|
||||||
metadata={},
|
metadata={},
|
||||||
)
|
)
|
||||||
instance.save()
|
instance.save()
|
||||||
|
LOGGER.info(
|
||||||
|
"Creating new blueprint instance from file", instance=instance, path=instance.path
|
||||||
|
)
|
||||||
if instance.last_applied_hash != blueprint.hash:
|
if instance.last_applied_hash != blueprint.hash:
|
||||||
|
LOGGER.info("Applying blueprint due to changed file", instance=instance, path=instance.path)
|
||||||
apply_blueprint.delay(str(instance.pk))
|
apply_blueprint.delay(str(instance.pk))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -98,6 +98,7 @@ class ApplicationSerializer(ModelSerializer):
|
||||||
class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||||
"""Application Viewset"""
|
"""Application Viewset"""
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
queryset = Application.objects.all().prefetch_related("provider")
|
queryset = Application.objects.all().prefetch_related("provider")
|
||||||
serializer_class = ApplicationSerializer
|
serializer_class = ApplicationSerializer
|
||||||
search_fields = [
|
search_fields = [
|
||||||
|
|
|
@ -139,6 +139,7 @@ class UserAccountSerializer(PassiveSerializer):
|
||||||
class GroupViewSet(UsedByMixin, ModelViewSet):
|
class GroupViewSet(UsedByMixin, ModelViewSet):
|
||||||
"""Group Viewset"""
|
"""Group Viewset"""
|
||||||
|
|
||||||
|
# pylint: disable=no-member
|
||||||
queryset = Group.objects.all().select_related("parent").prefetch_related("users")
|
queryset = Group.objects.all().select_related("parent").prefetch_related("users")
|
||||||
serializer_class = GroupSerializer
|
serializer_class = GroupSerializer
|
||||||
search_fields = ["name", "is_superuser"]
|
search_fields = ["name", "is_superuser"]
|
||||||
|
|
|
@ -38,7 +38,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||||
|
|
||||||
managed = ReadOnlyField()
|
managed = ReadOnlyField()
|
||||||
component = SerializerMethodField()
|
component = SerializerMethodField()
|
||||||
icon = ReadOnlyField(source="get_icon")
|
icon = ReadOnlyField(source="icon_url")
|
||||||
|
|
||||||
def get_component(self, obj: Source) -> str:
|
def get_component(self, obj: Source) -> str:
|
||||||
"""Get object component so that we know how to edit the object"""
|
"""Get object component so that we know how to edit the object"""
|
||||||
|
|
|
@ -171,6 +171,11 @@ class UserSerializer(ModelSerializer):
|
||||||
raise ValidationError("Setting a user to internal service account is not allowed.")
|
raise ValidationError("Setting a user to internal service account is not allowed.")
|
||||||
return user_type
|
return user_type
|
||||||
|
|
||||||
|
def validate(self, attrs: dict) -> dict:
|
||||||
|
if self.instance and self.instance.type == UserTypes.INTERNAL_SERVICE_ACCOUNT:
|
||||||
|
raise ValidationError("Can't modify internal service account users")
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = [
|
fields = [
|
||||||
|
|
|
@ -44,6 +44,7 @@ class PropertyMappingEvaluator(BaseEvaluator):
|
||||||
if request:
|
if request:
|
||||||
req.http_request = request
|
req.http_request = request
|
||||||
self._context["request"] = req
|
self._context["request"] = req
|
||||||
|
req.context.update(**kwargs)
|
||||||
self._context.update(**kwargs)
|
self._context.update(**kwargs)
|
||||||
self.dry_run = dry_run
|
self.dry_run = dry_run
|
||||||
|
|
||||||
|
|
|
@ -17,9 +17,15 @@ class Command(BaseCommand):
|
||||||
"""Run worker"""
|
"""Run worker"""
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument("-b", "--beat", action="store_true")
|
parser.add_argument(
|
||||||
|
"-b",
|
||||||
|
"--beat",
|
||||||
|
action="store_false",
|
||||||
|
help="When set, this worker will _not_ run Beat (scheduled) tasks",
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, **options):
|
def handle(self, **options):
|
||||||
|
LOGGER.debug("Celery options", **options)
|
||||||
close_old_connections()
|
close_old_connections()
|
||||||
if CONFIG.get_bool("remote_debug"):
|
if CONFIG.get_bool("remote_debug"):
|
||||||
import debugpy
|
import debugpy
|
||||||
|
|
|
@ -97,6 +97,7 @@ class SourceFlowManager:
|
||||||
if self.request.user.is_authenticated:
|
if self.request.user.is_authenticated:
|
||||||
new_connection.user = self.request.user
|
new_connection.user = self.request.user
|
||||||
new_connection = self.update_connection(new_connection, **kwargs)
|
new_connection = self.update_connection(new_connection, **kwargs)
|
||||||
|
# pylint: disable=no-member
|
||||||
new_connection.save()
|
new_connection.save()
|
||||||
return Action.LINK, new_connection
|
return Action.LINK, new_connection
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,6 @@
|
||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)">
|
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
|
||||||
<script src="{% static 'dist/poly.js' %}?version={{ version }}" type="module"></script>
|
<script src="{% static 'dist/poly.js' %}?version={{ version }}" type="module"></script>
|
||||||
<script src="{% static 'dist/standalone/loading/index.js' %}?version={{ version }}" type="module"></script>
|
<script src="{% static 'dist/standalone/loading/index.js' %}?version={{ version }}" type="module"></script>
|
||||||
|
|
|
@ -16,8 +16,8 @@ You've logged out of {{ application }}.
|
||||||
{% block card %}
|
{% block card %}
|
||||||
<form method="POST" class="pf-c-form">
|
<form method="POST" class="pf-c-form">
|
||||||
<p>
|
<p>
|
||||||
{% blocktrans with application=application.name %}
|
{% blocktrans with application=application.name branding_title=tenant.branding_title %}
|
||||||
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your authentik account.
|
You've logged out of {{ application }}. You can go back to the overview to launch another application, or log out of your {{ branding_title }} account.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
{% block head_before %}
|
{% block head_before %}
|
||||||
<link rel="prefetch" href="/static/dist/assets/images/flow_background.jpg" />
|
<link rel="prefetch" href="/static/dist/assets/images/flow_background.jpg" />
|
||||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)">
|
||||||
{% include "base/header_js.html" %}
|
{% include "base/header_js.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -1,13 +1,10 @@
|
||||||
"""authentik crypto app config"""
|
"""authentik crypto app config"""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from authentik.blueprints.apps import ManagedAppConfig
|
from authentik.blueprints.apps import ManagedAppConfig
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
|
||||||
|
|
||||||
MANAGED_KEY = "goauthentik.io/crypto/jwt-managed"
|
MANAGED_KEY = "goauthentik.io/crypto/jwt-managed"
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,33 +20,37 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
||||||
"""Load crypto tasks"""
|
"""Load crypto tasks"""
|
||||||
self.import_module("authentik.crypto.tasks")
|
self.import_module("authentik.crypto.tasks")
|
||||||
|
|
||||||
def _create_update_cert(self, cert: Optional["CertificateKeyPair"] = None):
|
def _create_update_cert(self):
|
||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
builder = CertificateBuilder("authentik Internal JWT Certificate")
|
common_name = "authentik Internal JWT Certificate"
|
||||||
|
builder = CertificateBuilder(common_name)
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=["goauthentik.io"],
|
subject_alt_names=["goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
if not cert:
|
CertificateKeyPair.objects.update_or_create(
|
||||||
cert = CertificateKeyPair()
|
managed=MANAGED_KEY,
|
||||||
builder.cert = cert
|
defaults={
|
||||||
builder.cert.managed = MANAGED_KEY
|
"name": common_name,
|
||||||
builder.save()
|
"certificate_data": builder.certificate,
|
||||||
|
"key_data": builder.private_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def reconcile_managed_jwt_cert(self):
|
def reconcile_managed_jwt_cert(self):
|
||||||
"""Ensure managed JWT certificate"""
|
"""Ensure managed JWT certificate"""
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
certs = CertificateKeyPair.objects.filter(managed=MANAGED_KEY)
|
cert: Optional[CertificateKeyPair] = CertificateKeyPair.objects.filter(
|
||||||
if not certs.exists():
|
managed=MANAGED_KEY
|
||||||
self._create_update_cert()
|
).first()
|
||||||
return
|
|
||||||
cert: CertificateKeyPair = certs.first()
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
if now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after:
|
if not cert or (
|
||||||
self._create_update_cert(cert)
|
now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after
|
||||||
|
):
|
||||||
|
self._create_update_cert()
|
||||||
|
|
||||||
def reconcile_self_signed(self):
|
def reconcile_self_signed(self):
|
||||||
"""Create self-signed keypair"""
|
"""Create self-signed keypair"""
|
||||||
|
@ -61,4 +62,10 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
||||||
return
|
return
|
||||||
builder = CertificateBuilder(name)
|
builder = CertificateBuilder(name)
|
||||||
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
||||||
builder.save()
|
CertificateKeyPair.objects.get_or_create(
|
||||||
|
name=name,
|
||||||
|
defaults={
|
||||||
|
"certificate_data": builder.certificate,
|
||||||
|
"key_data": builder.private_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
@ -136,6 +136,9 @@ class LicenseKey:
|
||||||
|
|
||||||
def record_usage(self):
|
def record_usage(self):
|
||||||
"""Capture the current validity status and metrics and save them"""
|
"""Capture the current validity status and metrics and save them"""
|
||||||
|
threshold = now() - timedelta(hours=8)
|
||||||
|
if LicenseUsage.objects.filter(record_date__gte=threshold).exists():
|
||||||
|
return
|
||||||
LicenseUsage.objects.create(
|
LicenseUsage.objects.create(
|
||||||
user_count=self.get_default_user_count(),
|
user_count=self.get_default_user_count(),
|
||||||
external_user_count=self.get_external_user_count(),
|
external_user_count=self.get_external_user_count(),
|
||||||
|
|
|
@ -6,7 +6,7 @@ from authentik.lib.utils.time import fqdn_rand
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
"enterprise_calculate_license": {
|
"enterprise_calculate_license": {
|
||||||
"task": "authentik.enterprise.tasks.calculate_license",
|
"task": "authentik.enterprise.tasks.calculate_license",
|
||||||
"schedule": crontab(minute=fqdn_rand("calculate_license"), hour="*/8"),
|
"schedule": crontab(minute=fqdn_rand("calculate_license"), hour="*/2"),
|
||||||
"options": {"queue": "authentik_scheduled"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,5 +6,4 @@ from authentik.root.celery import CELERY_APP
|
||||||
@CELERY_APP.task()
|
@CELERY_APP.task()
|
||||||
def calculate_license():
|
def calculate_license():
|
||||||
"""Calculate licensing status"""
|
"""Calculate licensing status"""
|
||||||
total = LicenseKey.get_total()
|
LicenseKey.get_total().record_usage()
|
||||||
total.record_usage()
|
|
||||||
|
|
|
@ -27,6 +27,7 @@ from authentik.lib.sentry import before_send
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
from authentik.outposts.models import OutpostServiceConnection
|
from authentik.outposts.models import OutpostServiceConnection
|
||||||
from authentik.policies.models import Policy, PolicyBindingModel
|
from authentik.policies.models import Policy, PolicyBindingModel
|
||||||
|
from authentik.policies.reputation.models import Reputation
|
||||||
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
|
||||||
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
from authentik.providers.scim.models import SCIMGroup, SCIMUser
|
||||||
from authentik.stages.authenticator_static.models import StaticToken
|
from authentik.stages.authenticator_static.models import StaticToken
|
||||||
|
@ -52,11 +53,13 @@ IGNORED_MODELS = (
|
||||||
RefreshToken,
|
RefreshToken,
|
||||||
SCIMUser,
|
SCIMUser,
|
||||||
SCIMGroup,
|
SCIMGroup,
|
||||||
|
Reputation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def should_log_model(model: Model) -> bool:
|
def should_log_model(model: Model) -> bool:
|
||||||
"""Return true if operation on `model` should be logged"""
|
"""Return true if operation on `model` should be logged"""
|
||||||
|
# Check for silk by string so this comparison doesn't fail when silk isn't installed
|
||||||
if model.__module__.startswith("silk"):
|
if model.__module__.startswith("silk"):
|
||||||
return False
|
return False
|
||||||
return model.__class__ not in IGNORED_MODELS
|
return model.__class__ not in IGNORED_MODELS
|
||||||
|
@ -93,21 +96,30 @@ class AuditMiddleware:
|
||||||
of models"""
|
of models"""
|
||||||
|
|
||||||
get_response: Callable[[HttpRequest], HttpResponse]
|
get_response: Callable[[HttpRequest], HttpResponse]
|
||||||
|
anonymous_user: User = None
|
||||||
|
|
||||||
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def _ensure_fallback_user(self):
|
||||||
|
"""Defer fetching anonymous user until we have to"""
|
||||||
|
if self.anonymous_user:
|
||||||
|
return
|
||||||
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
|
self.anonymous_user = get_anonymous_user()
|
||||||
|
|
||||||
def connect(self, request: HttpRequest):
|
def connect(self, request: HttpRequest):
|
||||||
"""Connect signal for automatic logging"""
|
"""Connect signal for automatic logging"""
|
||||||
if not hasattr(request, "user"):
|
self._ensure_fallback_user()
|
||||||
return
|
user = getattr(request, "user", self.anonymous_user)
|
||||||
if not getattr(request.user, "is_authenticated", False):
|
if not user.is_authenticated:
|
||||||
return
|
user = self.anonymous_user
|
||||||
if not hasattr(request, "request_id"):
|
if not hasattr(request, "request_id"):
|
||||||
return
|
return
|
||||||
post_save_handler = partial(self.post_save_handler, user=request.user, request=request)
|
post_save_handler = partial(self.post_save_handler, user=user, request=request)
|
||||||
pre_delete_handler = partial(self.pre_delete_handler, user=request.user, request=request)
|
pre_delete_handler = partial(self.pre_delete_handler, user=user, request=request)
|
||||||
m2m_changed_handler = partial(self.m2m_changed_handler, user=request.user, request=request)
|
m2m_changed_handler = partial(self.m2m_changed_handler, user=user, request=request)
|
||||||
post_save.connect(
|
post_save.connect(
|
||||||
post_save_handler,
|
post_save_handler,
|
||||||
dispatch_uid=request.request_id,
|
dispatch_uid=request.request_id,
|
||||||
|
|
|
@ -217,6 +217,7 @@ class Event(SerializerModel, ExpiringModel):
|
||||||
"path": request.path,
|
"path": request.path,
|
||||||
"method": request.method,
|
"method": request.method,
|
||||||
"args": cleanse_dict(QueryDict(request.META.get("QUERY_STRING", ""))),
|
"args": cleanse_dict(QueryDict(request.META.get("QUERY_STRING", ""))),
|
||||||
|
"user_agent": request.META.get("HTTP_USER_AGENT", ""),
|
||||||
}
|
}
|
||||||
# Special case for events created during flow execution
|
# Special case for events created during flow execution
|
||||||
# since they keep the http query within a wrapped query
|
# since they keep the http query within a wrapped query
|
||||||
|
|
|
@ -13,6 +13,7 @@ from authentik.events.tasks import event_notification_handler, gdpr_cleanup
|
||||||
from authentik.flows.models import Stage
|
from authentik.flows.models import Stage
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
|
from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
|
||||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.stages.invitation.models import Invitation
|
from authentik.stages.invitation.models import Invitation
|
||||||
from authentik.stages.invitation.signals import invitation_used
|
from authentik.stages.invitation.signals import invitation_used
|
||||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||||
|
@ -92,4 +93,5 @@ def event_post_save_notification(sender, instance: Event, **_):
|
||||||
@receiver(pre_delete, sender=User)
|
@receiver(pre_delete, sender=User)
|
||||||
def event_user_pre_delete_cleanup(sender, instance: User, **_):
|
def event_user_pre_delete_cleanup(sender, instance: User, **_):
|
||||||
"""If gdpr_compliance is enabled, remove all the user's events"""
|
"""If gdpr_compliance is enabled, remove all the user's events"""
|
||||||
gdpr_cleanup.delay(instance.pk)
|
if CONFIG.get_bool("gdpr_compliance", True):
|
||||||
|
gdpr_cleanup.delay(instance.pk)
|
||||||
|
|
|
@ -53,7 +53,15 @@ class TestEvents(TestCase):
|
||||||
"""Test plain from_http"""
|
"""Test plain from_http"""
|
||||||
event = Event.new("unittest").from_http(self.factory.get("/"))
|
event = Event.new("unittest").from_http(self.factory.get("/"))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
event.context, {"http_request": {"args": {}, "method": "GET", "path": "/"}}
|
event.context,
|
||||||
|
{
|
||||||
|
"http_request": {
|
||||||
|
"args": {},
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/",
|
||||||
|
"user_agent": "",
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_from_http_clean_querystring(self):
|
def test_from_http_clean_querystring(self):
|
||||||
|
@ -67,6 +75,7 @@ class TestEvents(TestCase):
|
||||||
"args": {"token": SafeExceptionReporterFilter.cleansed_substitute},
|
"args": {"token": SafeExceptionReporterFilter.cleansed_substitute},
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
|
"user_agent": "",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -83,6 +92,7 @@ class TestEvents(TestCase):
|
||||||
"args": {"token": SafeExceptionReporterFilter.cleansed_substitute},
|
"args": {"token": SafeExceptionReporterFilter.cleansed_substitute},
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
|
"user_agent": "",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,12 +5,13 @@ from dataclasses import asdict, is_dataclass
|
||||||
from datetime import date, datetime, time, timedelta
|
from datetime import date, datetime, time, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import GeneratorType
|
from types import GeneratorType, NoneType
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.handlers.wsgi import WSGIRequest
|
from django.core.handlers.wsgi import WSGIRequest
|
||||||
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.base import Model
|
from django.db.models.base import Model
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
|
@ -153,7 +154,20 @@ def sanitize_item(value: Any) -> Any:
|
||||||
return value.isoformat()
|
return value.isoformat()
|
||||||
if isinstance(value, timedelta):
|
if isinstance(value, timedelta):
|
||||||
return str(value.total_seconds())
|
return str(value.total_seconds())
|
||||||
return value
|
if callable(value):
|
||||||
|
return {
|
||||||
|
"type": "callable",
|
||||||
|
"name": value.__name__,
|
||||||
|
"module": value.__module__,
|
||||||
|
}
|
||||||
|
# List taken from the stdlib's JSON encoder (_make_iterencode, encoder.py:415)
|
||||||
|
if isinstance(value, (bool, int, float, NoneType, list, tuple, dict)):
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return DjangoJSONEncoder().default(value)
|
||||||
|
except TypeError:
|
||||||
|
return str(value)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
||||||
|
|
34
authentik/flows/migrations/0027_auto_20231028_1424.py
Normal file
34
authentik/flows/migrations/0027_auto_20231028_1424.py
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-28 14:24
|
||||||
|
|
||||||
|
from django.apps.registry import Apps
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||||
|
|
||||||
|
|
||||||
|
def set_oobe_flow_authentication(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||||
|
from guardian.shortcuts import get_anonymous_user
|
||||||
|
|
||||||
|
Flow = apps.get_model("authentik_flows", "Flow")
|
||||||
|
User = apps.get_model("authentik_core", "User")
|
||||||
|
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
|
||||||
|
users = User.objects.using(db_alias).exclude(username="akadmin")
|
||||||
|
try:
|
||||||
|
users = users.exclude(pk=get_anonymous_user().pk)
|
||||||
|
# pylint: disable=broad-except
|
||||||
|
except Exception: # nosec
|
||||||
|
pass
|
||||||
|
|
||||||
|
if users.exists():
|
||||||
|
Flow.objects.filter(slug="initial-setup").update(authentication="require_superuser")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("authentik_flows", "0026_alter_flow_options"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(set_oobe_flow_authentication),
|
||||||
|
]
|
|
@ -167,7 +167,11 @@ class ChallengeStageView(StageView):
|
||||||
stage_type=self.__class__.__name__, method="get_challenge"
|
stage_type=self.__class__.__name__, method="get_challenge"
|
||||||
).time(),
|
).time(),
|
||||||
):
|
):
|
||||||
challenge = self.get_challenge(*args, **kwargs)
|
try:
|
||||||
|
challenge = self.get_challenge(*args, **kwargs)
|
||||||
|
except StageInvalidException as exc:
|
||||||
|
self.logger.debug("Got StageInvalidException", exc=exc)
|
||||||
|
return self.executor.stage_invalid()
|
||||||
with Hub.current.start_span(
|
with Hub.current.start_span(
|
||||||
op="authentik.flow.stage._get_challenge",
|
op="authentik.flow.stage._get_challenge",
|
||||||
description=self.__class__.__name__,
|
description=self.__class__.__name__,
|
||||||
|
|
|
@ -18,7 +18,7 @@ from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import PassiveSerializer, is_dict
|
from authentik.core.api.utils import PassiveSerializer, is_dict
|
||||||
from authentik.core.models import Provider
|
from authentik.core.models import Provider
|
||||||
from authentik.outposts.api.service_connections import ServiceConnectionSerializer
|
from authentik.outposts.api.service_connections import ServiceConnectionSerializer
|
||||||
from authentik.outposts.apps import MANAGED_OUTPOST
|
from authentik.outposts.apps import MANAGED_OUTPOST, MANAGED_OUTPOST_NAME
|
||||||
from authentik.outposts.models import (
|
from authentik.outposts.models import (
|
||||||
Outpost,
|
Outpost,
|
||||||
OutpostConfig,
|
OutpostConfig,
|
||||||
|
@ -47,6 +47,16 @@ class OutpostSerializer(ModelSerializer):
|
||||||
source="service_connection", read_only=True
|
source="service_connection", read_only=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def validate_name(self, name: str) -> str:
|
||||||
|
"""Validate name (especially for embedded outpost)"""
|
||||||
|
if not self.instance:
|
||||||
|
return name
|
||||||
|
if self.instance.managed == MANAGED_OUTPOST and name != MANAGED_OUTPOST_NAME:
|
||||||
|
raise ValidationError("Embedded outpost's name cannot be changed")
|
||||||
|
if self.instance.name == MANAGED_OUTPOST_NAME:
|
||||||
|
self.instance.managed = MANAGED_OUTPOST
|
||||||
|
return name
|
||||||
|
|
||||||
def validate_providers(self, providers: list[Provider]) -> list[Provider]:
|
def validate_providers(self, providers: list[Provider]) -> list[Provider]:
|
||||||
"""Check that all providers match the type of the outpost"""
|
"""Check that all providers match the type of the outpost"""
|
||||||
type_map = {
|
type_map = {
|
||||||
|
|
|
@ -15,6 +15,7 @@ GAUGE_OUTPOSTS_LAST_UPDATE = Gauge(
|
||||||
["outpost", "uid", "version"],
|
["outpost", "uid", "version"],
|
||||||
)
|
)
|
||||||
MANAGED_OUTPOST = "goauthentik.io/outposts/embedded"
|
MANAGED_OUTPOST = "goauthentik.io/outposts/embedded"
|
||||||
|
MANAGED_OUTPOST_NAME = "authentik Embedded Outpost"
|
||||||
|
|
||||||
|
|
||||||
class AuthentikOutpostConfig(ManagedAppConfig):
|
class AuthentikOutpostConfig(ManagedAppConfig):
|
||||||
|
@ -35,14 +36,17 @@ class AuthentikOutpostConfig(ManagedAppConfig):
|
||||||
DockerServiceConnection,
|
DockerServiceConnection,
|
||||||
KubernetesServiceConnection,
|
KubernetesServiceConnection,
|
||||||
Outpost,
|
Outpost,
|
||||||
OutpostConfig,
|
|
||||||
OutpostType,
|
OutpostType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if outpost := Outpost.objects.filter(name=MANAGED_OUTPOST_NAME, managed="").first():
|
||||||
|
outpost.managed = MANAGED_OUTPOST
|
||||||
|
outpost.save()
|
||||||
|
return
|
||||||
outpost, updated = Outpost.objects.update_or_create(
|
outpost, updated = Outpost.objects.update_or_create(
|
||||||
defaults={
|
defaults={
|
||||||
"name": "authentik Embedded Outpost",
|
|
||||||
"type": OutpostType.PROXY,
|
"type": OutpostType.PROXY,
|
||||||
|
"name": MANAGED_OUTPOST_NAME,
|
||||||
},
|
},
|
||||||
managed=MANAGED_OUTPOST,
|
managed=MANAGED_OUTPOST,
|
||||||
)
|
)
|
||||||
|
@ -51,10 +55,4 @@ class AuthentikOutpostConfig(ManagedAppConfig):
|
||||||
outpost.service_connection = KubernetesServiceConnection.objects.first()
|
outpost.service_connection = KubernetesServiceConnection.objects.first()
|
||||||
elif DockerServiceConnection.objects.exists():
|
elif DockerServiceConnection.objects.exists():
|
||||||
outpost.service_connection = DockerServiceConnection.objects.first()
|
outpost.service_connection = DockerServiceConnection.objects.first()
|
||||||
outpost.config = OutpostConfig(
|
|
||||||
kubernetes_disabled_components=[
|
|
||||||
"deployment",
|
|
||||||
"secret",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
outpost.save()
|
outpost.save()
|
||||||
|
|
|
@ -43,6 +43,10 @@ class DeploymentReconciler(KubernetesObjectReconciler[V1Deployment]):
|
||||||
self.api = AppsV1Api(controller.client)
|
self.api = AppsV1Api(controller.client)
|
||||||
self.outpost = self.controller.outpost
|
self.outpost = self.controller.outpost
|
||||||
|
|
||||||
|
@property
|
||||||
|
def noop(self) -> bool:
|
||||||
|
return self.is_embedded
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reconciler_name() -> str:
|
def reconciler_name() -> str:
|
||||||
return "deployment"
|
return "deployment"
|
||||||
|
|
|
@ -24,6 +24,10 @@ class SecretReconciler(KubernetesObjectReconciler[V1Secret]):
|
||||||
super().__init__(controller)
|
super().__init__(controller)
|
||||||
self.api = CoreV1Api(controller.client)
|
self.api = CoreV1Api(controller.client)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def noop(self) -> bool:
|
||||||
|
return self.is_embedded
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reconciler_name() -> str:
|
def reconciler_name() -> str:
|
||||||
return "secret"
|
return "secret"
|
||||||
|
|
|
@ -77,7 +77,10 @@ class PrometheusServiceMonitorReconciler(KubernetesObjectReconciler[PrometheusSe
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def noop(self) -> bool:
|
def noop(self) -> bool:
|
||||||
return (not self._crd_exists()) or (self.is_embedded)
|
if not self._crd_exists():
|
||||||
|
self.logger.debug("CRD doesn't exist")
|
||||||
|
return True
|
||||||
|
return self.is_embedded
|
||||||
|
|
||||||
def _crd_exists(self) -> bool:
|
def _crd_exists(self) -> bool:
|
||||||
"""Check if the Prometheus ServiceMonitor exists"""
|
"""Check if the Prometheus ServiceMonitor exists"""
|
||||||
|
|
|
@ -344,12 +344,22 @@ class Outpost(SerializerModel, ManagedModel):
|
||||||
user_created = False
|
user_created = False
|
||||||
if not user:
|
if not user:
|
||||||
user: User = User.objects.create(username=self.user_identifier)
|
user: User = User.objects.create(username=self.user_identifier)
|
||||||
user.set_unusable_password()
|
|
||||||
user_created = True
|
user_created = True
|
||||||
user.type = UserTypes.INTERNAL_SERVICE_ACCOUNT
|
attrs = {
|
||||||
user.name = f"Outpost {self.name} Service-Account"
|
"type": UserTypes.INTERNAL_SERVICE_ACCOUNT,
|
||||||
user.path = USER_PATH_OUTPOSTS
|
"name": f"Outpost {self.name} Service-Account",
|
||||||
user.save()
|
"path": USER_PATH_OUTPOSTS,
|
||||||
|
}
|
||||||
|
dirty = False
|
||||||
|
for key, value in attrs.items():
|
||||||
|
if getattr(user, key) != value:
|
||||||
|
dirty = True
|
||||||
|
setattr(user, key, value)
|
||||||
|
if user.has_usable_password():
|
||||||
|
user.set_unusable_password()
|
||||||
|
dirty = True
|
||||||
|
if dirty:
|
||||||
|
user.save()
|
||||||
if user_created:
|
if user_created:
|
||||||
self.build_user_permissions(user)
|
self.build_user_permissions(user)
|
||||||
return user
|
return user
|
||||||
|
|
|
@ -2,11 +2,13 @@
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from authentik.blueprints.tests import reconcile_app
|
||||||
from authentik.core.models import PropertyMapping
|
from authentik.core.models import PropertyMapping
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.outposts.api.outposts import OutpostSerializer
|
from authentik.outposts.api.outposts import OutpostSerializer
|
||||||
from authentik.outposts.models import OutpostType, default_outpost_config
|
from authentik.outposts.apps import MANAGED_OUTPOST
|
||||||
|
from authentik.outposts.models import Outpost, OutpostType, default_outpost_config
|
||||||
from authentik.providers.ldap.models import LDAPProvider
|
from authentik.providers.ldap.models import LDAPProvider
|
||||||
from authentik.providers.proxy.models import ProxyProvider
|
from authentik.providers.proxy.models import ProxyProvider
|
||||||
|
|
||||||
|
@ -22,7 +24,36 @@ class TestOutpostServiceConnectionsAPI(APITestCase):
|
||||||
self.user = create_test_admin_user()
|
self.user = create_test_admin_user()
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
def test_outpost_validaton(self):
|
@reconcile_app("authentik_outposts")
|
||||||
|
def test_managed_name_change(self):
|
||||||
|
"""Test name change for embedded outpost"""
|
||||||
|
embedded_outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first()
|
||||||
|
self.assertIsNotNone(embedded_outpost)
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("authentik_api:outpost-detail", kwargs={"pk": embedded_outpost.pk}),
|
||||||
|
{"name": "foo"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content, {"name": ["Embedded outpost's name cannot be changed"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
@reconcile_app("authentik_outposts")
|
||||||
|
def test_managed_without_managed(self):
|
||||||
|
"""Test name change for embedded outpost"""
|
||||||
|
embedded_outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first()
|
||||||
|
self.assertIsNotNone(embedded_outpost)
|
||||||
|
embedded_outpost.managed = ""
|
||||||
|
embedded_outpost.save()
|
||||||
|
response = self.client.patch(
|
||||||
|
reverse("authentik_api:outpost-detail", kwargs={"pk": embedded_outpost.pk}),
|
||||||
|
{"name": "foo"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
embedded_outpost.refresh_from_db()
|
||||||
|
self.assertEqual(embedded_outpost.managed, MANAGED_OUTPOST)
|
||||||
|
|
||||||
|
def test_outpost_validation(self):
|
||||||
"""Test Outpost validation"""
|
"""Test Outpost validation"""
|
||||||
valid = OutpostSerializer(
|
valid = OutpostSerializer(
|
||||||
data={
|
data={
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
# Generated by Django 5.0 on 2023-12-22 23:20
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("authentik_providers_oauth2", "0016_alter_refreshtoken_token"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="accesstoken",
|
||||||
|
name="session_id",
|
||||||
|
field=models.CharField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="authorizationcode",
|
||||||
|
name="session_id",
|
||||||
|
field=models.CharField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="refreshtoken",
|
||||||
|
name="session_id",
|
||||||
|
field=models.CharField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
]
|
|
@ -296,6 +296,7 @@ class BaseGrantModel(models.Model):
|
||||||
revoked = models.BooleanField(default=False)
|
revoked = models.BooleanField(default=False)
|
||||||
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
_scope = models.TextField(default="", verbose_name=_("Scopes"))
|
||||||
auth_time = models.DateTimeField(verbose_name="Authentication time")
|
auth_time = models.DateTimeField(verbose_name="Authentication time")
|
||||||
|
session_id = models.CharField(default="", blank=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def scope(self) -> list[str]:
|
def scope(self) -> list[str]:
|
||||||
|
|
|
@ -85,6 +85,25 @@ class TestAuthorize(OAuthTestCase):
|
||||||
)
|
)
|
||||||
OAuthAuthorizationParams.from_request(request)
|
OAuthAuthorizationParams.from_request(request)
|
||||||
|
|
||||||
|
def test_blocked_redirect_uri(self):
|
||||||
|
"""test missing/invalid redirect URI"""
|
||||||
|
OAuth2Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
client_id="test",
|
||||||
|
authorization_flow=create_test_flow(),
|
||||||
|
redirect_uris="data:local.invalid",
|
||||||
|
)
|
||||||
|
with self.assertRaises(RedirectUriError):
|
||||||
|
request = self.factory.get(
|
||||||
|
"/",
|
||||||
|
data={
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": "test",
|
||||||
|
"redirect_uri": "data:localhost",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
OAuthAuthorizationParams.from_request(request)
|
||||||
|
|
||||||
def test_invalid_redirect_uri_empty(self):
|
def test_invalid_redirect_uri_empty(self):
|
||||||
"""test missing/invalid redirect URI"""
|
"""test missing/invalid redirect URI"""
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
|
|
187
authentik/providers/oauth2/tests/test_token_pkce.py
Normal file
187
authentik/providers/oauth2/tests/test_token_pkce.py
Normal file
|
@ -0,0 +1,187 @@
|
||||||
|
"""Test token view"""
|
||||||
|
from base64 import b64encode, urlsafe_b64encode
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from authentik.core.models import Application
|
||||||
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
|
from authentik.flows.challenge import ChallengeTypes
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
|
from authentik.providers.oauth2.constants import GRANT_TYPE_AUTHORIZATION_CODE
|
||||||
|
from authentik.providers.oauth2.models import AuthorizationCode, OAuth2Provider
|
||||||
|
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenPKCE(OAuthTestCase):
|
||||||
|
"""Test token view"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.app = Application.objects.create(name=generate_id(), slug="test")
|
||||||
|
|
||||||
|
def test_pkce_missing_in_token(self):
|
||||||
|
"""Test full with pkce"""
|
||||||
|
flow = create_test_flow()
|
||||||
|
provider = OAuth2Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
client_id="test",
|
||||||
|
authorization_flow=flow,
|
||||||
|
redirect_uris="foo://localhost",
|
||||||
|
access_code_validity="seconds=100",
|
||||||
|
)
|
||||||
|
Application.objects.create(name="app", slug="app", provider=provider)
|
||||||
|
state = generate_id()
|
||||||
|
user = create_test_admin_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
challenge = generate_id()
|
||||||
|
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||||
|
# Step 1, initiate params and get redirect to flow
|
||||||
|
self.client.get(
|
||||||
|
reverse("authentik_providers_oauth2:authorize"),
|
||||||
|
data={
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": "test",
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": "foo://localhost",
|
||||||
|
"code_challenge": challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
|
)
|
||||||
|
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(),
|
||||||
|
{
|
||||||
|
"component": "xak-flow-redirect",
|
||||||
|
"type": ChallengeTypes.REDIRECT.value,
|
||||||
|
"to": f"foo://localhost?code={code.code}&state={state}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
data={
|
||||||
|
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
"code": code.code,
|
||||||
|
# Missing the code_verifier here
|
||||||
|
"redirect_uri": "foo://localhost",
|
||||||
|
},
|
||||||
|
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||||
|
)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content,
|
||||||
|
{"error": "invalid_request", "error_description": "The request is otherwise malformed"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_pkce_correct_s256(self):
|
||||||
|
"""Test full with pkce"""
|
||||||
|
flow = create_test_flow()
|
||||||
|
provider = OAuth2Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
client_id="test",
|
||||||
|
authorization_flow=flow,
|
||||||
|
redirect_uris="foo://localhost",
|
||||||
|
access_code_validity="seconds=100",
|
||||||
|
)
|
||||||
|
Application.objects.create(name="app", slug="app", provider=provider)
|
||||||
|
state = generate_id()
|
||||||
|
user = create_test_admin_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
verifier = generate_id()
|
||||||
|
challenge = (
|
||||||
|
urlsafe_b64encode(sha256(verifier.encode("ascii")).digest())
|
||||||
|
.decode("utf-8")
|
||||||
|
.replace("=", "")
|
||||||
|
)
|
||||||
|
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||||
|
# Step 1, initiate params and get redirect to flow
|
||||||
|
self.client.get(
|
||||||
|
reverse("authentik_providers_oauth2:authorize"),
|
||||||
|
data={
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": "test",
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": "foo://localhost",
|
||||||
|
"code_challenge": challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
|
)
|
||||||
|
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(),
|
||||||
|
{
|
||||||
|
"component": "xak-flow-redirect",
|
||||||
|
"type": ChallengeTypes.REDIRECT.value,
|
||||||
|
"to": f"foo://localhost?code={code.code}&state={state}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
data={
|
||||||
|
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
"code": code.code,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
"redirect_uri": "foo://localhost",
|
||||||
|
},
|
||||||
|
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_pkce_correct_plain(self):
|
||||||
|
"""Test full with pkce"""
|
||||||
|
flow = create_test_flow()
|
||||||
|
provider = OAuth2Provider.objects.create(
|
||||||
|
name=generate_id(),
|
||||||
|
client_id="test",
|
||||||
|
authorization_flow=flow,
|
||||||
|
redirect_uris="foo://localhost",
|
||||||
|
access_code_validity="seconds=100",
|
||||||
|
)
|
||||||
|
Application.objects.create(name="app", slug="app", provider=provider)
|
||||||
|
state = generate_id()
|
||||||
|
user = create_test_admin_user()
|
||||||
|
self.client.force_login(user)
|
||||||
|
verifier = generate_id()
|
||||||
|
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||||
|
# Step 1, initiate params and get redirect to flow
|
||||||
|
self.client.get(
|
||||||
|
reverse("authentik_providers_oauth2:authorize"),
|
||||||
|
data={
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": "test",
|
||||||
|
"state": state,
|
||||||
|
"redirect_uri": "foo://localhost",
|
||||||
|
"code_challenge": verifier,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
|
)
|
||||||
|
code: AuthorizationCode = AuthorizationCode.objects.filter(user=user).first()
|
||||||
|
self.assertJSONEqual(
|
||||||
|
response.content.decode(),
|
||||||
|
{
|
||||||
|
"component": "xak-flow-redirect",
|
||||||
|
"type": ChallengeTypes.REDIRECT.value,
|
||||||
|
"to": f"foo://localhost?code={code.code}&state={state}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("authentik_providers_oauth2:token"),
|
||||||
|
data={
|
||||||
|
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
|
||||||
|
"code": code.code,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
"redirect_uri": "foo://localhost",
|
||||||
|
},
|
||||||
|
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
|
@ -188,6 +188,7 @@ def authenticate_provider(request: HttpRequest) -> Optional[OAuth2Provider]:
|
||||||
if client_id != provider.client_id or client_secret != provider.client_secret:
|
if client_id != provider.client_id or client_secret != provider.client_secret:
|
||||||
LOGGER.debug("(basic) Provider for basic auth does not exist")
|
LOGGER.debug("(basic) Provider for basic auth does not exist")
|
||||||
return None
|
return None
|
||||||
|
CTX_AUTH_VIA.set("oauth_client_secret")
|
||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""authentik OAuth2 Authorization views"""
|
"""authentik OAuth2 Authorization views"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from hashlib import sha256
|
||||||
from json import dumps
|
from json import dumps
|
||||||
from re import error as RegexError
|
from re import error as RegexError
|
||||||
from re import fullmatch
|
from re import fullmatch
|
||||||
|
@ -74,6 +75,7 @@ PLAN_CONTEXT_PARAMS = "goauthentik.io/providers/oauth2/params"
|
||||||
SESSION_KEY_LAST_LOGIN_UID = "authentik/providers/oauth2/last_login_uid"
|
SESSION_KEY_LAST_LOGIN_UID = "authentik/providers/oauth2/last_login_uid"
|
||||||
|
|
||||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSENT, PROMPT_LOGIN}
|
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSENT, PROMPT_LOGIN}
|
||||||
|
FORBIDDEN_URI_SCHEMES = {"javascript", "data", "vbscript"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
@ -174,6 +176,10 @@ class OAuthAuthorizationParams:
|
||||||
self.check_scope()
|
self.check_scope()
|
||||||
self.check_nonce()
|
self.check_nonce()
|
||||||
self.check_code_challenge()
|
self.check_code_challenge()
|
||||||
|
if self.request:
|
||||||
|
raise AuthorizeError(
|
||||||
|
self.redirect_uri, "request_not_supported", self.grant_type, self.state
|
||||||
|
)
|
||||||
|
|
||||||
def check_redirect_uri(self):
|
def check_redirect_uri(self):
|
||||||
"""Redirect URI validation."""
|
"""Redirect URI validation."""
|
||||||
|
@ -211,10 +217,9 @@ class OAuthAuthorizationParams:
|
||||||
expected=allowed_redirect_urls,
|
expected=allowed_redirect_urls,
|
||||||
)
|
)
|
||||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||||
if self.request:
|
# Check against forbidden schemes
|
||||||
raise AuthorizeError(
|
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
|
||||||
self.redirect_uri, "request_not_supported", self.grant_type, self.state
|
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||||
)
|
|
||||||
|
|
||||||
def check_scope(self):
|
def check_scope(self):
|
||||||
"""Ensure openid scope is set in Hybrid flows, or when requesting an id_token"""
|
"""Ensure openid scope is set in Hybrid flows, or when requesting an id_token"""
|
||||||
|
@ -282,6 +287,7 @@ class OAuthAuthorizationParams:
|
||||||
expires=now + timedelta_from_string(self.provider.access_code_validity),
|
expires=now + timedelta_from_string(self.provider.access_code_validity),
|
||||||
scope=self.scope,
|
scope=self.scope,
|
||||||
nonce=self.nonce,
|
nonce=self.nonce,
|
||||||
|
session_id=sha256(request.session.session_key.encode("ascii")).hexdigest(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.code_challenge and self.code_challenge_method:
|
if self.code_challenge and self.code_challenge_method:
|
||||||
|
@ -569,6 +575,7 @@ class OAuthFulfillmentStage(StageView):
|
||||||
expires=access_token_expiry,
|
expires=access_token_expiry,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
auth_time=auth_event.created if auth_event else now,
|
auth_time=auth_event.created if auth_event else now,
|
||||||
|
session_id=sha256(self.request.session.session_key.encode("ascii")).hexdigest(),
|
||||||
)
|
)
|
||||||
|
|
||||||
id_token = IDToken.new(self.provider, token, self.request)
|
id_token = IDToken.new(self.provider, token, self.request)
|
||||||
|
|
|
@ -6,6 +6,7 @@ from hashlib import sha256
|
||||||
from re import error as RegexError
|
from re import error as RegexError
|
||||||
from re import fullmatch
|
from re import fullmatch
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -17,6 +18,7 @@ from jwt import PyJWK, PyJWT, PyJWTError, decode
|
||||||
from sentry_sdk.hub import Hub
|
from sentry_sdk.hub import Hub
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.core.middleware import CTX_AUTH_VIA
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
USER_ATTRIBUTE_EXPIRES,
|
USER_ATTRIBUTE_EXPIRES,
|
||||||
USER_ATTRIBUTE_GENERATED,
|
USER_ATTRIBUTE_GENERATED,
|
||||||
|
@ -53,6 +55,7 @@ from authentik.providers.oauth2.models import (
|
||||||
RefreshToken,
|
RefreshToken,
|
||||||
)
|
)
|
||||||
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
|
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
|
||||||
|
from authentik.providers.oauth2.views.authorize import FORBIDDEN_URI_SCHEMES
|
||||||
from authentik.sources.oauth.models import OAuthSource
|
from authentik.sources.oauth.models import OAuthSource
|
||||||
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
|
||||||
|
|
||||||
|
@ -204,6 +207,10 @@ class TokenParams:
|
||||||
).from_http(request)
|
).from_http(request)
|
||||||
raise TokenError("invalid_client")
|
raise TokenError("invalid_client")
|
||||||
|
|
||||||
|
# Check against forbidden schemes
|
||||||
|
if urlparse(self.redirect_uri).scheme in FORBIDDEN_URI_SCHEMES:
|
||||||
|
raise TokenError("invalid_request")
|
||||||
|
|
||||||
self.authorization_code = AuthorizationCode.objects.filter(code=raw_code).first()
|
self.authorization_code = AuthorizationCode.objects.filter(code=raw_code).first()
|
||||||
if not self.authorization_code:
|
if not self.authorization_code:
|
||||||
LOGGER.warning("Code does not exist", code=raw_code)
|
LOGGER.warning("Code does not exist", code=raw_code)
|
||||||
|
@ -221,7 +228,10 @@ class TokenParams:
|
||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
# Validate PKCE parameters.
|
# Validate PKCE parameters.
|
||||||
if self.code_verifier:
|
if self.authorization_code.code_challenge:
|
||||||
|
# Authorization code had PKCE but we didn't get one
|
||||||
|
if not self.code_verifier:
|
||||||
|
raise TokenError("invalid_request")
|
||||||
if self.authorization_code.code_challenge_method == PKCE_METHOD_S256:
|
if self.authorization_code.code_challenge_method == PKCE_METHOD_S256:
|
||||||
new_code_challenge = (
|
new_code_challenge = (
|
||||||
urlsafe_b64encode(sha256(self.code_verifier.encode("ascii")).digest())
|
urlsafe_b64encode(sha256(self.code_verifier.encode("ascii")).digest())
|
||||||
|
@ -448,6 +458,7 @@ class TokenView(View):
|
||||||
if not self.provider:
|
if not self.provider:
|
||||||
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
||||||
raise TokenError("invalid_client")
|
raise TokenError("invalid_client")
|
||||||
|
CTX_AUTH_VIA.set("oauth_client_secret")
|
||||||
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
||||||
|
|
||||||
with Hub.current.start_span(
|
with Hub.current.start_span(
|
||||||
|
@ -482,6 +493,7 @@ class TokenView(View):
|
||||||
# Keep same scopes as previous token
|
# Keep same scopes as previous token
|
||||||
scope=self.params.authorization_code.scope,
|
scope=self.params.authorization_code.scope,
|
||||||
auth_time=self.params.authorization_code.auth_time,
|
auth_time=self.params.authorization_code.auth_time,
|
||||||
|
session_id=self.params.authorization_code.session_id,
|
||||||
)
|
)
|
||||||
access_token.id_token = IDToken.new(
|
access_token.id_token = IDToken.new(
|
||||||
self.provider,
|
self.provider,
|
||||||
|
@ -497,6 +509,7 @@ class TokenView(View):
|
||||||
expires=refresh_token_expiry,
|
expires=refresh_token_expiry,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
auth_time=self.params.authorization_code.auth_time,
|
auth_time=self.params.authorization_code.auth_time,
|
||||||
|
session_id=self.params.authorization_code.session_id,
|
||||||
)
|
)
|
||||||
id_token = IDToken.new(
|
id_token = IDToken.new(
|
||||||
self.provider,
|
self.provider,
|
||||||
|
@ -534,6 +547,7 @@ class TokenView(View):
|
||||||
# Keep same scopes as previous token
|
# Keep same scopes as previous token
|
||||||
scope=self.params.refresh_token.scope,
|
scope=self.params.refresh_token.scope,
|
||||||
auth_time=self.params.refresh_token.auth_time,
|
auth_time=self.params.refresh_token.auth_time,
|
||||||
|
session_id=self.params.refresh_token.session_id,
|
||||||
)
|
)
|
||||||
access_token.id_token = IDToken.new(
|
access_token.id_token = IDToken.new(
|
||||||
self.provider,
|
self.provider,
|
||||||
|
@ -549,6 +563,7 @@ class TokenView(View):
|
||||||
expires=refresh_token_expiry,
|
expires=refresh_token_expiry,
|
||||||
provider=self.provider,
|
provider=self.provider,
|
||||||
auth_time=self.params.refresh_token.auth_time,
|
auth_time=self.params.refresh_token.auth_time,
|
||||||
|
session_id=self.params.refresh_token.session_id,
|
||||||
)
|
)
|
||||||
id_token = IDToken.new(
|
id_token = IDToken.new(
|
||||||
self.provider,
|
self.provider,
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
"""proxy provider tasks"""
|
"""proxy provider tasks"""
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from django.db import DatabaseError, InternalError, ProgrammingError
|
from django.db import DatabaseError, InternalError, ProgrammingError
|
||||||
|
@ -23,6 +25,7 @@ def proxy_set_defaults():
|
||||||
def proxy_on_logout(session_id: str):
|
def proxy_on_logout(session_id: str):
|
||||||
"""Update outpost instances connected to a single outpost"""
|
"""Update outpost instances connected to a single outpost"""
|
||||||
layer = get_channel_layer()
|
layer = get_channel_layer()
|
||||||
|
hashed_session_id = sha256(session_id.encode("ascii")).hexdigest()
|
||||||
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
|
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
|
||||||
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
|
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
|
||||||
async_to_sync(layer.group_send)(
|
async_to_sync(layer.group_send)(
|
||||||
|
@ -30,6 +33,6 @@ def proxy_on_logout(session_id: str):
|
||||||
{
|
{
|
||||||
"type": "event.provider.specific",
|
"type": "event.provider.specific",
|
||||||
"sub_type": "logout",
|
"sub_type": "logout",
|
||||||
"session_id": session_id,
|
"session_id": hashed_session_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -21,6 +21,7 @@ class RadiusProviderSerializer(ProviderSerializer):
|
||||||
# an admin might have to view it
|
# an admin might have to view it
|
||||||
"shared_secret",
|
"shared_secret",
|
||||||
"outpost_set",
|
"outpost_set",
|
||||||
|
"mfa_support",
|
||||||
]
|
]
|
||||||
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
extra_kwargs = ProviderSerializer.Meta.extra_kwargs
|
||||||
|
|
||||||
|
@ -55,6 +56,7 @@ class RadiusOutpostConfigSerializer(ModelSerializer):
|
||||||
"auth_flow_slug",
|
"auth_flow_slug",
|
||||||
"client_networks",
|
"client_networks",
|
||||||
"shared_secret",
|
"shared_secret",
|
||||||
|
"mfa_support",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-18 15:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("authentik_providers_radius", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="radiusprovider",
|
||||||
|
name="mfa_support",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="When enabled, code-based multi-factor authentication can be used by appending a semicolon and the TOTP code to the password. This should only be enabled if all users that will bind to this provider have a TOTP device configured, as otherwise a password may incorrectly be rejected if it contains a semicolon.",
|
||||||
|
verbose_name="MFA Support",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -27,6 +27,17 @@ class RadiusProvider(OutpostModel, Provider):
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mfa_support = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name="MFA Support",
|
||||||
|
help_text=_(
|
||||||
|
"When enabled, code-based multi-factor authentication can be used by appending a "
|
||||||
|
"semicolon and the TOTP code to the password. This should only be enabled if all "
|
||||||
|
"users that will bind to this provider have a TOTP device configured, as otherwise "
|
||||||
|
"a password may incorrectly be rejected if it contains a semicolon."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def launch_url(self) -> Optional[str]:
|
def launch_url(self) -> Optional[str]:
|
||||||
"""Radius never has a launch URL"""
|
"""Radius never has a launch URL"""
|
||||||
|
|
|
@ -46,7 +46,9 @@ class SCIMGroupClient(SCIMClient[Group, SCIMGroupSchema]):
|
||||||
|
|
||||||
def to_scim(self, obj: Group) -> SCIMGroupSchema:
|
def to_scim(self, obj: Group) -> SCIMGroupSchema:
|
||||||
"""Convert authentik user into SCIM"""
|
"""Convert authentik user into SCIM"""
|
||||||
raw_scim_group = {}
|
raw_scim_group = {
|
||||||
|
"schemas": ("urn:ietf:params:scim:schemas:core:2.0:Group",),
|
||||||
|
}
|
||||||
for mapping in (
|
for mapping in (
|
||||||
self.provider.property_mappings_group.all().order_by("name").select_subclasses()
|
self.provider.property_mappings_group.all().order_by("name").select_subclasses()
|
||||||
):
|
):
|
||||||
|
|
|
@ -15,12 +15,14 @@ from pydanticscim.user import User as BaseUser
|
||||||
class User(BaseUser):
|
class User(BaseUser):
|
||||||
"""Modified User schema with added externalId field"""
|
"""Modified User schema with added externalId field"""
|
||||||
|
|
||||||
|
schemas: tuple[str] = ("urn:ietf:params:scim:schemas:core:2.0:User",)
|
||||||
externalId: Optional[str] = None
|
externalId: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class Group(BaseGroup):
|
class Group(BaseGroup):
|
||||||
"""Modified Group schema with added externalId field"""
|
"""Modified Group schema with added externalId field"""
|
||||||
|
|
||||||
|
schemas: tuple[str] = ("urn:ietf:params:scim:schemas:core:2.0:Group",)
|
||||||
externalId: Optional[str] = None
|
externalId: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,9 @@ class SCIMUserClient(SCIMClient[User, SCIMUserSchema]):
|
||||||
|
|
||||||
def to_scim(self, obj: User) -> SCIMUserSchema:
|
def to_scim(self, obj: User) -> SCIMUserSchema:
|
||||||
"""Convert authentik user into SCIM"""
|
"""Convert authentik user into SCIM"""
|
||||||
raw_scim_user = {}
|
raw_scim_user = {
|
||||||
|
"schemas": ("urn:ietf:params:scim:schemas:core:2.0:User",),
|
||||||
|
}
|
||||||
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses():
|
for mapping in self.provider.property_mappings.all().order_by("name").select_subclasses():
|
||||||
if not isinstance(mapping, SCIMMapping):
|
if not isinstance(mapping, SCIMMapping):
|
||||||
continue
|
continue
|
||||||
|
|
|
@ -61,7 +61,11 @@ class SCIMGroupTests(TestCase):
|
||||||
self.assertEqual(mock.request_history[1].method, "POST")
|
self.assertEqual(mock.request_history[1].method, "POST")
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mock.request_history[1].body,
|
mock.request_history[1].body,
|
||||||
{"externalId": str(group.pk), "displayName": group.name},
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||||
|
"externalId": str(group.pk),
|
||||||
|
"displayName": group.name,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@Mocker()
|
@Mocker()
|
||||||
|
@ -96,7 +100,11 @@ class SCIMGroupTests(TestCase):
|
||||||
validate(body, loads(schema.read()))
|
validate(body, loads(schema.read()))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
body,
|
body,
|
||||||
{"externalId": str(group.pk), "displayName": group.name},
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||||
|
"externalId": str(group.pk),
|
||||||
|
"displayName": group.name,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
group.save()
|
group.save()
|
||||||
self.assertEqual(mock.call_count, 4)
|
self.assertEqual(mock.call_count, 4)
|
||||||
|
@ -129,7 +137,11 @@ class SCIMGroupTests(TestCase):
|
||||||
self.assertEqual(mock.request_history[1].method, "POST")
|
self.assertEqual(mock.request_history[1].method, "POST")
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mock.request_history[1].body,
|
mock.request_history[1].body,
|
||||||
{"externalId": str(group.pk), "displayName": group.name},
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||||
|
"externalId": str(group.pk),
|
||||||
|
"displayName": group.name,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
group.delete()
|
group.delete()
|
||||||
self.assertEqual(mock.call_count, 4)
|
self.assertEqual(mock.call_count, 4)
|
||||||
|
|
|
@ -89,17 +89,22 @@ class SCIMMembershipTests(TestCase):
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mocker.request_history[3].body,
|
mocker.request_history[3].body,
|
||||||
{
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
"emails": [],
|
"emails": [],
|
||||||
"active": True,
|
"active": True,
|
||||||
"externalId": user.uid,
|
"externalId": user.uid,
|
||||||
"name": {"familyName": "", "formatted": "", "givenName": ""},
|
"name": {"familyName": " ", "formatted": " ", "givenName": ""},
|
||||||
"displayName": "",
|
"displayName": "",
|
||||||
"userName": user.username,
|
"userName": user.username,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mocker.request_history[5].body,
|
mocker.request_history[5].body,
|
||||||
{"externalId": str(group.pk), "displayName": group.name},
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||||
|
"externalId": str(group.pk),
|
||||||
|
"displayName": group.name,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
with Mocker() as mocker:
|
with Mocker() as mocker:
|
||||||
|
@ -118,6 +123,7 @@ class SCIMMembershipTests(TestCase):
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mocker.request_history[1].body,
|
mocker.request_history[1].body,
|
||||||
{
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
"Operations": [
|
"Operations": [
|
||||||
{
|
{
|
||||||
"op": "add",
|
"op": "add",
|
||||||
|
@ -125,7 +131,6 @@ class SCIMMembershipTests(TestCase):
|
||||||
"value": [{"value": user_scim_id}],
|
"value": [{"value": user_scim_id}],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -174,17 +179,22 @@ class SCIMMembershipTests(TestCase):
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mocker.request_history[3].body,
|
mocker.request_history[3].body,
|
||||||
{
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
"active": True,
|
"active": True,
|
||||||
"displayName": "",
|
"displayName": "",
|
||||||
"emails": [],
|
"emails": [],
|
||||||
"externalId": user.uid,
|
"externalId": user.uid,
|
||||||
"name": {"familyName": "", "formatted": "", "givenName": ""},
|
"name": {"familyName": " ", "formatted": " ", "givenName": ""},
|
||||||
"userName": user.username,
|
"userName": user.username,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mocker.request_history[5].body,
|
mocker.request_history[5].body,
|
||||||
{"externalId": str(group.pk), "displayName": group.name},
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"],
|
||||||
|
"externalId": str(group.pk),
|
||||||
|
"displayName": group.name,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
with Mocker() as mocker:
|
with Mocker() as mocker:
|
||||||
|
@ -203,6 +213,7 @@ class SCIMMembershipTests(TestCase):
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mocker.request_history[1].body,
|
mocker.request_history[1].body,
|
||||||
{
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
"Operations": [
|
"Operations": [
|
||||||
{
|
{
|
||||||
"op": "add",
|
"op": "add",
|
||||||
|
@ -210,7 +221,6 @@ class SCIMMembershipTests(TestCase):
|
||||||
"value": [{"value": user_scim_id}],
|
"value": [{"value": user_scim_id}],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -230,6 +240,7 @@ class SCIMMembershipTests(TestCase):
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mocker.request_history[1].body,
|
mocker.request_history[1].body,
|
||||||
{
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
||||||
"Operations": [
|
"Operations": [
|
||||||
{
|
{
|
||||||
"op": "remove",
|
"op": "remove",
|
||||||
|
@ -237,6 +248,5 @@ class SCIMMembershipTests(TestCase):
|
||||||
"value": [{"value": user_scim_id}],
|
"value": [{"value": user_scim_id}],
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -57,7 +57,7 @@ class SCIMUserTests(TestCase):
|
||||||
uid = generate_id()
|
uid = generate_id()
|
||||||
user = User.objects.create(
|
user = User.objects.create(
|
||||||
username=uid,
|
username=uid,
|
||||||
name=uid,
|
name=f"{uid} {uid}",
|
||||||
email=f"{uid}@goauthentik.io",
|
email=f"{uid}@goauthentik.io",
|
||||||
)
|
)
|
||||||
self.assertEqual(mock.call_count, 2)
|
self.assertEqual(mock.call_count, 2)
|
||||||
|
@ -66,6 +66,7 @@ class SCIMUserTests(TestCase):
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mock.request_history[1].body,
|
mock.request_history[1].body,
|
||||||
{
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
"active": True,
|
"active": True,
|
||||||
"emails": [
|
"emails": [
|
||||||
{
|
{
|
||||||
|
@ -76,11 +77,11 @@ class SCIMUserTests(TestCase):
|
||||||
],
|
],
|
||||||
"externalId": user.uid,
|
"externalId": user.uid,
|
||||||
"name": {
|
"name": {
|
||||||
"familyName": "",
|
"familyName": uid,
|
||||||
"formatted": uid,
|
"formatted": f"{uid} {uid}",
|
||||||
"givenName": uid,
|
"givenName": uid,
|
||||||
},
|
},
|
||||||
"displayName": uid,
|
"displayName": f"{uid} {uid}",
|
||||||
"userName": uid,
|
"userName": uid,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -109,7 +110,7 @@ class SCIMUserTests(TestCase):
|
||||||
uid = generate_id()
|
uid = generate_id()
|
||||||
user = User.objects.create(
|
user = User.objects.create(
|
||||||
username=uid,
|
username=uid,
|
||||||
name=uid,
|
name=f"{uid} {uid}",
|
||||||
email=f"{uid}@goauthentik.io",
|
email=f"{uid}@goauthentik.io",
|
||||||
)
|
)
|
||||||
self.assertEqual(mock.call_count, 2)
|
self.assertEqual(mock.call_count, 2)
|
||||||
|
@ -121,6 +122,7 @@ class SCIMUserTests(TestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
body,
|
body,
|
||||||
{
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
"active": True,
|
"active": True,
|
||||||
"emails": [
|
"emails": [
|
||||||
{
|
{
|
||||||
|
@ -129,11 +131,11 @@ class SCIMUserTests(TestCase):
|
||||||
"value": f"{uid}@goauthentik.io",
|
"value": f"{uid}@goauthentik.io",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"displayName": uid,
|
"displayName": f"{uid} {uid}",
|
||||||
"externalId": user.uid,
|
"externalId": user.uid,
|
||||||
"name": {
|
"name": {
|
||||||
"familyName": "",
|
"familyName": uid,
|
||||||
"formatted": uid,
|
"formatted": f"{uid} {uid}",
|
||||||
"givenName": uid,
|
"givenName": uid,
|
||||||
},
|
},
|
||||||
"userName": uid,
|
"userName": uid,
|
||||||
|
@ -164,7 +166,7 @@ class SCIMUserTests(TestCase):
|
||||||
uid = generate_id()
|
uid = generate_id()
|
||||||
user = User.objects.create(
|
user = User.objects.create(
|
||||||
username=uid,
|
username=uid,
|
||||||
name=uid,
|
name=f"{uid} {uid}",
|
||||||
email=f"{uid}@goauthentik.io",
|
email=f"{uid}@goauthentik.io",
|
||||||
)
|
)
|
||||||
self.assertEqual(mock.call_count, 2)
|
self.assertEqual(mock.call_count, 2)
|
||||||
|
@ -173,6 +175,7 @@ class SCIMUserTests(TestCase):
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mock.request_history[1].body,
|
mock.request_history[1].body,
|
||||||
{
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
"active": True,
|
"active": True,
|
||||||
"emails": [
|
"emails": [
|
||||||
{
|
{
|
||||||
|
@ -183,11 +186,11 @@ class SCIMUserTests(TestCase):
|
||||||
],
|
],
|
||||||
"externalId": user.uid,
|
"externalId": user.uid,
|
||||||
"name": {
|
"name": {
|
||||||
"familyName": "",
|
"familyName": uid,
|
||||||
"formatted": uid,
|
"formatted": f"{uid} {uid}",
|
||||||
"givenName": uid,
|
"givenName": uid,
|
||||||
},
|
},
|
||||||
"displayName": uid,
|
"displayName": f"{uid} {uid}",
|
||||||
"userName": uid,
|
"userName": uid,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
@ -227,7 +230,7 @@ class SCIMUserTests(TestCase):
|
||||||
)
|
)
|
||||||
user = User.objects.create(
|
user = User.objects.create(
|
||||||
username=uid,
|
username=uid,
|
||||||
name=uid,
|
name=f"{uid} {uid}",
|
||||||
email=f"{uid}@goauthentik.io",
|
email=f"{uid}@goauthentik.io",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -240,6 +243,7 @@ class SCIMUserTests(TestCase):
|
||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
mock.request_history[1].body,
|
mock.request_history[1].body,
|
||||||
{
|
{
|
||||||
|
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
|
||||||
"active": True,
|
"active": True,
|
||||||
"emails": [
|
"emails": [
|
||||||
{
|
{
|
||||||
|
@ -250,11 +254,11 @@ class SCIMUserTests(TestCase):
|
||||||
],
|
],
|
||||||
"externalId": user.uid,
|
"externalId": user.uid,
|
||||||
"name": {
|
"name": {
|
||||||
"familyName": "",
|
"familyName": uid,
|
||||||
"formatted": uid,
|
"formatted": f"{uid} {uid}",
|
||||||
"givenName": uid,
|
"givenName": uid,
|
||||||
},
|
},
|
||||||
"displayName": uid,
|
"displayName": f"{uid} {uid}",
|
||||||
"userName": uid,
|
"userName": uid,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -32,13 +32,19 @@ class PermissionSerializer(ModelSerializer):
|
||||||
|
|
||||||
def get_app_label_verbose(self, instance: Permission) -> str:
|
def get_app_label_verbose(self, instance: Permission) -> str:
|
||||||
"""Human-readable app label"""
|
"""Human-readable app label"""
|
||||||
return apps.get_app_config(instance.content_type.app_label).verbose_name
|
try:
|
||||||
|
return apps.get_app_config(instance.content_type.app_label).verbose_name
|
||||||
|
except LookupError:
|
||||||
|
return f"{instance.content_type.app_label}.{instance.content_type.model}"
|
||||||
|
|
||||||
def get_model_verbose(self, instance: Permission) -> str:
|
def get_model_verbose(self, instance: Permission) -> str:
|
||||||
"""Human-readable model name"""
|
"""Human-readable model name"""
|
||||||
return apps.get_model(
|
try:
|
||||||
instance.content_type.app_label, instance.content_type.model
|
return apps.get_model(
|
||||||
)._meta.verbose_name
|
instance.content_type.app_label, instance.content_type.model
|
||||||
|
)._meta.verbose_name
|
||||||
|
except LookupError:
|
||||||
|
return f"{instance.content_type.app_label}.{instance.content_type.model}"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Permission
|
model = Permission
|
||||||
|
|
|
@ -24,13 +24,19 @@ class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer):
|
||||||
|
|
||||||
def get_app_label_verbose(self, instance: GroupObjectPermission) -> str:
|
def get_app_label_verbose(self, instance: GroupObjectPermission) -> str:
|
||||||
"""Get app label from permission's model"""
|
"""Get app label from permission's model"""
|
||||||
return apps.get_app_config(instance.content_type.app_label).verbose_name
|
try:
|
||||||
|
return apps.get_app_config(instance.content_type.app_label).verbose_name
|
||||||
|
except LookupError:
|
||||||
|
return instance.content_type.app_label
|
||||||
|
|
||||||
def get_model_verbose(self, instance: GroupObjectPermission) -> str:
|
def get_model_verbose(self, instance: GroupObjectPermission) -> str:
|
||||||
"""Get model label from permission's model"""
|
"""Get model label from permission's model"""
|
||||||
return apps.get_model(
|
try:
|
||||||
instance.content_type.app_label, instance.content_type.model
|
return apps.get_model(
|
||||||
)._meta.verbose_name
|
instance.content_type.app_label, instance.content_type.model
|
||||||
|
)._meta.verbose_name
|
||||||
|
except LookupError:
|
||||||
|
return f"{instance.content_type.app_label}.{instance.content_type.model}"
|
||||||
|
|
||||||
def get_object_description(self, instance: GroupObjectPermission) -> Optional[str]:
|
def get_object_description(self, instance: GroupObjectPermission) -> Optional[str]:
|
||||||
"""Get model description from attached model. This operation takes at least
|
"""Get model description from attached model. This operation takes at least
|
||||||
|
@ -38,7 +44,10 @@ class ExtraRoleObjectPermissionSerializer(RoleObjectPermissionSerializer):
|
||||||
view_ permission on the object"""
|
view_ permission on the object"""
|
||||||
app_label = instance.content_type.app_label
|
app_label = instance.content_type.app_label
|
||||||
model = instance.content_type.model
|
model = instance.content_type.model
|
||||||
model_class = apps.get_model(app_label, model)
|
try:
|
||||||
|
model_class = apps.get_model(app_label, model)
|
||||||
|
except LookupError:
|
||||||
|
return None
|
||||||
objects = get_objects_for_group(instance.group, f"{app_label}.view_{model}", model_class)
|
objects = get_objects_for_group(instance.group, f"{app_label}.view_{model}", model_class)
|
||||||
obj = objects.first()
|
obj = objects.first()
|
||||||
if not obj:
|
if not obj:
|
||||||
|
|
|
@ -24,13 +24,19 @@ class ExtraUserObjectPermissionSerializer(UserObjectPermissionSerializer):
|
||||||
|
|
||||||
def get_app_label_verbose(self, instance: UserObjectPermission) -> str:
|
def get_app_label_verbose(self, instance: UserObjectPermission) -> str:
|
||||||
"""Get app label from permission's model"""
|
"""Get app label from permission's model"""
|
||||||
return apps.get_app_config(instance.content_type.app_label).verbose_name
|
try:
|
||||||
|
return apps.get_app_config(instance.content_type.app_label).verbose_name
|
||||||
|
except LookupError:
|
||||||
|
return instance.content_type.app_label
|
||||||
|
|
||||||
def get_model_verbose(self, instance: UserObjectPermission) -> str:
|
def get_model_verbose(self, instance: UserObjectPermission) -> str:
|
||||||
"""Get model label from permission's model"""
|
"""Get model label from permission's model"""
|
||||||
return apps.get_model(
|
try:
|
||||||
instance.content_type.app_label, instance.content_type.model
|
return apps.get_model(
|
||||||
)._meta.verbose_name
|
instance.content_type.app_label, instance.content_type.model
|
||||||
|
)._meta.verbose_name
|
||||||
|
except LookupError:
|
||||||
|
return f"{instance.content_type.app_label}.{instance.content_type.model}"
|
||||||
|
|
||||||
def get_object_description(self, instance: UserObjectPermission) -> Optional[str]:
|
def get_object_description(self, instance: UserObjectPermission) -> Optional[str]:
|
||||||
"""Get model description from attached model. This operation takes at least
|
"""Get model description from attached model. This operation takes at least
|
||||||
|
@ -38,7 +44,10 @@ class ExtraUserObjectPermissionSerializer(UserObjectPermissionSerializer):
|
||||||
view_ permission on the object"""
|
view_ permission on the object"""
|
||||||
app_label = instance.content_type.app_label
|
app_label = instance.content_type.app_label
|
||||||
model = instance.content_type.model
|
model = instance.content_type.model
|
||||||
model_class = apps.get_model(app_label, model)
|
try:
|
||||||
|
model_class = apps.get_model(app_label, model)
|
||||||
|
except LookupError:
|
||||||
|
return None
|
||||||
objects = get_objects_for_user(instance.user, f"{app_label}.view_{model}", model_class)
|
objects = get_objects_for_user(instance.user, f"{app_label}.view_{model}", model_class)
|
||||||
obj = objects.first()
|
obj = objects.first()
|
||||||
if not obj:
|
if not obj:
|
||||||
|
|
|
@ -30,6 +30,8 @@ class SourceTypeSerializer(PassiveSerializer):
|
||||||
authorization_url = CharField(read_only=True, allow_null=True)
|
authorization_url = CharField(read_only=True, allow_null=True)
|
||||||
access_token_url = CharField(read_only=True, allow_null=True)
|
access_token_url = CharField(read_only=True, allow_null=True)
|
||||||
profile_url = CharField(read_only=True, allow_null=True)
|
profile_url = CharField(read_only=True, allow_null=True)
|
||||||
|
oidc_well_known_url = CharField(read_only=True, allow_null=True)
|
||||||
|
oidc_jwks_url = CharField(read_only=True, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
class OAuthSourceSerializer(SourceSerializer):
|
class OAuthSourceSerializer(SourceSerializer):
|
||||||
|
@ -52,11 +54,15 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||||
@extend_schema_field(SourceTypeSerializer)
|
@extend_schema_field(SourceTypeSerializer)
|
||||||
def get_type(self, instance: OAuthSource) -> SourceTypeSerializer:
|
def get_type(self, instance: OAuthSource) -> SourceTypeSerializer:
|
||||||
"""Get source's type configuration"""
|
"""Get source's type configuration"""
|
||||||
return SourceTypeSerializer(instance.type).data
|
return SourceTypeSerializer(instance.source_type).data
|
||||||
|
|
||||||
def validate(self, attrs: dict) -> dict:
|
def validate(self, attrs: dict) -> dict:
|
||||||
session = get_http_session()
|
session = get_http_session()
|
||||||
well_known = attrs.get("oidc_well_known_url")
|
source_type = registry.find_type(attrs["provider_type"])
|
||||||
|
|
||||||
|
well_known = attrs.get("oidc_well_known_url") or source_type.oidc_well_known_url
|
||||||
|
inferred_oidc_jwks_url = None
|
||||||
|
|
||||||
if well_known and well_known != "":
|
if well_known and well_known != "":
|
||||||
try:
|
try:
|
||||||
well_known_config = session.get(well_known)
|
well_known_config = session.get(well_known)
|
||||||
|
@ -65,24 +71,23 @@ class OAuthSourceSerializer(SourceSerializer):
|
||||||
text = exc.response.text if exc.response else str(exc)
|
text = exc.response.text if exc.response else str(exc)
|
||||||
raise ValidationError({"oidc_well_known_url": text})
|
raise ValidationError({"oidc_well_known_url": text})
|
||||||
config = well_known_config.json()
|
config = well_known_config.json()
|
||||||
try:
|
if "issuer" not in config:
|
||||||
attrs["authorization_url"] = config["authorization_endpoint"]
|
raise ValidationError({"oidc_well_known_url": "Invalid well-known configuration"})
|
||||||
attrs["access_token_url"] = config["token_endpoint"]
|
attrs["authorization_url"] = config.get("authorization_endpoint", "")
|
||||||
attrs["profile_url"] = config["userinfo_endpoint"]
|
attrs["access_token_url"] = config.get("token_endpoint", "")
|
||||||
attrs["oidc_jwks_url"] = config["jwks_uri"]
|
attrs["profile_url"] = config.get("userinfo_endpoint", "")
|
||||||
except (IndexError, KeyError) as exc:
|
inferred_oidc_jwks_url = config.get("jwks_uri", "")
|
||||||
raise ValidationError(
|
|
||||||
{"oidc_well_known_url": f"Invalid well-known configuration: {exc}"}
|
|
||||||
)
|
|
||||||
|
|
||||||
jwks_url = attrs.get("oidc_jwks_url")
|
# Prefer user-entered URL to inferred URL to default URL
|
||||||
|
jwks_url = attrs.get("oidc_jwks_url") or inferred_oidc_jwks_url or source_type.oidc_jwks_url
|
||||||
if jwks_url and jwks_url != "":
|
if jwks_url and jwks_url != "":
|
||||||
|
attrs["oidc_jwks_url"] = jwks_url
|
||||||
try:
|
try:
|
||||||
jwks_config = session.get(jwks_url)
|
jwks_config = session.get(jwks_url)
|
||||||
jwks_config.raise_for_status()
|
jwks_config.raise_for_status()
|
||||||
except RequestException as exc:
|
except RequestException as exc:
|
||||||
text = exc.response.text if exc.response else str(exc)
|
text = exc.response.text if exc.response else str(exc)
|
||||||
raise ValidationError({"jwks_url": text})
|
raise ValidationError({"oidc_jwks_url": text})
|
||||||
config = jwks_config.json()
|
config = jwks_config.json()
|
||||||
attrs["oidc_jwks"] = config
|
attrs["oidc_jwks"] = config
|
||||||
|
|
||||||
|
|
|
@ -36,8 +36,8 @@ class BaseOAuthClient:
|
||||||
|
|
||||||
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
||||||
"""Fetch user profile information."""
|
"""Fetch user profile information."""
|
||||||
profile_url = self.source.type.profile_url or ""
|
profile_url = self.source.source_type.profile_url or ""
|
||||||
if self.source.type.urls_customizable and self.source.profile_url:
|
if self.source.source_type.urls_customizable and self.source.profile_url:
|
||||||
profile_url = self.source.profile_url
|
profile_url = self.source.profile_url
|
||||||
response = self.do_request("get", profile_url, token=token)
|
response = self.do_request("get", profile_url, token=token)
|
||||||
try:
|
try:
|
||||||
|
@ -57,8 +57,8 @@ class BaseOAuthClient:
|
||||||
|
|
||||||
def get_redirect_url(self, parameters=None):
|
def get_redirect_url(self, parameters=None):
|
||||||
"""Build authentication redirect url."""
|
"""Build authentication redirect url."""
|
||||||
authorization_url = self.source.type.authorization_url or ""
|
authorization_url = self.source.source_type.authorization_url or ""
|
||||||
if self.source.type.urls_customizable and self.source.authorization_url:
|
if self.source.source_type.urls_customizable and self.source.authorization_url:
|
||||||
authorization_url = self.source.authorization_url
|
authorization_url = self.source.authorization_url
|
||||||
if authorization_url == "":
|
if authorization_url == "":
|
||||||
Event.new(
|
Event.new(
|
||||||
|
|
|
@ -28,8 +28,8 @@ class OAuthClient(BaseOAuthClient):
|
||||||
if raw_token is not None and verifier is not None:
|
if raw_token is not None and verifier is not None:
|
||||||
token = self.parse_raw_token(raw_token)
|
token = self.parse_raw_token(raw_token)
|
||||||
try:
|
try:
|
||||||
access_token_url = self.source.type.access_token_url or ""
|
access_token_url = self.source.source_type.access_token_url or ""
|
||||||
if self.source.type.urls_customizable and self.source.access_token_url:
|
if self.source.source_type.urls_customizable and self.source.access_token_url:
|
||||||
access_token_url = self.source.access_token_url
|
access_token_url = self.source.access_token_url
|
||||||
response = self.do_request(
|
response = self.do_request(
|
||||||
"post",
|
"post",
|
||||||
|
@ -54,8 +54,8 @@ class OAuthClient(BaseOAuthClient):
|
||||||
"""Fetch the OAuth request token. Only required for OAuth 1.0."""
|
"""Fetch the OAuth request token. Only required for OAuth 1.0."""
|
||||||
callback = self.request.build_absolute_uri(self.callback)
|
callback = self.request.build_absolute_uri(self.callback)
|
||||||
try:
|
try:
|
||||||
request_token_url = self.source.type.request_token_url or ""
|
request_token_url = self.source.source_type.request_token_url or ""
|
||||||
if self.source.type.urls_customizable and self.source.request_token_url:
|
if self.source.source_type.urls_customizable and self.source.request_token_url:
|
||||||
request_token_url = self.source.request_token_url
|
request_token_url = self.source.request_token_url
|
||||||
response = self.do_request(
|
response = self.do_request(
|
||||||
"post",
|
"post",
|
||||||
|
|
|
@ -76,8 +76,8 @@ class OAuth2Client(BaseOAuthClient):
|
||||||
if SESSION_KEY_OAUTH_PKCE in self.request.session:
|
if SESSION_KEY_OAUTH_PKCE in self.request.session:
|
||||||
args["code_verifier"] = self.request.session[SESSION_KEY_OAUTH_PKCE]
|
args["code_verifier"] = self.request.session[SESSION_KEY_OAUTH_PKCE]
|
||||||
try:
|
try:
|
||||||
access_token_url = self.source.type.access_token_url or ""
|
access_token_url = self.source.source_type.access_token_url or ""
|
||||||
if self.source.type.urls_customizable and self.source.access_token_url:
|
if self.source.source_type.urls_customizable and self.source.access_token_url:
|
||||||
access_token_url = self.source.access_token_url
|
access_token_url = self.source.access_token_url
|
||||||
response = self.session.request(
|
response = self.session.request(
|
||||||
"post", access_token_url, data=args, headers=self._default_headers, **request_kwargs
|
"post", access_token_url, data=args, headers=self._default_headers, **request_kwargs
|
||||||
|
@ -140,8 +140,8 @@ class UserprofileHeaderAuthClient(OAuth2Client):
|
||||||
|
|
||||||
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
||||||
"Fetch user profile information."
|
"Fetch user profile information."
|
||||||
profile_url = self.source.type.profile_url or ""
|
profile_url = self.source.source_type.profile_url or ""
|
||||||
if self.source.type.urls_customizable and self.source.profile_url:
|
if self.source.source_type.urls_customizable and self.source.profile_url:
|
||||||
profile_url = self.source.profile_url
|
profile_url = self.source.profile_url
|
||||||
response = self.session.request(
|
response = self.session.request(
|
||||||
"get",
|
"get",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
"""OAuth Client models"""
|
"""OAuth Client models"""
|
||||||
from typing import TYPE_CHECKING, Optional, Type
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.http.request import HttpRequest
|
from django.http.request import HttpRequest
|
||||||
|
@ -55,7 +55,7 @@ class OAuthSource(Source):
|
||||||
oidc_jwks = models.JSONField(default=dict, blank=True)
|
oidc_jwks = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self) -> type["SourceType"]:
|
def source_type(self) -> type["SourceType"]:
|
||||||
"""Return the provider instance for this source"""
|
"""Return the provider instance for this source"""
|
||||||
from authentik.sources.oauth.types.registry import registry
|
from authentik.sources.oauth.types.registry import registry
|
||||||
|
|
||||||
|
@ -65,15 +65,14 @@ class OAuthSource(Source):
|
||||||
def component(self) -> str:
|
def component(self) -> str:
|
||||||
return "ak-source-oauth-form"
|
return "ak-source-oauth-form"
|
||||||
|
|
||||||
# we're using Type[] instead of type[] here since type[] interferes with the property above
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> Type[Serializer]:
|
def serializer(self) -> type[Serializer]:
|
||||||
from authentik.sources.oauth.api.source import OAuthSourceSerializer
|
from authentik.sources.oauth.api.source import OAuthSourceSerializer
|
||||||
|
|
||||||
return OAuthSourceSerializer
|
return OAuthSourceSerializer
|
||||||
|
|
||||||
def ui_login_button(self, request: HttpRequest) -> UILoginButton:
|
def ui_login_button(self, request: HttpRequest) -> UILoginButton:
|
||||||
provider_type = self.type
|
provider_type = self.source_type
|
||||||
provider = provider_type()
|
provider = provider_type()
|
||||||
icon = self.get_icon
|
icon = self.get_icon
|
||||||
if not icon:
|
if not icon:
|
||||||
|
@ -85,7 +84,7 @@ class OAuthSource(Source):
|
||||||
)
|
)
|
||||||
|
|
||||||
def ui_user_settings(self) -> Optional[UserSettingSerializer]:
|
def ui_user_settings(self) -> Optional[UserSettingSerializer]:
|
||||||
provider_type = self.type
|
provider_type = self.source_type
|
||||||
icon = self.get_icon
|
icon = self.get_icon
|
||||||
if not icon:
|
if not icon:
|
||||||
icon = provider_type().icon_url()
|
icon = provider_type().icon_url()
|
||||||
|
|
12
authentik/sources/oauth/settings.py
Normal file
12
authentik/sources/oauth/settings.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
"""OAuth source settings"""
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"update_oauth_source_oidc_well_known": {
|
||||||
|
"task": "authentik.sources.oauth.tasks.update_well_known_jwks",
|
||||||
|
"schedule": crontab(minute=fqdn_rand("update_well_known_jwks"), hour="*/3"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
|
}
|
70
authentik/sources/oauth/tasks.py
Normal file
70
authentik/sources/oauth/tasks.py
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
"""OAuth Source tasks"""
|
||||||
|
from json import dumps
|
||||||
|
|
||||||
|
from requests import RequestException
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||||
|
from authentik.lib.utils.http import get_http_session
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
from authentik.sources.oauth.models import OAuthSource
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||||
|
def update_well_known_jwks(self: MonitoredTask):
|
||||||
|
"""Update OAuth sources' config from well_known, and JWKS info from the configured URL"""
|
||||||
|
session = get_http_session()
|
||||||
|
result = TaskResult(TaskResultStatus.SUCCESSFUL, [])
|
||||||
|
for source in OAuthSource.objects.all().exclude(oidc_well_known_url=""):
|
||||||
|
try:
|
||||||
|
well_known_config = session.get(source.oidc_well_known_url)
|
||||||
|
well_known_config.raise_for_status()
|
||||||
|
except RequestException as exc:
|
||||||
|
text = exc.response.text if exc.response else str(exc)
|
||||||
|
LOGGER.warning("Failed to update well_known", source=source, exc=exc, text=text)
|
||||||
|
result.messages.append(f"Failed to update OIDC configuration for {source.slug}")
|
||||||
|
continue
|
||||||
|
config = well_known_config.json()
|
||||||
|
try:
|
||||||
|
dirty = False
|
||||||
|
source_attr_key = (
|
||||||
|
("authorization_url", "authorization_endpoint"),
|
||||||
|
("access_token_url", "token_endpoint"),
|
||||||
|
("profile_url", "userinfo_endpoint"),
|
||||||
|
("oidc_jwks_url", "jwks_uri"),
|
||||||
|
)
|
||||||
|
for source_attr, config_key in source_attr_key:
|
||||||
|
# Check if we're actually changing anything to only
|
||||||
|
# save when something has changed
|
||||||
|
if getattr(source, source_attr, "") != config[config_key]:
|
||||||
|
dirty = True
|
||||||
|
setattr(source, source_attr, config[config_key])
|
||||||
|
except (IndexError, KeyError) as exc:
|
||||||
|
LOGGER.warning(
|
||||||
|
"Failed to update well_known",
|
||||||
|
source=source,
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
result.messages.append(f"Failed to update OIDC configuration for {source.slug}")
|
||||||
|
continue
|
||||||
|
if dirty:
|
||||||
|
LOGGER.info("Updating sources' OpenID Configuration", source=source)
|
||||||
|
source.save()
|
||||||
|
|
||||||
|
for source in OAuthSource.objects.all().exclude(oidc_jwks_url=""):
|
||||||
|
try:
|
||||||
|
jwks_config = session.get(source.oidc_jwks_url)
|
||||||
|
jwks_config.raise_for_status()
|
||||||
|
except RequestException as exc:
|
||||||
|
text = exc.response.text if exc.response else str(exc)
|
||||||
|
LOGGER.warning("Failed to update JWKS", source=source, exc=exc, text=text)
|
||||||
|
result.messages.append(f"Failed to update JWKS for {source.slug}")
|
||||||
|
continue
|
||||||
|
config = jwks_config.json()
|
||||||
|
if dumps(source.oidc_jwks, sort_keys=True) != dumps(config, sort_keys=True):
|
||||||
|
source.oidc_jwks = config
|
||||||
|
LOGGER.info("Updating sources' JWKS", source=source)
|
||||||
|
source.save()
|
||||||
|
self.set_status(result)
|
48
authentik/sources/oauth/tests/test_tasks.py
Normal file
48
authentik/sources/oauth/tests/test_tasks.py
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
"""Test OAuth Source tasks"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from requests_mock import Mocker
|
||||||
|
|
||||||
|
from authentik.sources.oauth.models import OAuthSource
|
||||||
|
from authentik.sources.oauth.tasks import update_well_known_jwks
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthSourceTasks(TestCase):
|
||||||
|
"""Test OAuth Source tasks"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.source = OAuthSource.objects.create(
|
||||||
|
name="test",
|
||||||
|
slug="test",
|
||||||
|
provider_type="openidconnect",
|
||||||
|
authorization_url="",
|
||||||
|
profile_url="",
|
||||||
|
consumer_key="",
|
||||||
|
)
|
||||||
|
|
||||||
|
@Mocker()
|
||||||
|
def test_well_known_jwks(self, mock: Mocker):
|
||||||
|
"""Test well_known update"""
|
||||||
|
self.source.oidc_well_known_url = "http://foo/.well-known/openid-configuration"
|
||||||
|
self.source.save()
|
||||||
|
mock.get(
|
||||||
|
self.source.oidc_well_known_url,
|
||||||
|
json={
|
||||||
|
"authorization_endpoint": "foo",
|
||||||
|
"token_endpoint": "foo",
|
||||||
|
"userinfo_endpoint": "foo",
|
||||||
|
"jwks_uri": "http://foo/jwks",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mock.get("http://foo/jwks", json={"foo": "bar"})
|
||||||
|
update_well_known_jwks() # pylint: disable=no-value-for-parameter
|
||||||
|
self.source.refresh_from_db()
|
||||||
|
self.assertEqual(self.source.authorization_url, "foo")
|
||||||
|
self.assertEqual(self.source.access_token_url, "foo")
|
||||||
|
self.assertEqual(self.source.profile_url, "foo")
|
||||||
|
self.assertEqual(self.source.oidc_jwks_url, "http://foo/jwks")
|
||||||
|
self.assertEqual(
|
||||||
|
self.source.oidc_jwks,
|
||||||
|
{
|
||||||
|
"foo": "bar",
|
||||||
|
},
|
||||||
|
)
|
|
@ -50,6 +50,7 @@ class TestOAuthSource(TestCase):
|
||||||
def test_api_validate_openid_connect(self):
|
def test_api_validate_openid_connect(self):
|
||||||
"""Test API validation (with OIDC endpoints)"""
|
"""Test API validation (with OIDC endpoints)"""
|
||||||
openid_config = {
|
openid_config = {
|
||||||
|
"issuer": "foo",
|
||||||
"authorization_endpoint": "http://mock/oauth/authorize",
|
"authorization_endpoint": "http://mock/oauth/authorize",
|
||||||
"token_endpoint": "http://mock/oauth/token",
|
"token_endpoint": "http://mock/oauth/token",
|
||||||
"userinfo_endpoint": "http://mock/oauth/userinfo",
|
"userinfo_endpoint": "http://mock/oauth/userinfo",
|
||||||
|
|
|
@ -4,8 +4,8 @@ from typing import Any
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient
|
from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient
|
||||||
|
from authentik.sources.oauth.types.oidc import OpenIDConnectOAuth2Callback
|
||||||
from authentik.sources.oauth.types.registry import SourceType, registry
|
from authentik.sources.oauth.types.registry import SourceType, registry
|
||||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
|
||||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
@ -20,7 +20,7 @@ class AzureADOAuthRedirect(OAuthRedirect):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class AzureADOAuthCallback(OAuthCallback):
|
class AzureADOAuthCallback(OpenIDConnectOAuth2Callback):
|
||||||
"""AzureAD OAuth2 Callback"""
|
"""AzureAD OAuth2 Callback"""
|
||||||
|
|
||||||
client_class = UserprofileHeaderAuthClient
|
client_class = UserprofileHeaderAuthClient
|
||||||
|
@ -50,4 +50,8 @@ class AzureADType(SourceType):
|
||||||
|
|
||||||
authorization_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
|
authorization_url = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
|
||||||
access_token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec
|
access_token_url = "https://login.microsoftonline.com/common/oauth2/v2.0/token" # nosec
|
||||||
profile_url = "https://graph.microsoft.com/v1.0/me"
|
profile_url = "https://login.microsoftonline.com/common/openid/userinfo"
|
||||||
|
oidc_well_known_url = (
|
||||||
|
"https://login.microsoftonline.com/common/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
oidc_jwks_url = "https://login.microsoftonline.com/common/discovery/keys"
|
||||||
|
|
|
@ -23,8 +23,8 @@ class GitHubOAuth2Client(OAuth2Client):
|
||||||
|
|
||||||
def get_github_emails(self, token: dict[str, str]) -> list[dict[str, Any]]:
|
def get_github_emails(self, token: dict[str, str]) -> list[dict[str, Any]]:
|
||||||
"""Get Emails from the GitHub API"""
|
"""Get Emails from the GitHub API"""
|
||||||
profile_url = self.source.type.profile_url or ""
|
profile_url = self.source.source_type.profile_url or ""
|
||||||
if self.source.type.urls_customizable and self.source.profile_url:
|
if self.source.source_type.urls_customizable and self.source.profile_url:
|
||||||
profile_url = self.source.profile_url
|
profile_url = self.source.profile_url
|
||||||
profile_url += "/emails"
|
profile_url += "/emails"
|
||||||
response = self.do_request("get", profile_url, token=token)
|
response = self.do_request("get", profile_url, token=token)
|
||||||
|
@ -76,3 +76,7 @@ class GitHubType(SourceType):
|
||||||
authorization_url = "https://github.com/login/oauth/authorize"
|
authorization_url = "https://github.com/login/oauth/authorize"
|
||||||
access_token_url = "https://github.com/login/oauth/access_token" # nosec
|
access_token_url = "https://github.com/login/oauth/access_token" # nosec
|
||||||
profile_url = "https://api.github.com/user"
|
profile_url = "https://api.github.com/user"
|
||||||
|
oidc_well_known_url = (
|
||||||
|
"https://token.actions.githubusercontent.com/.well-known/openid-configuration"
|
||||||
|
)
|
||||||
|
oidc_jwks_url = "https://token.actions.githubusercontent.com/.well-known/jwks"
|
||||||
|
|
|
@ -40,3 +40,5 @@ class GoogleType(SourceType):
|
||||||
authorization_url = "https://accounts.google.com/o/oauth2/auth"
|
authorization_url = "https://accounts.google.com/o/oauth2/auth"
|
||||||
access_token_url = "https://oauth2.googleapis.com/token" # nosec
|
access_token_url = "https://oauth2.googleapis.com/token" # nosec
|
||||||
profile_url = "https://www.googleapis.com/oauth2/v1/userinfo"
|
profile_url = "https://www.googleapis.com/oauth2/v1/userinfo"
|
||||||
|
oidc_well_known_url = "https://accounts.google.com/.well-known/openid-configuration"
|
||||||
|
oidc_jwks_url = "https://www.googleapis.com/oauth2/v3/certs"
|
||||||
|
|
|
@ -26,8 +26,8 @@ class MailcowOAuth2Client(OAuth2Client):
|
||||||
|
|
||||||
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
def get_profile_info(self, token: dict[str, str]) -> Optional[dict[str, Any]]:
|
||||||
"Fetch user profile information."
|
"Fetch user profile information."
|
||||||
profile_url = self.source.type.profile_url or ""
|
profile_url = self.source.source_type.profile_url or ""
|
||||||
if self.source.type.urls_customizable and self.source.profile_url:
|
if self.source.source_type.urls_customizable and self.source.profile_url:
|
||||||
profile_url = self.source.profile_url
|
profile_url = self.source.profile_url
|
||||||
response = self.session.request(
|
response = self.session.request(
|
||||||
"get",
|
"get",
|
||||||
|
|
|
@ -23,7 +23,7 @@ class OpenIDConnectOAuth2Callback(OAuthCallback):
|
||||||
client_class = UserprofileHeaderAuthClient
|
client_class = UserprofileHeaderAuthClient
|
||||||
|
|
||||||
def get_user_id(self, info: dict[str, str]) -> str:
|
def get_user_id(self, info: dict[str, str]) -> str:
|
||||||
return info.get("sub", "")
|
return info.get("sub", None)
|
||||||
|
|
||||||
def get_user_enroll_context(
|
def get_user_enroll_context(
|
||||||
self,
|
self,
|
||||||
|
|
|
@ -3,8 +3,8 @@ from typing import Any
|
||||||
|
|
||||||
from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient
|
from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient
|
||||||
from authentik.sources.oauth.models import OAuthSource
|
from authentik.sources.oauth.models import OAuthSource
|
||||||
|
from authentik.sources.oauth.types.oidc import OpenIDConnectOAuth2Callback
|
||||||
from authentik.sources.oauth.types.registry import SourceType, registry
|
from authentik.sources.oauth.types.registry import SourceType, registry
|
||||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
|
||||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ class OktaOAuthRedirect(OAuthRedirect):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class OktaOAuth2Callback(OAuthCallback):
|
class OktaOAuth2Callback(OpenIDConnectOAuth2Callback):
|
||||||
"""Okta OAuth2 Callback"""
|
"""Okta OAuth2 Callback"""
|
||||||
|
|
||||||
# Okta has the same quirk as azure and throws an error if the access token
|
# Okta has the same quirk as azure and throws an error if the access token
|
||||||
|
@ -25,9 +25,6 @@ class OktaOAuth2Callback(OAuthCallback):
|
||||||
# see https://github.com/goauthentik/authentik/issues/1910
|
# see https://github.com/goauthentik/authentik/issues/1910
|
||||||
client_class = UserprofileHeaderAuthClient
|
client_class = UserprofileHeaderAuthClient
|
||||||
|
|
||||||
def get_user_id(self, info: dict[str, str]) -> str:
|
|
||||||
return info.get("sub", "")
|
|
||||||
|
|
||||||
def get_user_enroll_context(
|
def get_user_enroll_context(
|
||||||
self,
|
self,
|
||||||
info: dict[str, Any],
|
info: dict[str, Any],
|
||||||
|
|
|
@ -12,8 +12,9 @@ class PatreonOAuthRedirect(OAuthRedirect):
|
||||||
"""Patreon OAuth2 Redirect"""
|
"""Patreon OAuth2 Redirect"""
|
||||||
|
|
||||||
def get_additional_parameters(self, source: OAuthSource): # pragma: no cover
|
def get_additional_parameters(self, source: OAuthSource): # pragma: no cover
|
||||||
|
# https://docs.patreon.com/#scopes
|
||||||
return {
|
return {
|
||||||
"scope": ["openid", "email", "profile"],
|
"scope": ["identity", "identity[email]"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,8 @@ class SourceType:
|
||||||
authorization_url: Optional[str] = None
|
authorization_url: Optional[str] = None
|
||||||
access_token_url: Optional[str] = None
|
access_token_url: Optional[str] = None
|
||||||
profile_url: Optional[str] = None
|
profile_url: Optional[str] = None
|
||||||
|
oidc_well_known_url: Optional[str] = None
|
||||||
|
oidc_jwks_url: Optional[str] = None
|
||||||
|
|
||||||
def icon_url(self) -> str:
|
def icon_url(self) -> str:
|
||||||
"""Get Icon URL for login"""
|
"""Get Icon URL for login"""
|
||||||
|
|
|
@ -3,8 +3,8 @@ from json import dumps
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient
|
from authentik.sources.oauth.clients.oauth2 import UserprofileHeaderAuthClient
|
||||||
|
from authentik.sources.oauth.types.oidc import OpenIDConnectOAuth2Callback
|
||||||
from authentik.sources.oauth.types.registry import SourceType, registry
|
from authentik.sources.oauth.types.registry import SourceType, registry
|
||||||
from authentik.sources.oauth.views.callback import OAuthCallback
|
|
||||||
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
from authentik.sources.oauth.views.redirect import OAuthRedirect
|
||||||
|
|
||||||
|
|
||||||
|
@ -27,14 +27,11 @@ class TwitchOAuthRedirect(OAuthRedirect):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class TwitchOAuth2Callback(OAuthCallback):
|
class TwitchOAuth2Callback(OpenIDConnectOAuth2Callback):
|
||||||
"""Twitch OAuth2 Callback"""
|
"""Twitch OAuth2 Callback"""
|
||||||
|
|
||||||
client_class = TwitchClient
|
client_class = TwitchClient
|
||||||
|
|
||||||
def get_user_id(self, info: dict[str, str]) -> str:
|
|
||||||
return info.get("sub", "")
|
|
||||||
|
|
||||||
def get_user_enroll_context(
|
def get_user_enroll_context(
|
||||||
self,
|
self,
|
||||||
info: dict[str, Any],
|
info: dict[str, Any],
|
||||||
|
|
|
@ -25,7 +25,7 @@ class OAuthClientMixin:
|
||||||
if self.client_class is not None:
|
if self.client_class is not None:
|
||||||
# pylint: disable=not-callable
|
# pylint: disable=not-callable
|
||||||
return self.client_class(source, self.request, **kwargs)
|
return self.client_class(source, self.request, **kwargs)
|
||||||
if source.type.request_token_url or source.request_token_url:
|
if source.source_type.request_token_url or source.request_token_url:
|
||||||
client = OAuthClient(source, self.request, **kwargs)
|
client = OAuthClient(source, self.request, **kwargs)
|
||||||
else:
|
else:
|
||||||
client = OAuth2Client(source, self.request, **kwargs)
|
client = OAuth2Client(source, self.request, **kwargs)
|
||||||
|
|
|
@ -12,7 +12,6 @@ from authentik.flows.challenge import (
|
||||||
Challenge,
|
Challenge,
|
||||||
ChallengeResponse,
|
ChallengeResponse,
|
||||||
ChallengeTypes,
|
ChallengeTypes,
|
||||||
ErrorDetailSerializer,
|
|
||||||
WithUserInfoChallenge,
|
WithUserInfoChallenge,
|
||||||
)
|
)
|
||||||
from authentik.flows.stage import ChallengeStageView
|
from authentik.flows.stage import ChallengeStageView
|
||||||
|
@ -24,6 +23,7 @@ from authentik.stages.authenticator_sms.models import (
|
||||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||||
|
|
||||||
SESSION_KEY_SMS_DEVICE = "authentik/stages/authenticator_sms/sms_device"
|
SESSION_KEY_SMS_DEVICE = "authentik/stages/authenticator_sms/sms_device"
|
||||||
|
PLAN_CONTEXT_PHONE = "phone"
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatorSMSChallenge(WithUserInfoChallenge):
|
class AuthenticatorSMSChallenge(WithUserInfoChallenge):
|
||||||
|
@ -48,6 +48,8 @@ class AuthenticatorSMSChallengeResponse(ChallengeResponse):
|
||||||
def validate(self, attrs: dict) -> dict:
|
def validate(self, attrs: dict) -> dict:
|
||||||
"""Check"""
|
"""Check"""
|
||||||
if "code" not in attrs:
|
if "code" not in attrs:
|
||||||
|
if "phone_number" not in attrs:
|
||||||
|
raise ValidationError("phone_number required")
|
||||||
self.device.phone_number = attrs["phone_number"]
|
self.device.phone_number = attrs["phone_number"]
|
||||||
self.stage.validate_and_send(attrs["phone_number"])
|
self.stage.validate_and_send(attrs["phone_number"])
|
||||||
return super().validate(attrs)
|
return super().validate(attrs)
|
||||||
|
@ -75,9 +77,9 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||||
|
|
||||||
def _has_phone_number(self) -> Optional[str]:
|
def _has_phone_number(self) -> Optional[str]:
|
||||||
context = self.executor.plan.context
|
context = self.executor.plan.context
|
||||||
if "phone" in context.get(PLAN_CONTEXT_PROMPT, {}):
|
if PLAN_CONTEXT_PHONE in context.get(PLAN_CONTEXT_PROMPT, {}):
|
||||||
self.logger.debug("got phone number from plan context")
|
self.logger.debug("got phone number from plan context")
|
||||||
return context.get(PLAN_CONTEXT_PROMPT, {}).get("phone")
|
return context.get(PLAN_CONTEXT_PROMPT, {}).get(PLAN_CONTEXT_PHONE)
|
||||||
if SESSION_KEY_SMS_DEVICE in self.request.session:
|
if SESSION_KEY_SMS_DEVICE in self.request.session:
|
||||||
self.logger.debug("got phone number from device in session")
|
self.logger.debug("got phone number from device in session")
|
||||||
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
device: SMSDevice = self.request.session[SESSION_KEY_SMS_DEVICE]
|
||||||
|
@ -113,10 +115,17 @@ class AuthenticatorSMSStageView(ChallengeStageView):
|
||||||
try:
|
try:
|
||||||
self.validate_and_send(phone_number)
|
self.validate_and_send(phone_number)
|
||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
response = AuthenticatorSMSChallengeResponse()
|
# We had a phone number given already (at this point only possible from flow
|
||||||
response._errors.setdefault("phone_number", [])
|
# context), but an error occurred while sending a number (most likely)
|
||||||
response._errors["phone_number"].append(ErrorDetailSerializer(exc.detail))
|
# due to a duplicate device, so delete the number we got given, reset the state
|
||||||
return self.challenge_invalid(response)
|
# (ish) and retry
|
||||||
|
device.phone_number = ""
|
||||||
|
self.executor.plan.context.get(PLAN_CONTEXT_PROMPT, {}).pop(
|
||||||
|
PLAN_CONTEXT_PHONE, 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)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||||
|
|
|
@ -7,7 +7,9 @@ from requests_mock import Mocker
|
||||||
|
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
from authentik.flows.models import FlowStageBinding
|
from authentik.flows.models import FlowStageBinding
|
||||||
|
from authentik.flows.planner import FlowPlan
|
||||||
from authentik.flows.tests import FlowTestCase
|
from authentik.flows.tests import FlowTestCase
|
||||||
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
from authentik.lib.generators import generate_id
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.stages.authenticator_sms.models import (
|
from authentik.stages.authenticator_sms.models import (
|
||||||
AuthenticatorSMSStage,
|
AuthenticatorSMSStage,
|
||||||
|
@ -15,7 +17,8 @@ from authentik.stages.authenticator_sms.models import (
|
||||||
SMSProviders,
|
SMSProviders,
|
||||||
hash_phone_number,
|
hash_phone_number,
|
||||||
)
|
)
|
||||||
from authentik.stages.authenticator_sms.stage import SESSION_KEY_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
|
||||||
|
|
||||||
|
|
||||||
class AuthenticatorSMSStageTests(FlowTestCase):
|
class AuthenticatorSMSStageTests(FlowTestCase):
|
||||||
|
@ -172,6 +175,43 @@ class AuthenticatorSMSStageTests(FlowTestCase):
|
||||||
phone_number_required=False,
|
phone_number_required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_stage_context_data_duplicate(self):
|
||||||
|
"""test stage context data (phone number exists already)"""
|
||||||
|
self.client.get(
|
||||||
|
reverse("authentik_flows:configure", kwargs={"stage_uuid": self.stage.stage_uuid}),
|
||||||
|
)
|
||||||
|
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
|
||||||
|
plan.context[PLAN_CONTEXT_PROMPT] = {
|
||||||
|
PLAN_CONTEXT_PHONE: "1234",
|
||||||
|
}
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
SMSDevice.objects.create(
|
||||||
|
phone_number="1234",
|
||||||
|
user=self.user,
|
||||||
|
stage=self.stage,
|
||||||
|
)
|
||||||
|
sms_send_mock = MagicMock()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"authentik.stages.authenticator_sms.models.AuthenticatorSMSStage.send",
|
||||||
|
sms_send_mock,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
|
)
|
||||||
|
self.assertStageResponse(
|
||||||
|
response,
|
||||||
|
self.flow,
|
||||||
|
self.user,
|
||||||
|
component="ak-stage-authenticator-sms",
|
||||||
|
phone_number_required=True,
|
||||||
|
)
|
||||||
|
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
|
||||||
|
self.assertEqual(plan.context[PLAN_CONTEXT_PROMPT], {})
|
||||||
|
|
||||||
def test_stage_submit_full(self):
|
def test_stage_submit_full(self):
|
||||||
"""test stage (submit)"""
|
"""test stage (submit)"""
|
||||||
self.client.get(
|
self.client.get(
|
||||||
|
|
|
@ -184,6 +184,7 @@ class AuthenticatorValidateStageDuoTests(FlowTestCase):
|
||||||
"args": {},
|
"args": {},
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": f"/api/v3/flows/executor/{flow.slug}/",
|
"path": f"/api/v3/flows/executor/{flow.slug}/",
|
||||||
|
"user_agent": "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -11,7 +11,7 @@ class DenyStageSerializer(StageSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = DenyStage
|
model = DenyStage
|
||||||
fields = StageSerializer.Meta.fields
|
fields = StageSerializer.Meta.fields + ["deny_message"]
|
||||||
|
|
||||||
|
|
||||||
class DenyStageViewSet(UsedByMixin, ModelViewSet):
|
class DenyStageViewSet(UsedByMixin, ModelViewSet):
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 4.2.6 on 2023-10-18 09:11
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("authentik_stages_deny", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="denystage",
|
||||||
|
name="deny_message",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
]
|
|
@ -1,5 +1,5 @@
|
||||||
"""deny stage models"""
|
"""deny stage models"""
|
||||||
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from rest_framework.serializers import BaseSerializer
|
from rest_framework.serializers import BaseSerializer
|
||||||
|
@ -10,6 +10,8 @@ from authentik.flows.models import Stage
|
||||||
class DenyStage(Stage):
|
class DenyStage(Stage):
|
||||||
"""Cancels the current flow."""
|
"""Cancels the current flow."""
|
||||||
|
|
||||||
|
deny_message = models.TextField(blank=True, default="")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type[BaseSerializer]:
|
def serializer(self) -> type[BaseSerializer]:
|
||||||
from authentik.stages.deny.api import DenyStageSerializer
|
from authentik.stages.deny.api import DenyStageSerializer
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
|
||||||
from authentik.flows.stage import StageView
|
from authentik.flows.stage import StageView
|
||||||
|
from authentik.stages.deny.models import DenyStage
|
||||||
|
|
||||||
|
|
||||||
class DenyStageView(StageView):
|
class DenyStageView(StageView):
|
||||||
|
@ -9,4 +10,6 @@ class DenyStageView(StageView):
|
||||||
|
|
||||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""Cancels the current flow"""
|
"""Cancels the current flow"""
|
||||||
return self.executor.stage_invalid()
|
stage: DenyStage = self.executor.current_stage
|
||||||
|
message = self.executor.plan.context.get("deny_message", stage.deny_message)
|
||||||
|
return self.executor.stage_invalid(message)
|
||||||
|
|
|
@ -45,3 +45,38 @@ class TestUserDenyStage(FlowTestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertStageResponse(response, self.flow, component="ak-stage-access-denied")
|
self.assertStageResponse(response, self.flow, component="ak-stage-access-denied")
|
||||||
|
|
||||||
|
def test_message_static(self):
|
||||||
|
"""Test with a static error message"""
|
||||||
|
self.stage.deny_message = "foo"
|
||||||
|
self.stage.save()
|
||||||
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertStageResponse(
|
||||||
|
response, self.flow, component="ak-stage-access-denied", error_message="foo"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_message_overwrite(self):
|
||||||
|
"""Test with an overwritten error message"""
|
||||||
|
self.stage.deny_message = "foo"
|
||||||
|
self.stage.save()
|
||||||
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||||
|
plan.context["deny_message"] = "bar"
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertStageResponse(
|
||||||
|
response, self.flow, component="ak-stage-access-denied", error_message="bar"
|
||||||
|
)
|
||||||
|
|
|
@ -1,21 +1,26 @@
|
||||||
"""authentik multi-stage authentication engine"""
|
"""authentik multi-stage authentication engine"""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.http.request import QueryDict
|
||||||
|
from django.template.exceptions import TemplateSyntaxError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import urlencode
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
from rest_framework.fields import CharField
|
from rest_framework.fields import CharField
|
||||||
from rest_framework.serializers import ValidationError
|
from rest_framework.serializers import ValidationError
|
||||||
|
|
||||||
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
|
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes
|
||||||
|
from authentik.flows.exceptions import StageInvalidException
|
||||||
from authentik.flows.models import FlowDesignation, FlowToken
|
from authentik.flows.models import FlowDesignation, FlowToken
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
|
from authentik.flows.planner import PLAN_CONTEXT_IS_RESTORED, PLAN_CONTEXT_PENDING_USER
|
||||||
from authentik.flows.stage import ChallengeStageView
|
from authentik.flows.stage import ChallengeStageView
|
||||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
from authentik.flows.views.executor import QS_KEY_TOKEN, QS_QUERY
|
||||||
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
from authentik.stages.email.tasks import send_mails
|
from authentik.stages.email.tasks import send_mails
|
||||||
from authentik.stages.email.utils import TemplateEmailMessage
|
from authentik.stages.email.utils import TemplateEmailMessage
|
||||||
|
@ -51,8 +56,17 @@ class EmailStageView(ChallengeStageView):
|
||||||
"authentik_core:if-flow",
|
"authentik_core:if-flow",
|
||||||
kwargs={"flow_slug": self.executor.flow.slug},
|
kwargs={"flow_slug": self.executor.flow.slug},
|
||||||
)
|
)
|
||||||
relative_url = f"{base_url}?{urlencode(kwargs)}"
|
# Parse query string from current URL (full query string)
|
||||||
return self.request.build_absolute_uri(relative_url)
|
# this view is only run within a flow executor, where we need to get the query string
|
||||||
|
# from the query= parameter (double encoded); but for the redirect
|
||||||
|
# we need to expand it since it'll go through the flow interface
|
||||||
|
query_params = QueryDict(self.request.GET.get(QS_QUERY), mutable=True)
|
||||||
|
query_params.pop(QS_KEY_TOKEN, None)
|
||||||
|
query_params.update(kwargs)
|
||||||
|
full_url = base_url
|
||||||
|
if len(query_params) > 0:
|
||||||
|
full_url = f"{full_url}?{query_params.urlencode()}"
|
||||||
|
return self.request.build_absolute_uri(full_url)
|
||||||
|
|
||||||
def get_token(self) -> FlowToken:
|
def get_token(self) -> FlowToken:
|
||||||
"""Get token"""
|
"""Get token"""
|
||||||
|
@ -61,7 +75,7 @@ class EmailStageView(ChallengeStageView):
|
||||||
valid_delta = timedelta(
|
valid_delta = timedelta(
|
||||||
minutes=current_stage.token_expiry + 1
|
minutes=current_stage.token_expiry + 1
|
||||||
) # + 1 because django timesince always rounds down
|
) # + 1 because django timesince always rounds down
|
||||||
identifier = slugify(f"ak-email-stage-{current_stage.name}-{pending_user}")
|
identifier = slugify(f"ak-email-stage-{current_stage.name}-{str(uuid4())}")
|
||||||
# Don't check for validity here, we only care if the token exists
|
# Don't check for validity here, we only care if the token exists
|
||||||
tokens = FlowToken.objects.filter(identifier=identifier)
|
tokens = FlowToken.objects.filter(identifier=identifier)
|
||||||
if not tokens.exists():
|
if not tokens.exists():
|
||||||
|
@ -93,18 +107,27 @@ class EmailStageView(ChallengeStageView):
|
||||||
current_stage: EmailStage = self.executor.current_stage
|
current_stage: EmailStage = self.executor.current_stage
|
||||||
token = self.get_token()
|
token = self.get_token()
|
||||||
# Send mail to user
|
# Send mail to user
|
||||||
message = TemplateEmailMessage(
|
try:
|
||||||
subject=_(current_stage.subject),
|
message = TemplateEmailMessage(
|
||||||
to=[email],
|
subject=_(current_stage.subject),
|
||||||
language=pending_user.locale(self.request),
|
to=[email],
|
||||||
template_name=current_stage.template,
|
language=pending_user.locale(self.request),
|
||||||
template_context={
|
template_name=current_stage.template,
|
||||||
"url": self.get_full_url(**{QS_KEY_TOKEN: token.key}),
|
template_context={
|
||||||
"user": pending_user,
|
"url": self.get_full_url(**{QS_KEY_TOKEN: token.key}),
|
||||||
"expires": token.expires,
|
"user": pending_user,
|
||||||
},
|
"expires": token.expires,
|
||||||
)
|
},
|
||||||
send_mails(current_stage, message)
|
)
|
||||||
|
send_mails(current_stage, message)
|
||||||
|
except TemplateSyntaxError as exc:
|
||||||
|
Event.new(
|
||||||
|
EventAction.CONFIGURATION_ERROR,
|
||||||
|
message=_("Exception occurred while rendering E-mail template"),
|
||||||
|
error=exception_to_string(exc),
|
||||||
|
template=current_stage.template,
|
||||||
|
).from_http(self.request)
|
||||||
|
raise StageInvalidException from exc
|
||||||
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
# Check if the user came back from the email link to verify
|
# Check if the user came back from the email link to verify
|
||||||
|
@ -125,7 +148,11 @@ class EmailStageView(ChallengeStageView):
|
||||||
return self.executor.stage_invalid()
|
return self.executor.stage_invalid()
|
||||||
# Check if we've already sent the initial e-mail
|
# Check if we've already sent the initial e-mail
|
||||||
if PLAN_CONTEXT_EMAIL_SENT not in self.executor.plan.context:
|
if PLAN_CONTEXT_EMAIL_SENT not in self.executor.plan.context:
|
||||||
self.send_email()
|
try:
|
||||||
|
self.send_email()
|
||||||
|
except StageInvalidException as exc:
|
||||||
|
self.logger.debug("Got StageInvalidException", exc=exc)
|
||||||
|
return self.executor.stage_invalid()
|
||||||
self.executor.plan.context[PLAN_CONTEXT_EMAIL_SENT] = True
|
self.executor.plan.context[PLAN_CONTEXT_EMAIL_SENT] = True
|
||||||
return super().get(request, *args, **kwargs)
|
return super().get(request, *args, **kwargs)
|
||||||
|
|
||||||
|
@ -146,6 +173,7 @@ class EmailStageView(ChallengeStageView):
|
||||||
messages.error(self.request, _("No pending user."))
|
messages.error(self.request, _("No pending user."))
|
||||||
return super().challenge_invalid(response)
|
return super().challenge_invalid(response)
|
||||||
self.send_email()
|
self.send_email()
|
||||||
|
messages.success(self.request, _("Email Successfully sent."))
|
||||||
# We can't call stage_ok yet, as we're still waiting
|
# We can't call stage_ok yet, as we're still waiting
|
||||||
# for the user to click the link in the email
|
# for the user to click the link in the email
|
||||||
return super().challenge_invalid(response)
|
return super().challenge_invalid(response)
|
||||||
|
|
|
@ -13,6 +13,7 @@ from authentik.events.models import Event, EventAction
|
||||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||||
from authentik.root.celery import CELERY_APP
|
from authentik.root.celery import CELERY_APP
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
|
from authentik.stages.email.utils import logo_data
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
@ -81,6 +82,10 @@ def send_mail(self: MonitoredTask, message: dict[Any, Any], email_stage_pk: Opti
|
||||||
# Because we use the Message-ID as UID for the task, manually assign it
|
# Because we use the Message-ID as UID for the task, manually assign it
|
||||||
message_object.extra_headers["Message-ID"] = message_id
|
message_object.extra_headers["Message-ID"] = message_id
|
||||||
|
|
||||||
|
# Add the logo (we can't add it in the previous message since MIMEImage
|
||||||
|
# can't be converted to json)
|
||||||
|
message_object.attach(logo_data())
|
||||||
|
|
||||||
LOGGER.debug("Sending mail", to=message_object.to)
|
LOGGER.debug("Sending mail", to=message_object.to)
|
||||||
backend.send_messages([message_object])
|
backend.send_messages([message_object])
|
||||||
Event.new(
|
Event.new(
|
||||||
|
|
|
@ -4,6 +4,7 @@ from unittest.mock import MagicMock, PropertyMock, patch
|
||||||
from django.core import mail
|
from django.core import mail
|
||||||
from django.core.mail.backends.locmem import EmailBackend
|
from django.core.mail.backends.locmem import EmailBackend
|
||||||
from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
|
from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend
|
||||||
|
from django.test import RequestFactory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
|
|
||||||
|
@ -12,10 +13,11 @@ from authentik.flows.markers import StageMarker
|
||||||
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
|
from authentik.flows.models import FlowDesignation, FlowStageBinding, FlowToken
|
||||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||||
from authentik.flows.tests import FlowTestCase
|
from authentik.flows.tests import FlowTestCase
|
||||||
from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN
|
from authentik.flows.views.executor import QS_KEY_TOKEN, SESSION_KEY_PLAN, FlowExecutorView
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
from authentik.stages.email.models import EmailStage
|
from authentik.stages.email.models import EmailStage
|
||||||
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE
|
from authentik.stages.email.stage import PLAN_CONTEXT_EMAIL_OVERRIDE, EmailStageView
|
||||||
|
|
||||||
|
|
||||||
class TestEmailStage(FlowTestCase):
|
class TestEmailStage(FlowTestCase):
|
||||||
|
@ -23,8 +25,8 @@ class TestEmailStage(FlowTestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
self.factory = RequestFactory()
|
||||||
self.user = create_test_admin_user()
|
self.user = create_test_admin_user()
|
||||||
|
|
||||||
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||||
self.stage = EmailStage.objects.create(
|
self.stage = EmailStage.objects.create(
|
||||||
name="email",
|
name="email",
|
||||||
|
@ -205,3 +207,69 @@ class TestEmailStage(FlowTestCase):
|
||||||
|
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertStageResponse(response, component="ak-stage-access-denied")
|
self.assertStageResponse(response, component="ak-stage-access-denied")
|
||||||
|
|
||||||
|
def test_url_no_params(self):
|
||||||
|
"""Test generation of the URL in the EMail"""
|
||||||
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||||
|
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
|
request = self.factory.get(url)
|
||||||
|
stage_view = EmailStageView(
|
||||||
|
FlowExecutorView(
|
||||||
|
request=request,
|
||||||
|
flow=self.flow,
|
||||||
|
),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
self.assertEqual(stage_view.get_full_url(), f"http://testserver/if/flow/{self.flow.slug}/")
|
||||||
|
|
||||||
|
def test_url_our_params(self):
|
||||||
|
"""Test that all of our parameters are passed to the URL correctly"""
|
||||||
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||||
|
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
|
request = self.factory.get(url)
|
||||||
|
stage_view = EmailStageView(
|
||||||
|
FlowExecutorView(
|
||||||
|
request=request,
|
||||||
|
flow=self.flow,
|
||||||
|
),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
token = generate_id()
|
||||||
|
self.assertEqual(
|
||||||
|
stage_view.get_full_url(**{QS_KEY_TOKEN: token}),
|
||||||
|
f"http://testserver/if/flow/{self.flow.slug}/?flow_token={token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_url_existing_params(self):
|
||||||
|
"""Test to ensure that URL params are preserved in the URL being sent"""
|
||||||
|
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
|
||||||
|
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
|
url += "?query=" + urlencode({"foo": "bar"})
|
||||||
|
request = self.factory.get(url)
|
||||||
|
stage_view = EmailStageView(
|
||||||
|
FlowExecutorView(
|
||||||
|
request=request,
|
||||||
|
flow=self.flow,
|
||||||
|
),
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
token = generate_id()
|
||||||
|
self.assertEqual(
|
||||||
|
stage_view.get_full_url(**{QS_KEY_TOKEN: token}),
|
||||||
|
f"http://testserver/if/flow/{self.flow.slug}/?foo=bar&flow_token={token}",
|
||||||
|
)
|
||||||
|
|
|
@ -4,11 +4,20 @@ from pathlib import Path
|
||||||
from shutil import rmtree
|
from shutil import rmtree
|
||||||
from tempfile import mkdtemp, mkstemp
|
from tempfile import mkdtemp, mkstemp
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from unittest.mock import PropertyMock, patch
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.test import TestCase
|
from django.core.mail.backends.locmem import EmailBackend
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from authentik.stages.email.models import get_template_choices
|
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||||
|
from authentik.events.models import Event, EventAction
|
||||||
|
from authentik.flows.markers import StageMarker
|
||||||
|
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||||
|
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||||
|
from authentik.flows.tests import FlowTestCase
|
||||||
|
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||||
|
from authentik.stages.email.models import EmailStage, get_template_choices
|
||||||
|
|
||||||
|
|
||||||
def get_templates_setting(temp_dir: str) -> dict[str, Any]:
|
def get_templates_setting(temp_dir: str) -> dict[str, Any]:
|
||||||
|
@ -18,11 +27,18 @@ def get_templates_setting(temp_dir: str) -> dict[str, Any]:
|
||||||
return templates_setting
|
return templates_setting
|
||||||
|
|
||||||
|
|
||||||
class TestEmailStageTemplates(TestCase):
|
class TestEmailStageTemplates(FlowTestCase):
|
||||||
"""Email tests"""
|
"""Email tests"""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.dir = mkdtemp()
|
self.dir = Path(mkdtemp())
|
||||||
|
self.user = create_test_admin_user()
|
||||||
|
|
||||||
|
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||||
|
self.stage = EmailStage.objects.create(
|
||||||
|
name="email",
|
||||||
|
)
|
||||||
|
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
rmtree(self.dir)
|
rmtree(self.dir)
|
||||||
|
@ -38,3 +54,37 @@ class TestEmailStageTemplates(TestCase):
|
||||||
self.assertEqual(len(choices), 3)
|
self.assertEqual(len(choices), 3)
|
||||||
unlink(file)
|
unlink(file)
|
||||||
unlink(file2)
|
unlink(file2)
|
||||||
|
|
||||||
|
def test_custom_template_invalid_syntax(self):
|
||||||
|
"""Test with custom template"""
|
||||||
|
with open(self.dir / Path("invalid.html"), "w+", encoding="utf-8") as _invalid:
|
||||||
|
_invalid.write("{% blocktranslate %}")
|
||||||
|
with self.settings(TEMPLATES=get_templates_setting(self.dir)):
|
||||||
|
self.stage.template = "invalid.html"
|
||||||
|
plan = FlowPlan(
|
||||||
|
flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()]
|
||||||
|
)
|
||||||
|
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
|
||||||
|
session = self.client.session
|
||||||
|
session[SESSION_KEY_PLAN] = plan
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
|
||||||
|
with patch(
|
||||||
|
"authentik.stages.email.models.EmailStage.backend_class",
|
||||||
|
PropertyMock(return_value=EmailBackend),
|
||||||
|
):
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertStageResponse(
|
||||||
|
response,
|
||||||
|
self.flow,
|
||||||
|
error_message="Unknown error",
|
||||||
|
)
|
||||||
|
events = Event.objects.filter(action=EventAction.CONFIGURATION_ERROR)
|
||||||
|
self.assertEqual(len(events), 1)
|
||||||
|
event = events.first()
|
||||||
|
self.assertEqual(
|
||||||
|
event.context["message"], "Exception occurred while rendering E-mail template"
|
||||||
|
)
|
||||||
|
self.assertEqual(event.context["template"], "invalid.html")
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"""email utils"""
|
"""email utils"""
|
||||||
from email.mime.image import MIMEImage
|
from email.mime.image import MIMEImage
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
|
@ -8,9 +9,12 @@ from django.utils import translation
|
||||||
|
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def logo_data():
|
def logo_data() -> MIMEImage:
|
||||||
"""Get logo as MIME Image for emails"""
|
"""Get logo as MIME Image for emails"""
|
||||||
with open("web/icons/icon_left_brand.png", "rb") as _logo_file:
|
path = Path("web/icons/icon_left_brand.png")
|
||||||
|
if not path.exists():
|
||||||
|
path = Path("web/dist/assets/icons/icon_left_brand.png")
|
||||||
|
with open(path, "rb") as _logo_file:
|
||||||
logo = MIMEImage(_logo_file.read())
|
logo = MIMEImage(_logo_file.read())
|
||||||
logo.add_header("Content-ID", "logo.png")
|
logo.add_header("Content-ID", "logo.png")
|
||||||
return logo
|
return logo
|
||||||
|
@ -25,5 +29,4 @@ class TemplateEmailMessage(EmailMultiAlternatives):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.content_subtype = "html"
|
self.content_subtype = "html"
|
||||||
self.mixed_subtype = "related"
|
self.mixed_subtype = "related"
|
||||||
self.attach(logo_data())
|
|
||||||
self.attach_alternative(html_content, "text/html")
|
self.attach_alternative(html_content, "text/html")
|
||||||
|
|
|
@ -15,6 +15,7 @@ class UserWriteStageSerializer(StageSerializer):
|
||||||
"user_creation_mode",
|
"user_creation_mode",
|
||||||
"create_users_as_inactive",
|
"create_users_as_inactive",
|
||||||
"create_users_group",
|
"create_users_group",
|
||||||
|
"user_type",
|
||||||
"user_path_template",
|
"user_path_template",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue