Compare commits
275 Commits
version/20
...
version-20
Author | SHA1 | Date | |
---|---|---|---|
8543e140ef | |||
e2cf578afd | |||
0ce9fd9b2e | |||
d17ad65435 | |||
01529d3894 | |||
dae6493a3e | |||
ad07984158 | |||
f909b86338 | |||
327df6529b | |||
658dc63c4c | |||
4edec5f666 | |||
d150a0c135 | |||
d4242781a0 | |||
7369ca0b25 | |||
561f427cc5 | |||
8049ab703a | |||
9c2a97263a | |||
345504c1a4 | |||
549f6f2077 | |||
35c6decc75 | |||
b3abeb78ff | |||
0562a1ad42 | |||
febb0920fd | |||
549662beb0 | |||
1ea4440c5d | |||
787abdff5b | |||
2237807241 | |||
e9d9d658c4 | |||
e704092d19 | |||
305f72c197 | |||
fb6b6b4476 | |||
791cc74dbb | |||
41f139589c | |||
df24e3020b | |||
e44c716cbe | |||
d35302923d | |||
4d928368bc | |||
c055d7a470 | |||
9e1b49e181 | |||
db6a9ede1b | |||
86df0a448e | |||
5ec052bd92 | |||
6f7984d05a | |||
f6d64d1d4b | |||
ef0c7a5a57 | |||
34dfbf8e9e | |||
71d38e6fd0 | |||
9a9ba2560b | |||
2432e51970 | |||
47434cd62d | |||
ff500b44a6 | |||
4c14c7f3a4 | |||
019c4bf182 | |||
2cbc291f04 | |||
5197a3a461 | |||
52be87785f | |||
8e19fb3a8c | |||
0448dcf655 | |||
b8f74ab9e7 | |||
501ce5cebb | |||
b896ca7ef6 | |||
d497db3010 | |||
24f95fdeaa | |||
d1c4818724 | |||
9f736a9d99 | |||
49cce6a968 | |||
713337130b | |||
0a73e7ac9f | |||
3344af72c2 | |||
41eb44137e | |||
94a9667d86 | |||
8b56a7defb | |||
5a4b9b4239 | |||
f37308461c | |||
9721098178 | |||
0ca5e67dad | |||
da94564d5e | |||
1f33237659 | |||
62e5979c13 | |||
8a1e18e087 | |||
a951daddce | |||
690f6d444a | |||
b733930745 | |||
f316a3000b | |||
ddae9dc6e1 | |||
0348d6558a | |||
6a497b32f6 | |||
47acc0ea90 | |||
967c952a4a | |||
b648d159dd | |||
aecd9387d9 | |||
6e8a5e1426 | |||
607899be56 | |||
5a92a8639a | |||
4cd629b5fc | |||
6020736430 | |||
14a4047bdd | |||
23c1e22a04 | |||
2a2ae4bc4f | |||
5f4812e1d0 | |||
3ab475d916 | |||
453d64eea5 | |||
17d33f4b19 | |||
c39a5933e1 | |||
a9636b5727 | |||
5e3f44dd87 | |||
1c64616ebd | |||
23273f53cc | |||
d11ce0a86e | |||
766ceda57a | |||
eb633c607e | |||
c72d56d02d | |||
e758c434ea | |||
90e3ae9457 | |||
0e825ffcfd | |||
8a19c71f62 | |||
5a7eff041a | |||
552459834a | |||
cc6325bf6a | |||
9597ea9e1f | |||
69b5670659 | |||
56fd436e5d | |||
b7558ae28c | |||
ea60c389be | |||
f6042f29f6 | |||
983882f5a0 | |||
a6d3fd92df | |||
96f39904b8 | |||
ee347aa7ef | |||
6437334e67 | |||
2f57d7f427 | |||
db07f564aa | |||
d1479a1b16 | |||
4d80e207da | |||
e7be7ac9b4 | |||
e0954c0f89 | |||
7ae061909c | |||
45a806f46b | |||
feb6b07657 | |||
1d98582d29 | |||
06663edba2 | |||
de0d1dc94d | |||
1652ea25e4 | |||
d794e3055c | |||
a92c68ac85 | |||
dd41789230 | |||
022401b60e | |||
ef218ff1ff | |||
f933bf2f40 | |||
4fc761adea | |||
d11c214d32 | |||
ffbbe5ca5f | |||
8582091219 | |||
28c8eb3ee6 | |||
3a00a5ac3d | |||
20035e0f1b | |||
67021b0e7c | |||
c5a2831665 | |||
768f073e49 | |||
504338ea66 | |||
a8c04f96d2 | |||
340faf5341 | |||
a76c39aff9 | |||
bb728a53cc | |||
5c28a7dd44 | |||
e1efb47543 | |||
e50a296a18 | |||
e211265c30 | |||
1f143a24db | |||
48f490b810 | |||
aed382de0c | |||
8ecf40a58b | |||
aca3c75e17 | |||
f28509608b | |||
ff6c508de7 | |||
7319ea2dcf | |||
6a4efaecb0 | |||
29b0eae43f | |||
9f3e742fb1 | |||
c8e09fea33 | |||
437e932471 | |||
ce07d71d23 | |||
9815c591e0 | |||
db7a3ab630 | |||
3fa772c32e | |||
6c9dc7a15b | |||
ece0429ea8 | |||
d56ddb16b1 | |||
b6267fdf28 | |||
1f190a9255 | |||
1f0fc0a6a2 | |||
3ba678851e | |||
0869ef3d0d | |||
91100ce1e2 | |||
a65ce47736 | |||
def17bbc1e | |||
eb7da8f414 | |||
9201fc1834 | |||
5385feb428 | |||
c6f29d9eb4 | |||
db557401aa | |||
c824af5bc3 | |||
1faba11a57 | |||
f0c72e8536 | |||
91f91b08e5 | |||
8faa909c32 | |||
49142fa80b | |||
2a6fccd22a | |||
1d10afa209 | |||
4b7c3c38cd | |||
440cacbafe | |||
b33bff92ee | |||
caed306346 | |||
d0eb6af7e9 | |||
ec5ed67f6c | |||
59b899ddff | |||
85784f796c | |||
4c0e19cbea | |||
b42eb9464f | |||
6559fdee15 | |||
3455bf3d27 | |||
0d96e68c1e | |||
29d3db5112 | |||
cdf88e4477 | |||
7caac1d0c7 | |||
45364d6553 | |||
2298eb124f | |||
6dff1f8e5e | |||
a944701f3a | |||
23866fe459 | |||
0a83b04419 | |||
e6ecdf8b1d | |||
2d48fe42f4 | |||
5894ccdaf2 | |||
79bec6f6b2 | |||
9610f96c11 | |||
36a326cd81 | |||
c0c222a0b8 | |||
e17f7020e6 | |||
6d9579d3e6 | |||
9f15ee8cb8 | |||
e892ed14da | |||
093a67525a | |||
1c62a3db6e | |||
c4b4c7134d | |||
82cb6d41b8 | |||
423380d987 | |||
175d97fdcf | |||
5dbbf970b0 | |||
1541d477af | |||
d745331654 | |||
defbdc5068 | |||
350f0d8365 | |||
b5c93fb3e3 | |||
5be45ebf8e | |||
ad8fe9fe81 | |||
c2f7edaa42 | |||
6821402fef | |||
8dbb0bd2c6 | |||
24a21c1761 | |||
0cad56ec73 | |||
4d8021c403 | |||
6573cbb16c | |||
bdf76bb4b7 | |||
74ce9cc6fd | |||
070a6d866e | |||
5e2d647a6c | |||
7beebe030d | |||
66f4a31b4c | |||
beddd6a460 | |||
faec866581 | |||
effed50cc1 | |||
38ad6096ad | |||
bd53042553 | |||
039d896dee |
@ -1,30 +1,18 @@
|
||||
[bumpversion]
|
||||
current_version = 2022.6.2
|
||||
current_version = 2022.7.3
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
||||
serialize =
|
||||
{major}.{minor}.{patch}-{release}
|
||||
{major}.{minor}.{patch}
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||
serialize = {major}.{minor}.{patch}
|
||||
message = release: {new_version}
|
||||
tag_name = version/{new_version}
|
||||
|
||||
[bumpversion:part:release]
|
||||
optional_value = stable
|
||||
first_value = beta
|
||||
values =
|
||||
alpha
|
||||
beta
|
||||
stable
|
||||
|
||||
[bumpversion:file:pyproject.toml]
|
||||
|
||||
[bumpversion:file:docker-compose.yml]
|
||||
|
||||
[bumpversion:file:schema.yml]
|
||||
|
||||
[bumpversion:file:.github/workflows/release-publish.yml]
|
||||
|
||||
[bumpversion:file:authentik/__init__.py]
|
||||
|
||||
[bumpversion:file:internal/constants/constants.go]
|
||||
|
@ -17,6 +17,12 @@ outputs:
|
||||
sha:
|
||||
description: "sha"
|
||||
value: ${{ steps.ev.outputs.sha }}
|
||||
version:
|
||||
description: "version"
|
||||
value: ${{ steps.ev.outputs.version }}
|
||||
versionFamily:
|
||||
description: "versionFamily"
|
||||
value: ${{ steps.ev.outputs.versionFamily }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
@ -47,3 +53,11 @@ runs:
|
||||
print("##[set-output name=timestamp]%s" % int(time()))
|
||||
print("##[set-output name=sha]%s" % os.environ[sha])
|
||||
print("##[set-output name=shouldBuild]%s" % should_build)
|
||||
|
||||
import configparser
|
||||
parser = configparser.ConfigParser()
|
||||
parser.read(".bumpversion.cfg")
|
||||
version = parser.get("bumpversion", "current_version")
|
||||
version_family = ".".join(version.split(".")[:-1])
|
||||
print("##[set-output name=version]%s" % version)
|
||||
print("##[set-output name=versionFamily]%s" % version_family)
|
108
.github/dependabot.yml
vendored
108
.github/dependabot.yml
vendored
@ -1,50 +1,62 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: npm
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: npm
|
||||
directory: "/website"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
assignees:
|
||||
- BeryJu
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "ci:"
|
||||
- package-ecosystem: gomod
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
- package-ecosystem: npm
|
||||
directory: "/web"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "web:"
|
||||
- package-ecosystem: npm
|
||||
directory: "/website"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "website:"
|
||||
- package-ecosystem: pip
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
- package-ecosystem: docker
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
time: "04:00"
|
||||
open-pull-requests-limit: 10
|
||||
reviewers:
|
||||
- "@goauthentik/core"
|
||||
commit-message:
|
||||
prefix: "core:"
|
||||
|
11
.github/workflows/ci-main.yml
vendored
11
.github/workflows/ci-main.yml
vendored
@ -106,7 +106,7 @@ jobs:
|
||||
with:
|
||||
domain: ${{github.repository_owner}}
|
||||
- name: Create k8s Kind Cluster
|
||||
uses: helm/kind-action@v1.2.0
|
||||
uses: helm/kind-action@v1.3.0
|
||||
- name: run integration
|
||||
run: |
|
||||
poetry run make test-integration
|
||||
@ -133,12 +133,13 @@ jobs:
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
npm ci
|
||||
make -C .. gen-client-web
|
||||
npm run build
|
||||
- name: run e2e
|
||||
run: |
|
||||
@ -166,12 +167,13 @@ jobs:
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: web/dist
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
working-directory: web/
|
||||
run: |
|
||||
npm ci
|
||||
make -C .. gen-client-web
|
||||
npm run build
|
||||
- name: run e2e
|
||||
run: |
|
||||
@ -211,10 +213,10 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
uses: ./.github/actions/docker-setup
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v2
|
||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
@ -231,4 +233,5 @@ jobs:
|
||||
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||
platforms: ${{ matrix.arch }}
|
||||
|
5
.github/workflows/ci-outpost.yml
vendored
5
.github/workflows/ci-outpost.yml
vendored
@ -67,8 +67,8 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
uses: ./.github/actions/docker-setup
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
- name: Login to Container Registry
|
||||
@ -91,6 +91,7 @@ jobs:
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
build-args: |
|
||||
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
|
||||
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
|
||||
platforms: ${{ matrix.arch }}
|
||||
build-outpost-binary:
|
||||
timeout-minutes: 120
|
||||
@ -110,7 +111,7 @@ jobs:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
- uses: actions/setup-node@v3.4.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
|
15
.github/workflows/ci-web.yml
vendored
15
.github/workflows/ci-web.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
- uses: actions/setup-node@v3.4.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
- uses: actions/setup-node@v3.4.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -47,13 +47,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
- uses: actions/setup-node@v3.4.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
run: |
|
||||
npm ci
|
||||
# lit-analyse doesn't understand path rewrites, so make it
|
||||
# belive it's an actual module
|
||||
cd node_modules/@goauthentik
|
||||
ln -s ../../src/ web
|
||||
- name: Generate API
|
||||
run: make gen-client-web
|
||||
- name: lit-analyse
|
||||
@ -73,7 +78,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
- uses: actions/setup-node@v3.4.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
|
2
.github/workflows/ci-website.yml
vendored
2
.github/workflows/ci-website.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
- uses: actions/setup-node@v3.4.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
|
26
.github/workflows/release-publish.yml
vendored
26
.github/workflows/release-publish.yml
vendored
@ -5,7 +5,6 @@ on:
|
||||
types: [published, created]
|
||||
|
||||
jobs:
|
||||
# Build
|
||||
build-server:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@ -14,6 +13,9 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
- name: Docker Login Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@ -30,9 +32,11 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik:2022.6.2,
|
||||
beryju/authentik:${{ steps.ev.outputs.version }},
|
||||
beryju/authentik:${{ steps.ev.outputs.versionFamily }},
|
||||
beryju/authentik:latest,
|
||||
ghcr.io/goauthentik/server:2022.6.2,
|
||||
ghcr.io/goauthentik/server:${{ steps.ev.outputs.version }},
|
||||
ghcr.io/goauthentik/server:${{ steps.ev.outputs.versionFamily }},
|
||||
ghcr.io/goauthentik/server:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@ -53,6 +57,9 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
- name: Docker Login Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@ -69,9 +76,11 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik-${{ matrix.type }}:2022.6.2,
|
||||
beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.version }},
|
||||
beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }},
|
||||
beryju/authentik-${{ matrix.type }}:latest,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.6.2,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.version }},
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }},
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@ -91,7 +100,7 @@ jobs:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
- uses: actions/setup-node@v3.4.1
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -138,6 +147,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: prepare variables
|
||||
uses: ./.github/actions/docker-push-variables
|
||||
id: ev
|
||||
- name: Get static files from docker image
|
||||
run: |
|
||||
docker pull ghcr.io/goauthentik/server:latest
|
||||
@ -152,7 +164,7 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
version: authentik@2022.6.2
|
||||
version: authentik@${{ steps.ev.outputs.version }}
|
||||
environment: beryjuorg-prod
|
||||
sourcemaps: './web/dist'
|
||||
url_prefix: '~/static/dist'
|
||||
|
2
.github/workflows/web-api-publish.yml
vendored
2
.github/workflows/web-api-publish.yml
vendored
@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
- uses: actions/setup-node@v3.4.1
|
||||
with:
|
||||
node-version: '16'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -193,7 +193,6 @@ pip-selfcheck.json
|
||||
# End of https://www.gitignore.io/api/python,django
|
||||
/static/
|
||||
local.env.yml
|
||||
.vscode/
|
||||
|
||||
# Selenium Screenshots
|
||||
selenium_screenshots/
|
||||
|
6
.vscode/settings.json
vendored
6
.vscode/settings.json
vendored
@ -22,5 +22,9 @@
|
||||
"python.formatting.provider": "black",
|
||||
"files.associations": {
|
||||
"*.akflow": "json"
|
||||
}
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"typescript.preferences.importModuleSpecifierEnding": "index",
|
||||
"typescript.tsdk": "./web/node_modules/typescript/lib",
|
||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||
}
|
||||
|
86
.vscode/tasks.json
vendored
Normal file
86
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,86 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "authentik[core]: format & test",
|
||||
"command": "poetry",
|
||||
"args": [
|
||||
"run",
|
||||
"make"
|
||||
],
|
||||
"group": "build",
|
||||
},
|
||||
{
|
||||
"label": "authentik[core]: run",
|
||||
"command": "poetry",
|
||||
"args": [
|
||||
"run",
|
||||
"make",
|
||||
"run",
|
||||
],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"panel": "dedicated",
|
||||
"group": "running"
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "authentik[web]: format",
|
||||
"command": "make",
|
||||
"args": ["web"],
|
||||
"group": "build",
|
||||
},
|
||||
{
|
||||
"label": "authentik[web]: watch",
|
||||
"command": "make",
|
||||
"args": ["web-watch"],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"panel": "dedicated",
|
||||
"group": "running"
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "authentik: install",
|
||||
"command": "make",
|
||||
"args": ["install"],
|
||||
"group": "build",
|
||||
},
|
||||
{
|
||||
"label": "authentik: i18n-extract",
|
||||
"command": "poetry",
|
||||
"args": [
|
||||
"run",
|
||||
"make",
|
||||
"i18n-extract"
|
||||
],
|
||||
"group": "build",
|
||||
},
|
||||
{
|
||||
"label": "authentik[website]: format",
|
||||
"command": "make",
|
||||
"args": ["website"],
|
||||
"group": "build",
|
||||
},
|
||||
{
|
||||
"label": "authentik[website]: watch",
|
||||
"command": "make",
|
||||
"args": ["website-watch"],
|
||||
"group": "build",
|
||||
"presentation": {
|
||||
"panel": "dedicated",
|
||||
"group": "running"
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "authentik[api]: generate",
|
||||
"command": "poetry",
|
||||
"args": [
|
||||
"run",
|
||||
"make",
|
||||
"gen"
|
||||
],
|
||||
"group": "build"
|
||||
},
|
||||
]
|
||||
}
|
@ -18,7 +18,7 @@ WORKDIR /work/web
|
||||
RUN npm ci && npm run build
|
||||
|
||||
# Stage 3: Poetry to requirements.txt export
|
||||
FROM docker.io/python:3.10.4-slim-bullseye AS poetry-locker
|
||||
FROM docker.io/python:3.10.5-slim-bullseye AS poetry-locker
|
||||
|
||||
WORKDIR /work
|
||||
COPY ./pyproject.toml /work
|
||||
@ -29,7 +29,7 @@ RUN pip install --no-cache-dir poetry && \
|
||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||
|
||||
# Stage 4: Build go proxy
|
||||
FROM docker.io/golang:1.18.3-bullseye AS builder
|
||||
FROM docker.io/golang:1.18.4-bullseye AS builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
@ -45,7 +45,7 @@ COPY ./go.sum /work/go.sum
|
||||
RUN go build -o /work/authentik ./cmd/server/main.go
|
||||
|
||||
# Stage 5: Run
|
||||
FROM docker.io/python:3.10.4-slim-bullseye
|
||||
FROM docker.io/python:3.10.5-slim-bullseye
|
||||
|
||||
LABEL org.opencontainers.image.url https://goauthentik.io
|
||||
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
|
||||
|
12
Makefile
12
Makefile
@ -45,8 +45,8 @@ lint-fix:
|
||||
website/developer-docs
|
||||
|
||||
lint:
|
||||
bandit -r authentik tests lifecycle -x node_modules
|
||||
pylint authentik tests lifecycle
|
||||
bandit -r authentik tests lifecycle -x node_modules
|
||||
golangci-lint run -v
|
||||
|
||||
i18n-extract: i18n-extract-core web-extract
|
||||
@ -106,12 +106,15 @@ run:
|
||||
web-build: web-install
|
||||
cd web && npm run build
|
||||
|
||||
web: web-lint-fix web-lint web-extract
|
||||
web: web-lint-fix web-lint
|
||||
|
||||
web-install:
|
||||
cd web && npm ci
|
||||
|
||||
web-watch:
|
||||
rm -rf web/dist/
|
||||
mkdir web/dist/
|
||||
touch web/dist/.gitkeep
|
||||
cd web && npm run watch
|
||||
|
||||
web-lint-fix:
|
||||
@ -166,8 +169,3 @@ ci-pending-migrations: ci--meta-debug
|
||||
|
||||
install: web-install website-install
|
||||
poetry install
|
||||
|
||||
a: install
|
||||
tmux \
|
||||
new-session 'make run' \; \
|
||||
split-window 'make web-watch'
|
||||
|
@ -6,8 +6,9 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ---------- | ------------------ |
|
||||
| 2022.4.x | :white_check_mark: |
|
||||
| 2022.5.x | :white_check_mark: |
|
||||
| 2022.6.x | :white_check_mark: |
|
||||
| 2022.7.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2022.6.2"
|
||||
__version__ = "2022.7.3"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
"""authentik administration overview"""
|
||||
from django.conf import settings
|
||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||
from prometheus_client import Gauge
|
||||
from rest_framework.fields import IntegerField
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.request import Request
|
||||
@ -10,8 +9,6 @@ from rest_framework.views import APIView
|
||||
|
||||
from authentik.root.celery import CELERY_APP
|
||||
|
||||
GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
|
||||
|
||||
|
||||
class WorkerView(APIView):
|
||||
"""Get currently connected worker count."""
|
||||
|
@ -2,6 +2,10 @@
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from prometheus_client import Gauge, Info
|
||||
|
||||
PROM_INFO = Info("authentik_version", "Currently running authentik version")
|
||||
GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
|
||||
|
||||
|
||||
class AuthentikAdminConfig(AppConfig):
|
||||
|
@ -2,7 +2,7 @@
|
||||
from django.dispatch import receiver
|
||||
|
||||
from authentik.admin.api.tasks import TaskInfo
|
||||
from authentik.admin.api.workers import GAUGE_WORKERS
|
||||
from authentik.admin.apps import GAUGE_WORKERS
|
||||
from authentik.root.celery import CELERY_APP
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
|
||||
|
@ -4,11 +4,11 @@ import re
|
||||
from django.core.cache import cache
|
||||
from django.core.validators import URLValidator
|
||||
from packaging.version import parse
|
||||
from prometheus_client import Info
|
||||
from requests import RequestException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import __version__, get_build_hash
|
||||
from authentik.admin.apps import PROM_INFO
|
||||
from authentik.events.models import Event, EventAction, Notification
|
||||
from authentik.events.monitored_tasks import (
|
||||
MonitoredTask,
|
||||
@ -25,7 +25,6 @@ VERSION_CACHE_KEY = "authentik_latest_version"
|
||||
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
|
||||
# Chop of the first ^ because we want to search the entire string
|
||||
URL_FINDER = URLValidator.regex.pattern[1:]
|
||||
PROM_INFO = Info("authentik_version", "Currently running authentik version")
|
||||
LOCAL_VERSION = parse(__version__)
|
||||
|
||||
|
||||
|
@ -10,6 +10,8 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.middleware import KEY_AUTH_VIA, LOCAL
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
from authentik.outposts.models import Outpost
|
||||
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||
from authentik.providers.oauth2.models import RefreshToken
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -24,7 +26,7 @@ def validate_auth(header: bytes) -> str:
|
||||
if auth_type.lower() != "bearer":
|
||||
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
|
||||
raise AuthenticationFailed("Unsupported authentication type")
|
||||
if auth_credentials == "": # nosec
|
||||
if auth_credentials == "": # nosec # noqa
|
||||
raise AuthenticationFailed("Malformed header")
|
||||
return auth_credentials
|
||||
|
||||
@ -34,14 +36,30 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
|
||||
auth_credentials = validate_auth(raw_header)
|
||||
if not auth_credentials:
|
||||
return None
|
||||
if not hasattr(LOCAL, "authentik"):
|
||||
LOCAL.authentik = {}
|
||||
# first, check traditional tokens
|
||||
token = Token.filter_not_expired(key=auth_credentials, intent=TokenIntents.INTENT_API).first()
|
||||
if hasattr(LOCAL, "authentik"):
|
||||
key_token = Token.filter_not_expired(
|
||||
key=auth_credentials, intent=TokenIntents.INTENT_API
|
||||
).first()
|
||||
if key_token:
|
||||
LOCAL.authentik[KEY_AUTH_VIA] = "api_token"
|
||||
if token:
|
||||
return token.user
|
||||
return key_token.user
|
||||
# then try to auth via JWT
|
||||
jwt_token = RefreshToken.filter_not_expired(
|
||||
refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
|
||||
).first()
|
||||
if jwt_token:
|
||||
# Double-check scopes, since they are saved in a single string
|
||||
# we want to check the parsed version too
|
||||
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
LOCAL.authentik[KEY_AUTH_VIA] = "jwt"
|
||||
return jwt_token.user
|
||||
# then try to auth via secret key (for embedded outpost/etc)
|
||||
user = token_secret_key(auth_credentials)
|
||||
if user:
|
||||
LOCAL.authentik[KEY_AUTH_VIA] = "secret_key"
|
||||
return user
|
||||
raise AuthenticationFailed("Token invalid/expired")
|
||||
|
||||
@ -56,8 +74,6 @@ def token_secret_key(value: str) -> Optional[User]:
|
||||
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
|
||||
if not outposts:
|
||||
return None
|
||||
if hasattr(LOCAL, "authentik"):
|
||||
LOCAL.authentik[KEY_AUTH_VIA] = "secret_key"
|
||||
outpost = outposts.first()
|
||||
return outpost.user
|
||||
|
||||
|
@ -8,28 +8,37 @@ from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from authentik.api.authentication import bearer_auth
|
||||
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.outposts.managed import OutpostManager
|
||||
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||
|
||||
|
||||
class TestAPIAuth(TestCase):
|
||||
"""Test API Authentication"""
|
||||
|
||||
def test_valid_bearer(self):
|
||||
"""Test valid token"""
|
||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user())
|
||||
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
||||
|
||||
def test_invalid_type(self):
|
||||
"""Test invalid type"""
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
bearer_auth("foo bar".encode())
|
||||
|
||||
def test_invalid_empty(self):
|
||||
"""Test invalid type"""
|
||||
self.assertIsNone(bearer_auth("Bearer ".encode()))
|
||||
self.assertIsNone(bearer_auth("".encode()))
|
||||
|
||||
def test_invalid_no_token(self):
|
||||
"""Test invalid with no token"""
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
auth = b64encode(":abc".encode()).decode()
|
||||
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
|
||||
|
||||
def test_bearer_valid(self):
|
||||
"""Test valid token"""
|
||||
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user())
|
||||
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
|
||||
|
||||
def test_managed_outpost(self):
|
||||
"""Test managed outpost"""
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
@ -38,3 +47,30 @@ class TestAPIAuth(TestCase):
|
||||
OutpostManager().run()
|
||||
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
|
||||
self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True)
|
||||
|
||||
def test_jwt_valid(self):
|
||||
"""Test valid JWT"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
refresh = RefreshToken.objects.create(
|
||||
user=get_anonymous_user(),
|
||||
provider=provider,
|
||||
refresh_token=generate_id(),
|
||||
_scope=SCOPE_AUTHENTIK_API,
|
||||
)
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||
|
||||
def test_jwt_missing_scope(self):
|
||||
"""Test valid JWT"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
|
||||
)
|
||||
refresh = RefreshToken.objects.create(
|
||||
user=get_anonymous_user(),
|
||||
provider=provider,
|
||||
refresh_token=generate_id(),
|
||||
_scope="",
|
||||
)
|
||||
with self.assertRaises(AuthenticationFailed):
|
||||
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
|
||||
|
@ -74,7 +74,7 @@ class ConfigView(APIView):
|
||||
config = ConfigSerializer(
|
||||
{
|
||||
"error_reporting": {
|
||||
"enabled": CONFIG.y("error_reporting.enabled") and not settings.DEBUG,
|
||||
"enabled": CONFIG.y("error_reporting.enabled"),
|
||||
"environment": CONFIG.y("error_reporting.environment"),
|
||||
"send_pii": CONFIG.y("error_reporting.send_pii"),
|
||||
"traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)),
|
||||
|
@ -89,6 +89,14 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
"meta_publisher",
|
||||
"group",
|
||||
]
|
||||
filterset_fields = [
|
||||
"name",
|
||||
"slug",
|
||||
"meta_launch_url",
|
||||
"meta_description",
|
||||
"meta_publisher",
|
||||
"group",
|
||||
]
|
||||
lookup_field = "slug"
|
||||
filterset_fields = ["name", "slug"]
|
||||
ordering = ["name"]
|
||||
|
@ -53,6 +53,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"policy_engine_mode",
|
||||
"user_matching_mode",
|
||||
"managed",
|
||||
"user_path_template",
|
||||
]
|
||||
|
||||
|
||||
|
@ -24,7 +24,13 @@ from drf_spectacular.utils import (
|
||||
)
|
||||
from guardian.shortcuts import get_anonymous_user, get_objects_for_user
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.fields import CharField, JSONField, SerializerMethodField
|
||||
from rest_framework.fields import (
|
||||
CharField,
|
||||
IntegerField,
|
||||
JSONField,
|
||||
ListField,
|
||||
SerializerMethodField,
|
||||
)
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import (
|
||||
@ -50,12 +56,16 @@ from authentik.core.middleware import (
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_SA,
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||
USER_PATH_SERVICE_ACCOUNT,
|
||||
Group,
|
||||
Token,
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.events.models import EventAction
|
||||
from authentik.flows.models import FlowToken
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
||||
from authentik.flows.views.executor import QS_KEY_TOKEN
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.stages.email.tasks import send_mails
|
||||
from authentik.stages.email.utils import TemplateEmailMessage
|
||||
@ -77,6 +87,15 @@ class UserSerializer(ModelSerializer):
|
||||
uid = CharField(read_only=True)
|
||||
username = CharField(max_length=150)
|
||||
|
||||
def validate_path(self, path: str) -> str:
|
||||
"""Validate path"""
|
||||
if path[:1] == "/" or path[-1] == "/":
|
||||
raise ValidationError(_("No leading or trailing slashes allowed."))
|
||||
for segment in path.split("/"):
|
||||
if segment == "":
|
||||
raise ValidationError(_("No empty segments in user path allowed."))
|
||||
return path
|
||||
|
||||
class Meta:
|
||||
|
||||
model = User
|
||||
@ -93,6 +112,7 @@ class UserSerializer(ModelSerializer):
|
||||
"avatar",
|
||||
"attributes",
|
||||
"uid",
|
||||
"path",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"name": {"allow_blank": True},
|
||||
@ -208,6 +228,11 @@ class UsersFilter(FilterSet):
|
||||
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
|
||||
uuid = CharFilter(field_name="uuid")
|
||||
|
||||
path = CharFilter(
|
||||
field_name="path",
|
||||
)
|
||||
path_startswith = CharFilter(field_name="path", lookup_expr="startswith")
|
||||
|
||||
groups_by_name = ModelMultipleChoiceFilter(
|
||||
field_name="ak_groups__name",
|
||||
to_field_name="name",
|
||||
@ -272,12 +297,23 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
LOGGER.debug("No recovery flow set")
|
||||
return None, None
|
||||
user: User = self.get_object()
|
||||
token, __ = Token.objects.get_or_create(
|
||||
identifier=f"{user.uid}-password-reset",
|
||||
user=user,
|
||||
intent=TokenIntents.INTENT_RECOVERY,
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
plan = planner.plan(
|
||||
self.request._request,
|
||||
{
|
||||
PLAN_CONTEXT_PENDING_USER: user,
|
||||
},
|
||||
)
|
||||
querystring = urlencode({"token": token.key})
|
||||
token, __ = FlowToken.objects.update_or_create(
|
||||
identifier=f"{user.uid}-password-reset",
|
||||
defaults={
|
||||
"user": user,
|
||||
"flow": flow,
|
||||
"_plan": FlowToken.pickle(plan),
|
||||
},
|
||||
)
|
||||
querystring = urlencode({QS_KEY_TOKEN: token.key})
|
||||
link = self.request.build_absolute_uri(
|
||||
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
|
||||
+ f"?{querystring}"
|
||||
@ -299,6 +335,9 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
{
|
||||
"username": CharField(required=True),
|
||||
"token": CharField(required=True),
|
||||
"user_uid": CharField(required=True),
|
||||
"user_pk": IntegerField(required=True),
|
||||
"group_pk": CharField(required=False),
|
||||
},
|
||||
)
|
||||
},
|
||||
@ -314,19 +353,27 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
username=username,
|
||||
name=username,
|
||||
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
|
||||
path=USER_PATH_SERVICE_ACCOUNT,
|
||||
)
|
||||
response = {
|
||||
"username": user.username,
|
||||
"user_uid": user.uid,
|
||||
"user_pk": user.pk,
|
||||
}
|
||||
if create_group and self.request.user.has_perm("authentik_core.add_group"):
|
||||
group = Group.objects.create(
|
||||
name=username,
|
||||
)
|
||||
group.users.add(user)
|
||||
response["group_pk"] = str(group.pk)
|
||||
token = Token.objects.create(
|
||||
identifier=slugify(f"service-account-{username}-password"),
|
||||
intent=TokenIntents.INTENT_APP_PASSWORD,
|
||||
user=user,
|
||||
expires=now() + timedelta(days=360),
|
||||
)
|
||||
return Response({"username": user.username, "token": token.key})
|
||||
response["token"] = token.key
|
||||
return Response(response)
|
||||
except (IntegrityError) as exc:
|
||||
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
||||
|
||||
@ -344,7 +391,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
instance=request._request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER],
|
||||
context=context,
|
||||
).data
|
||||
self.request.session.save()
|
||||
self.request.session.modified = True
|
||||
return Response(serializer.initial_data)
|
||||
|
||||
@permission_required("authentik_core.reset_user_password")
|
||||
@ -464,3 +511,32 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
if self.request.user.has_perm("authentik_core.view_user"):
|
||||
return self._filter_queryset_for_list(queryset)
|
||||
return super().filter_queryset(queryset)
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
"UserPathSerializer", {"paths": ListField(child=CharField(), read_only=True)}
|
||||
)
|
||||
},
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="search",
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
)
|
||||
],
|
||||
)
|
||||
@action(detail=False, pagination_class=None)
|
||||
def paths(self, request: Request) -> Response:
|
||||
"""Get all user paths"""
|
||||
return Response(
|
||||
data={
|
||||
"paths": list(
|
||||
self.filter_queryset(self.get_queryset())
|
||||
.values("path")
|
||||
.distinct()
|
||||
.order_by("path")
|
||||
.values_list("path", flat=True)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
@ -2,6 +2,7 @@
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class AuthentikCoreConfig(AppConfig):
|
||||
@ -15,3 +16,7 @@ class AuthentikCoreConfig(AppConfig):
|
||||
def ready(self):
|
||||
import_module("authentik.core.signals")
|
||||
import_module("authentik.core.managed")
|
||||
if settings.DEBUG:
|
||||
from authentik.root.celery import worker_ready_hook
|
||||
|
||||
worker_ready_hook()
|
||||
|
13
authentik/core/management/commands/bootstrap_tasks.py
Normal file
13
authentik/core/management/commands/bootstrap_tasks.py
Normal file
@ -0,0 +1,13 @@
|
||||
"""Run bootstrap tasks"""
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from authentik.root.celery import _get_startup_tasks
|
||||
|
||||
|
||||
class Command(BaseCommand): # pragma: no cover
|
||||
"""Run bootstrap tasks to ensure certain objects are created"""
|
||||
|
||||
def handle(self, **options):
|
||||
tasks = _get_startup_tasks()
|
||||
for task in tasks:
|
||||
task()
|
@ -2,6 +2,7 @@
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.management import create_permissions
|
||||
from django.core.management.base import BaseCommand, no_translations
|
||||
from guardian.management import create_anonymous_user
|
||||
|
||||
|
||||
class Command(BaseCommand): # pragma: no cover
|
||||
@ -13,3 +14,4 @@ class Command(BaseCommand): # pragma: no cover
|
||||
for app in apps.get_app_configs():
|
||||
self.stdout.write(f"Checking app {app.name} ({app.label})\n")
|
||||
create_permissions(app, verbosity=0)
|
||||
create_anonymous_user(None, using="default")
|
@ -56,7 +56,7 @@ class RequestIDMiddleware:
|
||||
response[RESPONSE_HEADER_ID] = request.request_id
|
||||
setattr(response, "ak_context", {})
|
||||
response.ak_context.update(LOCAL.authentik)
|
||||
response.ak_context[KEY_USER] = request.user.username
|
||||
response.ak_context.setdefault(KEY_USER, request.user.username)
|
||||
for key in list(LOCAL.authentik.keys()):
|
||||
del LOCAL.authentik[key]
|
||||
return response
|
||||
|
@ -12,9 +12,9 @@ import authentik.core.models
|
||||
|
||||
|
||||
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
# We have to use a direct import here, otherwise we get an object manager error
|
||||
from authentik.core.models import User
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||
@ -28,9 +28,9 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
|
||||
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
|
||||
if password:
|
||||
akadmin.set_password(password, signal=False)
|
||||
akadmin.password = make_password(password)
|
||||
else:
|
||||
akadmin.set_unusable_password()
|
||||
akadmin.password = make_password(None)
|
||||
akadmin.save()
|
||||
|
||||
|
||||
|
@ -8,9 +8,9 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
# We have to use a direct import here, otherwise we get an object manager error
|
||||
from authentik.core.models import User
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||
@ -24,9 +24,9 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
|
||||
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
|
||||
if password:
|
||||
akadmin.set_password(password, signal=False)
|
||||
akadmin.password = make_password(password)
|
||||
else:
|
||||
akadmin.set_unusable_password()
|
||||
akadmin.password = make_password(None)
|
||||
akadmin.save()
|
||||
|
||||
|
||||
|
@ -36,8 +36,10 @@ def fix_duplicates(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
|
||||
|
||||
def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
# We have to use a direct import here, otherwise we get an object manager error
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
from authentik.core.models import TokenIntents
|
||||
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
Token = apps.get_model("authentik_core", "Token")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
|
23
authentik/core/migrations/0021_source_user_path_user_path.py
Normal file
23
authentik/core/migrations/0021_source_user_path_user_path.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.0.5 on 2022-06-13 18:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0020_application_open_in_new_tab"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="source",
|
||||
name="user_path_template",
|
||||
field=models.TextField(default="goauthentik.io/sources/%(slug)s"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="path",
|
||||
field=models.TextField(default="users"),
|
||||
),
|
||||
]
|
@ -7,8 +7,10 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
|
||||
def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
# We have to use a direct import here, otherwise we get an object manager error
|
||||
from authentik.core.models import Token, TokenIntents, User
|
||||
from authentik.core.models import TokenIntents
|
||||
|
||||
User = apps.get_model("authentik_core", "User")
|
||||
Token = apps.get_model("authentik_core", "Token")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
|
@ -46,6 +46,9 @@ USER_ATTRIBUTE_CHANGE_NAME = "goauthentik.io/user/can-change-name"
|
||||
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
|
||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
|
||||
|
||||
USER_PATH_SYSTEM_PREFIX = "goauthentik.io"
|
||||
USER_PATH_SERVICE_ACCOUNT = USER_PATH_SYSTEM_PREFIX + "/service-accounts"
|
||||
|
||||
GRAVATAR_URL = "https://secure.gravatar.com"
|
||||
DEFAULT_AVATAR = static("dist/assets/images/user_default.png")
|
||||
|
||||
@ -103,7 +106,10 @@ class Group(models.Model):
|
||||
|
||||
SELECT authentik_core_group.*, parents.relative_depth - 1
|
||||
FROM authentik_core_group,parents
|
||||
WHERE authentik_core_group.parent_id = parents.group_uuid
|
||||
WHERE (
|
||||
authentik_core_group.parent_id = parents.group_uuid and
|
||||
parents.relative_depth > -20
|
||||
)
|
||||
)
|
||||
SELECT group_uuid
|
||||
FROM parents
|
||||
@ -138,6 +144,7 @@ class User(GuardianUserMixin, AbstractUser):
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False)
|
||||
name = models.TextField(help_text=_("User's display name."))
|
||||
path = models.TextField(default="users")
|
||||
|
||||
sources = models.ManyToManyField("Source", through="UserSourceConnection")
|
||||
ak_groups = models.ManyToManyField("Group", related_name="users")
|
||||
@ -147,6 +154,11 @@ class User(GuardianUserMixin, AbstractUser):
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
@staticmethod
|
||||
def default_path() -> str:
|
||||
"""Get the default user path"""
|
||||
return User._meta.get_field("path").default
|
||||
|
||||
def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]:
|
||||
"""Get a dictionary containing the attributes from all groups the user belongs to,
|
||||
including the users attributes"""
|
||||
@ -373,6 +385,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
name = models.TextField(help_text=_("Source's display Name."))
|
||||
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
|
||||
|
||||
user_path_template = models.TextField(default="goauthentik.io/sources/%(slug)s")
|
||||
|
||||
enabled = models.BooleanField(default=True)
|
||||
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
|
||||
|
||||
@ -408,6 +422,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
|
||||
|
||||
objects = InheritanceManager()
|
||||
|
||||
def get_user_path(self) -> str:
|
||||
"""Get user path, fallback to default for formatting errors"""
|
||||
try:
|
||||
return self.user_path_template % {
|
||||
"slug": self.slug,
|
||||
}
|
||||
# pylint: disable=broad-except
|
||||
except Exception as exc:
|
||||
LOGGER.warning("Failed to template user path", exc=exc, source=self)
|
||||
return User.default_path()
|
||||
|
||||
@property
|
||||
def component(self) -> str:
|
||||
"""Return component used to edit this object"""
|
||||
@ -457,8 +482,9 @@ class ExpiringModel(models.Model):
|
||||
def filter_not_expired(cls, **kwargs) -> QuerySet:
|
||||
"""Filer for tokens which are not expired yet or are not expiring,
|
||||
and match filters in `kwargs`"""
|
||||
expired = Q(expires__lt=now(), expiring=True)
|
||||
return cls.objects.exclude(expired).filter(**kwargs)
|
||||
for obj in cls.objects.filter(**kwargs).filter(Q(expires__lt=now(), expiring=True)):
|
||||
obj.delete()
|
||||
return cls.objects.filter(**kwargs)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
|
@ -26,11 +26,11 @@ from authentik.flows.planner import (
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.utils.urls import redirect_with_qs
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
from authentik.policies.types import PolicyResult
|
||||
from authentik.policies.utils import delete_none_keys
|
||||
from authentik.stages.password import BACKEND_INBUILT
|
||||
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
|
||||
|
||||
|
||||
class Action(Enum):
|
||||
@ -165,9 +165,9 @@ class SourceFlowManager:
|
||||
return self.handle_enroll(connection)
|
||||
except FlowNonApplicableException as exc:
|
||||
self._logger.warning("Flow non applicable", exc=exc)
|
||||
return self.error_handler(exc, exc.policy_result)
|
||||
return self.error_handler(exc)
|
||||
# Default case, assume deny
|
||||
error = (
|
||||
error = Exception(
|
||||
_(
|
||||
(
|
||||
"Request to authenticate with %(source)s has been denied. Please authenticate "
|
||||
@ -178,14 +178,13 @@ class SourceFlowManager:
|
||||
)
|
||||
return self.error_handler(error)
|
||||
|
||||
def error_handler(
|
||||
self, error: Exception, policy_result: Optional[PolicyResult] = None
|
||||
) -> HttpResponse:
|
||||
def error_handler(self, error: Exception) -> HttpResponse:
|
||||
"""Handle any errors by returning an access denied stage"""
|
||||
response = AccessDeniedResponse(self.request)
|
||||
response.error_message = str(error)
|
||||
if policy_result:
|
||||
response.policy_result = policy_result
|
||||
if isinstance(error, FlowNonApplicableException):
|
||||
response.policy_result = error.policy_result
|
||||
response.error_message = error.messages
|
||||
return response
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
@ -291,5 +290,6 @@ class SourceFlowManager:
|
||||
connection,
|
||||
**{
|
||||
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
|
||||
PLAN_CONTEXT_USER_PATH: self.source.get_user_path(),
|
||||
},
|
||||
)
|
||||
|
@ -10,7 +10,9 @@
|
||||
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
window.authentik = {};
|
||||
window.authentik = {
|
||||
"locale": "{{ tenant.default_locale }}",
|
||||
};
|
||||
window.authentik.flow = {
|
||||
"layout": "{{ flow.layout }}",
|
||||
};
|
||||
|
@ -2,6 +2,7 @@
|
||||
from django.test.testcases import TestCase
|
||||
|
||||
from authentik.core.models import Group, User
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
class TestGroups(TestCase):
|
||||
@ -9,32 +10,43 @@ class TestGroups(TestCase):
|
||||
|
||||
def test_group_membership_simple(self):
|
||||
"""Test simple membership"""
|
||||
user = User.objects.create(username="user")
|
||||
user2 = User.objects.create(username="user2")
|
||||
group = Group.objects.create(name="group")
|
||||
user = User.objects.create(username=generate_id())
|
||||
user2 = User.objects.create(username=generate_id())
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group.users.add(user)
|
||||
self.assertTrue(group.is_member(user))
|
||||
self.assertFalse(group.is_member(user2))
|
||||
|
||||
def test_group_membership_parent(self):
|
||||
"""Test parent membership"""
|
||||
user = User.objects.create(username="user")
|
||||
user2 = User.objects.create(username="user2")
|
||||
first = Group.objects.create(name="first")
|
||||
second = Group.objects.create(name="second", parent=first)
|
||||
user = User.objects.create(username=generate_id())
|
||||
user2 = User.objects.create(username=generate_id())
|
||||
first = Group.objects.create(name=generate_id())
|
||||
second = Group.objects.create(name=generate_id(), parent=first)
|
||||
second.users.add(user)
|
||||
self.assertTrue(first.is_member(user))
|
||||
self.assertFalse(first.is_member(user2))
|
||||
|
||||
def test_group_membership_parent_extra(self):
|
||||
"""Test parent membership"""
|
||||
user = User.objects.create(username="user")
|
||||
user2 = User.objects.create(username="user2")
|
||||
first = Group.objects.create(name="first")
|
||||
second = Group.objects.create(name="second", parent=first)
|
||||
third = Group.objects.create(name="third", parent=second)
|
||||
user = User.objects.create(username=generate_id())
|
||||
user2 = User.objects.create(username=generate_id())
|
||||
first = Group.objects.create(name=generate_id())
|
||||
second = Group.objects.create(name=generate_id(), parent=first)
|
||||
third = Group.objects.create(name=generate_id(), parent=second)
|
||||
second.users.add(user)
|
||||
self.assertTrue(first.is_member(user))
|
||||
self.assertFalse(first.is_member(user2))
|
||||
self.assertFalse(third.is_member(user))
|
||||
self.assertFalse(third.is_member(user2))
|
||||
|
||||
def test_group_membership_recursive(self):
|
||||
"""Test group membership (recursive)"""
|
||||
user = User.objects.create(username=generate_id())
|
||||
group = Group.objects.create(name=generate_id())
|
||||
group2 = Group.objects.create(name=generate_id(), parent=group)
|
||||
group.users.add(user)
|
||||
group.parent = group2
|
||||
group.save()
|
||||
self.assertTrue(group.is_member(user))
|
||||
self.assertTrue(group2.is_member(user))
|
||||
|
@ -5,7 +5,7 @@ from rest_framework.test import APITestCase
|
||||
from authentik.core.models import User
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant
|
||||
from authentik.flows.models import FlowDesignation
|
||||
from authentik.lib.generators import generate_key
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.stages.email.models import EmailStage
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
@ -149,3 +149,65 @@ class TestUsersAPI(APITestCase):
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_paths(self):
|
||||
"""Test path"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:user-paths"),
|
||||
)
|
||||
print(response.content)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertJSONEqual(response.content.decode(), {"paths": ["users"]})
|
||||
|
||||
def test_path_valid(self):
|
||||
"""Test path"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
|
||||
def test_path_invalid(self):
|
||||
"""Test path (invalid)"""
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "/foo"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
|
||||
)
|
||||
|
||||
self.client.force_login(self.admin)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": ""},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(response.content.decode(), {"path": ["This field may not be blank."]})
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={"name": generate_id(), "username": generate_id(), "groups": [], "path": "foo/"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(), {"path": ["No leading or trailing slashes allowed."]}
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:user-list"),
|
||||
data={
|
||||
"name": generate_id(),
|
||||
"username": generate_id(),
|
||||
"groups": [],
|
||||
"path": "fos//o",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(), {"path": ["No empty segments in user path allowed."]}
|
||||
)
|
||||
|
@ -11,14 +11,13 @@ from authentik.lib.generators import generate_id
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
|
||||
def create_test_flow(designation: FlowDesignation = FlowDesignation.STAGE_CONFIGURATION) -> Flow:
|
||||
def create_test_flow(
|
||||
designation: FlowDesignation = FlowDesignation.STAGE_CONFIGURATION, **kwargs
|
||||
) -> Flow:
|
||||
"""Generate a flow that can be used for testing"""
|
||||
uid = generate_id(10)
|
||||
return Flow.objects.create(
|
||||
name=uid,
|
||||
title=uid,
|
||||
slug=slugify(uid),
|
||||
designation=designation,
|
||||
name=uid, title=uid, slug=slugify(uid), designation=designation, **kwargs
|
||||
)
|
||||
|
||||
|
||||
@ -47,11 +46,11 @@ def create_test_tenant() -> Tenant:
|
||||
|
||||
def create_test_cert() -> CertificateKeyPair:
|
||||
"""Generate a certificate for testing"""
|
||||
CertificateKeyPair.objects.filter(name="goauthentik.io").delete()
|
||||
builder = CertificateBuilder()
|
||||
builder.common_name = "goauthentik.io"
|
||||
builder.build(
|
||||
subject_alt_names=["goauthentik.io"],
|
||||
validity_days=360,
|
||||
)
|
||||
builder.name = generate_id()
|
||||
return builder.save()
|
||||
|
@ -14,7 +14,9 @@ from authentik.core.views.session import EndSessionView
|
||||
urlpatterns = [
|
||||
path(
|
||||
"",
|
||||
login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")),
|
||||
login_required(
|
||||
RedirectView.as_view(pattern_name="authentik_core:if-user", query_string=True)
|
||||
),
|
||||
name="root-redirect",
|
||||
),
|
||||
path(
|
||||
|
@ -53,10 +53,7 @@ class CertificateBuilder:
|
||||
.subject_name(
|
||||
x509.Name(
|
||||
[
|
||||
x509.NameAttribute(
|
||||
NameOID.COMMON_NAME,
|
||||
self.common_name,
|
||||
),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, self.common_name),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "authentik"),
|
||||
x509.NameAttribute(NameOID.ORGANIZATIONAL_UNIT_NAME, "Self-signed"),
|
||||
]
|
||||
@ -65,10 +62,7 @@ class CertificateBuilder:
|
||||
.issuer_name(
|
||||
x509.Name(
|
||||
[
|
||||
x509.NameAttribute(
|
||||
NameOID.COMMON_NAME,
|
||||
f"authentik {__version__}",
|
||||
),
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, f"authentik {__version__}"),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
@ -2,6 +2,13 @@
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from prometheus_client import Gauge
|
||||
|
||||
GAUGE_TASKS = Gauge(
|
||||
"authentik_system_tasks",
|
||||
"System tasks and their status",
|
||||
["task_name", "task_uid", "status"],
|
||||
)
|
||||
|
||||
|
||||
class AuthentikEventsConfig(AppConfig):
|
||||
|
@ -16,6 +16,7 @@ from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.events.models import Event, EventAction, Notification
|
||||
from authentik.events.signals import EventNewThread
|
||||
from authentik.events.utils import model_to_dict
|
||||
from authentik.flows.models import FlowToken
|
||||
from authentik.lib.sentry import before_send
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
|
||||
@ -26,6 +27,7 @@ IGNORED_MODELS = [
|
||||
AuthenticatedSession,
|
||||
StaticToken,
|
||||
Session,
|
||||
FlowToken,
|
||||
]
|
||||
if settings.DEBUG:
|
||||
from silk.models import Request, Response, SQLQuery
|
||||
|
@ -8,18 +8,12 @@ from typing import Any, Optional
|
||||
from celery import Task
|
||||
from django.core.cache import cache
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from prometheus_client import Gauge
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.events.apps import GAUGE_TASKS
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
|
||||
GAUGE_TASKS = Gauge(
|
||||
"authentik_system_tasks",
|
||||
"System tasks and their status",
|
||||
["task_name", "task_uid", "status"],
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
|
@ -73,6 +73,7 @@ class FlowSerializer(ModelSerializer):
|
||||
"compatibility_mode",
|
||||
"export_url",
|
||||
"layout",
|
||||
"denied_action",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"background": {"read_only": True},
|
||||
@ -110,8 +111,8 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = FlowSerializer
|
||||
lookup_field = "slug"
|
||||
ordering = ["slug", "name"]
|
||||
search_fields = ["name", "slug", "designation", "title"]
|
||||
filterset_fields = ["flow_uuid", "name", "slug", "designation"]
|
||||
search_fields = ["name", "slug", "designation", "title", "denied_action"]
|
||||
filterset_fields = ["flow_uuid", "name", "slug", "designation", "denied_action"]
|
||||
|
||||
@permission_required(None, ["authentik_flows.view_flow_cache"])
|
||||
@extend_schema(responses={200: CacheSerializer(many=False)})
|
||||
@ -371,7 +372,7 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
||||
request,
|
||||
_(
|
||||
"Flow not applicable to current user/request: %(messages)s"
|
||||
% {"messages": str(exc)}
|
||||
% {"messages": exc.messages}
|
||||
),
|
||||
)
|
||||
return Response(
|
||||
|
@ -3,9 +3,20 @@ from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.utils import ProgrammingError
|
||||
from prometheus_client import Gauge, Histogram
|
||||
|
||||
from authentik.lib.utils.reflection import all_subclasses
|
||||
|
||||
GAUGE_FLOWS_CACHED = Gauge(
|
||||
"authentik_flows_cached",
|
||||
"Cached flows",
|
||||
)
|
||||
HIST_FLOWS_PLAN_TIME = Histogram(
|
||||
"authentik_flows_plan_time",
|
||||
"Duration to build a plan for a flow",
|
||||
["flow_slug"],
|
||||
)
|
||||
|
||||
|
||||
class AuthentikFlowsConfig(AppConfig):
|
||||
"""authentik flows app config"""
|
||||
|
@ -1,6 +1,6 @@
|
||||
"""Challenge helpers"""
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Optional, TypedDict
|
||||
|
||||
from django.db import models
|
||||
from django.http import JsonResponse
|
||||
@ -95,6 +95,13 @@ class AccessDeniedChallenge(WithUserInfoChallenge):
|
||||
component = CharField(default="ak-stage-access-denied")
|
||||
|
||||
|
||||
class PermissionDict(TypedDict):
|
||||
"""Consent Permission"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class PermissionSerializer(PassiveSerializer):
|
||||
"""Permission used for consent"""
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""flow exceptions"""
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.policies.types import PolicyResult
|
||||
@ -9,6 +10,13 @@ class FlowNonApplicableException(SentryIgnoredException):
|
||||
|
||||
policy_result: PolicyResult
|
||||
|
||||
@property
|
||||
def messages(self) -> str:
|
||||
"""Get messages from policy result, fallback to generic reason"""
|
||||
if len(self.policy_result.messages) < 1:
|
||||
return _("Flow does not apply to current user (denied by policy).")
|
||||
return "\n".join(self.policy_result.messages)
|
||||
|
||||
|
||||
class EmptyFlowException(SentryIgnoredException):
|
||||
"""Flow has no stages."""
|
||||
|
@ -47,7 +47,8 @@ class ReevaluateMarker(StageMarker):
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||
|
||||
LOGGER.debug(
|
||||
"f(plan_inst)[re-eval marker]: running re-evaluation",
|
||||
"f(plan_inst): running re-evaluation",
|
||||
marker="ReevaluateMarker",
|
||||
binding=binding,
|
||||
policy_binding=self.binding,
|
||||
)
|
||||
@ -56,13 +57,15 @@ class ReevaluateMarker(StageMarker):
|
||||
)
|
||||
engine.use_cache = False
|
||||
engine.request.set_http_request(http_request)
|
||||
engine.request.context = plan.context
|
||||
engine.request.context["flow_plan"] = plan
|
||||
engine.request.context.update(plan.context)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if result.passing:
|
||||
return binding
|
||||
LOGGER.warning(
|
||||
"f(plan_inst)[re-eval marker]: binding failed re-evaluation",
|
||||
"f(plan_inst): binding failed re-evaluation",
|
||||
marker="ReevaluateMarker",
|
||||
binding=binding,
|
||||
messages=result.messages,
|
||||
)
|
||||
|
@ -14,7 +14,7 @@ return not akadmin.has_usable_password()"""
|
||||
PREFILL_POLICY_EXPRESSION = """# This policy sets the user for the currently running flow
|
||||
# by injecting "pending_user"
|
||||
akadmin = ak_user_by(username="akadmin")
|
||||
context["pending_user"] = akadmin
|
||||
context["flow_plan"].context["pending_user"] = akadmin
|
||||
return True"""
|
||||
|
||||
|
||||
|
26
authentik/flows/migrations/0023_flow_denied_action.py
Normal file
26
authentik/flows/migrations/0023_flow_denied_action.py
Normal file
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.0.5 on 2022-07-02 12:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0022_flow_layout"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="flow",
|
||||
name="denied_action",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("message_continue", "Message Continue"),
|
||||
("message", "Message"),
|
||||
("continue", "Continue"),
|
||||
],
|
||||
default="message_continue",
|
||||
help_text="Configure what should happen when a flow denies access to a user.",
|
||||
),
|
||||
),
|
||||
]
|
@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from model_utils.managers import InheritanceManager
|
||||
from rest_framework.serializers import BaseSerializer
|
||||
@ -40,6 +39,14 @@ class InvalidResponseAction(models.TextChoices):
|
||||
RESTART_WITH_CONTEXT = "restart_with_context"
|
||||
|
||||
|
||||
class FlowDeniedAction(models.TextChoices):
|
||||
"""Configure what response is given to denied flow executions"""
|
||||
|
||||
MESSAGE_CONTINUE = "message_continue"
|
||||
MESSAGE = "message"
|
||||
CONTINUE = "continue"
|
||||
|
||||
|
||||
class FlowDesignation(models.TextChoices):
|
||||
"""Designation of what a Flow should be used for. At a later point, this
|
||||
should be replaced by a database entry."""
|
||||
@ -87,13 +94,15 @@ class Stage(SerializerModel):
|
||||
return f"Stage {self.name}"
|
||||
|
||||
|
||||
def in_memory_stage(view: type["StageView"]) -> Stage:
|
||||
def in_memory_stage(view: type["StageView"], **kwargs) -> Stage:
|
||||
"""Creates an in-memory stage instance, based on a `view` as view."""
|
||||
stage = Stage()
|
||||
# Because we can't pickle a locally generated function,
|
||||
# we set the view as a separate property and reference a generic function
|
||||
# that returns that member
|
||||
setattr(stage, "__in_memory_type", view)
|
||||
for key, value in kwargs.items():
|
||||
setattr(stage, key, value)
|
||||
return stage
|
||||
|
||||
|
||||
@ -137,6 +146,12 @@ class Flow(SerializerModel, PolicyBindingModel):
|
||||
),
|
||||
)
|
||||
|
||||
denied_action = models.TextField(
|
||||
choices=FlowDeniedAction.choices,
|
||||
default=FlowDeniedAction.MESSAGE_CONTINUE,
|
||||
help_text=_("Configure what should happen when a flow denies access to a user."),
|
||||
)
|
||||
|
||||
@property
|
||||
def background_url(self) -> str:
|
||||
"""Get the URL to the background image. If the name is /static or starts with http
|
||||
@ -155,23 +170,6 @@ class Flow(SerializerModel, PolicyBindingModel):
|
||||
|
||||
return FlowSerializer
|
||||
|
||||
@staticmethod
|
||||
def with_policy(request: HttpRequest, **flow_filter) -> Optional["Flow"]:
|
||||
"""Get a Flow by `**flow_filter` and check if the request from `request` can access it."""
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
|
||||
flows = Flow.objects.filter(**flow_filter).order_by("slug")
|
||||
for flow in flows:
|
||||
engine = PolicyEngine(flow, request.user, request)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if result.passing:
|
||||
LOGGER.debug("with_policy: flow passing", flow=flow)
|
||||
return flow
|
||||
LOGGER.warning("with_policy: flow not passing", flow=flow, messages=result.messages)
|
||||
LOGGER.debug("with_policy: no flow found", filters=flow_filter)
|
||||
return None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Flow {self.name} ({self.slug})"
|
||||
|
||||
|
@ -4,16 +4,16 @@ from typing import Any, Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpRequest
|
||||
from prometheus_client import Gauge, Histogram
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.tracing import Span
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import cleanse_dict
|
||||
from authentik.flows.apps import HIST_FLOWS_PLAN_TIME
|
||||
from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableException
|
||||
from authentik.flows.markers import ReevaluateMarker, StageMarker
|
||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage
|
||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, Stage, in_memory_stage
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
|
||||
@ -26,15 +26,6 @@ PLAN_CONTEXT_SOURCE = "source"
|
||||
# Is set by the Flow Planner when a FlowToken was used, and the currently active flow plan
|
||||
# was restored.
|
||||
PLAN_CONTEXT_IS_RESTORED = "is_restored"
|
||||
GAUGE_FLOWS_CACHED = Gauge(
|
||||
"authentik_flows_cached",
|
||||
"Cached flows",
|
||||
)
|
||||
HIST_FLOWS_PLAN_TIME = Histogram(
|
||||
"authentik_flows_plan_time",
|
||||
"Duration to build a plan for a flow",
|
||||
["flow_slug"],
|
||||
)
|
||||
CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_flows"))
|
||||
|
||||
|
||||
@ -71,6 +62,12 @@ class FlowPlan:
|
||||
self.bindings.insert(1, FlowStageBinding(stage=stage, order=0))
|
||||
self.markers.insert(1, marker or StageMarker())
|
||||
|
||||
def redirect(self, destination: str):
|
||||
"""Insert a redirect stage as next stage"""
|
||||
from authentik.flows.stage import RedirectStage
|
||||
|
||||
self.insert_stage(in_memory_stage(RedirectStage, destination=destination))
|
||||
|
||||
def next(self, http_request: Optional[HttpRequest]) -> Optional[FlowStageBinding]:
|
||||
"""Return next pending stage from the bottom of the list"""
|
||||
if not self.has_stages:
|
||||
@ -146,11 +143,11 @@ class FlowPlanner:
|
||||
engine = PolicyEngine(self.flow, user, request)
|
||||
if default_context:
|
||||
span.set_data("default_context", cleanse_dict(default_context))
|
||||
engine.request.context = default_context
|
||||
engine.request.context.update(default_context)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if not result.passing:
|
||||
exc = FlowNonApplicableException(",".join(result.messages))
|
||||
exc = FlowNonApplicableException()
|
||||
exc.policy_result = result
|
||||
raise exc
|
||||
# User is passing so far, check if we have a cached plan
|
||||
@ -207,7 +204,8 @@ class FlowPlanner:
|
||||
stage=binding.stage,
|
||||
)
|
||||
engine = PolicyEngine(binding, user, request)
|
||||
engine.request.context = plan.context
|
||||
engine.request.context["flow_plan"] = plan
|
||||
engine.request.context.update(plan.context)
|
||||
engine.build()
|
||||
if engine.passing:
|
||||
self._logger.debug(
|
||||
|
@ -4,7 +4,7 @@ from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.flows.planner import GAUGE_FLOWS_CACHED
|
||||
from authentik.flows.apps import GAUGE_FLOWS_CACHED
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
@ -19,6 +19,7 @@ from authentik.flows.challenge import (
|
||||
ChallengeTypes,
|
||||
ContextualFlowInfo,
|
||||
HttpChallengeResponse,
|
||||
RedirectChallenge,
|
||||
WithUserInfoChallenge,
|
||||
)
|
||||
from authentik.flows.models import InvalidResponseAction
|
||||
@ -219,3 +220,21 @@ class AccessDeniedChallengeView(ChallengeStageView):
|
||||
# .get() method is called
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse: # pragma: no cover
|
||||
return self.executor.cancel()
|
||||
|
||||
|
||||
class RedirectStage(ChallengeStageView):
|
||||
"""Redirect to any URL"""
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> RedirectChallenge:
|
||||
destination = getattr(
|
||||
self.executor.current_stage, "destination", reverse("authentik_core:root-redirect")
|
||||
)
|
||||
return RedirectChallenge(
|
||||
data={
|
||||
"type": ChallengeTypes.REDIRECT.value,
|
||||
"to": destination,
|
||||
}
|
||||
)
|
||||
|
||||
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
|
||||
return HttpChallengeResponse(self.get_challenge())
|
||||
|
@ -6,14 +6,20 @@ from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
from authentik.core.tests.utils import create_test_flow
|
||||
from authentik.flows.markers import ReevaluateMarker, StageMarker
|
||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction
|
||||
from authentik.flows.models import (
|
||||
FlowDeniedAction,
|
||||
FlowDesignation,
|
||||
FlowStageBinding,
|
||||
InvalidResponseAction,
|
||||
)
|
||||
from authentik.flows.planner import FlowPlan, FlowPlanner
|
||||
from authentik.flows.stage import PLAN_CONTEXT_PENDING_USER_IDENTIFIER, StageView
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_PLAN, FlowExecutorView
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.policies.reputation.models import ReputationPolicy
|
||||
@ -22,7 +28,7 @@ from authentik.stages.deny.models import DenyStage
|
||||
from authentik.stages.dummy.models import DummyStage
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False))
|
||||
POLICY_RETURN_FALSE = PropertyMock(return_value=PolicyResult(False, "foo"))
|
||||
POLICY_RETURN_TRUE = MagicMock(return_value=PolicyResult(True))
|
||||
|
||||
|
||||
@ -47,12 +53,10 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
def test_existing_plan_diff_flow(self):
|
||||
"""Check that a plan for a different flow cancels the current plan"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-existing-plan-diff",
|
||||
slug="test-existing-plan-diff",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
stage = DummyStage.objects.create(name="dummy")
|
||||
stage = DummyStage.objects.create(name=generate_id())
|
||||
binding = FlowStageBinding(target=flow, stage=stage, order=0)
|
||||
plan = FlowPlan(flow_pk=flow.pk.hex + "a", bindings=[binding], markers=[StageMarker()])
|
||||
session = self.client.session
|
||||
@ -77,10 +81,8 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
def test_invalid_non_applicable_flow(self):
|
||||
"""Tests that a non-applicable flow returns the correct error message"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-non-applicable",
|
||||
slug="test-non-applicable",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
CONFIG.update_from_dict({"domain": "testserver"})
|
||||
@ -90,7 +92,7 @@ class TestFlowExecutor(FlowTestCase):
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow=flow,
|
||||
error_message=FlowNonApplicableException.__doc__,
|
||||
error_message="foo",
|
||||
component="ak-stage-access-denied",
|
||||
)
|
||||
|
||||
@ -98,12 +100,15 @@ class TestFlowExecutor(FlowTestCase):
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_invalid_empty_flow(self):
|
||||
"""Tests that an empty flow returns the correct error message"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-empty",
|
||||
slug="test-empty",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
@patch(
|
||||
"authentik.policies.engine.PolicyEngine.result",
|
||||
POLICY_RETURN_FALSE,
|
||||
)
|
||||
def test_invalid_non_applicable_flow_continue(self):
|
||||
"""Tests that a non-applicable flow that should redirect"""
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
denied_action=FlowDeniedAction.CONTINUE,
|
||||
)
|
||||
|
||||
CONFIG.update_from_dict({"domain": "testserver"})
|
||||
@ -119,10 +124,8 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
def test_invalid_flow_redirect(self):
|
||||
"""Tests that an invalid flow still redirects"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-empty",
|
||||
slug="test-empty",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
CONFIG.update_from_dict({"domain": "testserver"})
|
||||
@ -132,18 +135,33 @@ class TestFlowExecutor(FlowTestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
||||
|
||||
@patch(
|
||||
"authentik.flows.views.executor.to_stage_response",
|
||||
TO_STAGE_RESPONSE_MOCK,
|
||||
)
|
||||
def test_invalid_empty_flow(self):
|
||||
"""Tests that an empty flow returns the correct error message"""
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
CONFIG.update_from_dict({"domain": "testserver"})
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.url, reverse("authentik_core:root-redirect"))
|
||||
|
||||
def test_multi_stage_flow(self):
|
||||
"""Test a full flow with multiple stages"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-full",
|
||||
slug="test-full",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
)
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy2"), order=1
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=1
|
||||
)
|
||||
|
||||
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
@ -170,19 +188,19 @@ class TestFlowExecutor(FlowTestCase):
|
||||
)
|
||||
def test_reevaluate_remove_last(self):
|
||||
"""Test planner with re-evaluate (last stage is removed)"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-default-context",
|
||||
slug="test-default-context",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
false_policy = DummyPolicy.objects.create(
|
||||
name=generate_id(), result=False, wait_min=1, wait_max=2
|
||||
)
|
||||
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
||||
|
||||
binding = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name="dummy2"),
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=1,
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
@ -217,24 +235,24 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
def test_reevaluate_remove_middle(self):
|
||||
"""Test planner with re-evaluate (middle stage is removed)"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-default-context",
|
||||
slug="test-default-context",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
false_policy = DummyPolicy.objects.create(
|
||||
name=generate_id(), result=False, wait_min=1, wait_max=2
|
||||
)
|
||||
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
||||
|
||||
binding = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name="dummy2"),
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=1,
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
binding3 = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
|
||||
)
|
||||
|
||||
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
||||
@ -277,24 +295,24 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
def test_reevaluate_keep(self):
|
||||
"""Test planner with re-evaluate (everything is kept)"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-default-context",
|
||||
slug="test-default-context",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
true_policy = DummyPolicy.objects.create(
|
||||
name=generate_id(), result=True, wait_min=1, wait_max=2
|
||||
)
|
||||
true_policy = DummyPolicy.objects.create(result=True, wait_min=1, wait_max=2)
|
||||
|
||||
binding = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name="dummy2"),
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=1,
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
binding3 = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy3"), order=2
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
|
||||
)
|
||||
|
||||
PolicyBinding.objects.create(policy=true_policy, target=binding2, order=0)
|
||||
@ -347,30 +365,30 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
def test_reevaluate_remove_consecutive(self):
|
||||
"""Test planner with re-evaluate (consecutive stages are removed)"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-default-context",
|
||||
slug="test-default-context",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
false_policy = DummyPolicy.objects.create(
|
||||
name=generate_id(), result=False, wait_min=1, wait_max=2
|
||||
)
|
||||
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
||||
|
||||
binding = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
)
|
||||
binding2 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name="dummy2"),
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=1,
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
binding3 = FlowStageBinding.objects.create(
|
||||
target=flow,
|
||||
stage=DummyStage.objects.create(name="dummy3"),
|
||||
stage=DummyStage.objects.create(name=generate_id()),
|
||||
order=2,
|
||||
re_evaluate_policies=True,
|
||||
)
|
||||
binding4 = FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy4"), order=2
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=2
|
||||
)
|
||||
|
||||
PolicyBinding.objects.create(policy=false_policy, target=binding2, order=0)
|
||||
@ -415,13 +433,11 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
def test_stageview_user_identifier(self):
|
||||
"""Test PLAN_CONTEXT_PENDING_USER_IDENTIFIER"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-default-context",
|
||||
slug="test-default-context",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy"), order=0
|
||||
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
|
||||
)
|
||||
|
||||
ident = "test-identifier"
|
||||
@ -443,10 +459,8 @@ class TestFlowExecutor(FlowTestCase):
|
||||
|
||||
def test_invalid_restart(self):
|
||||
"""Test flow that restarts on invalid entry"""
|
||||
flow = Flow.objects.create(
|
||||
name="restart-on-invalid",
|
||||
slug="restart-on-invalid",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
flow = create_test_flow(
|
||||
FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
# Stage 0 is a deny stage that is added dynamically
|
||||
# when the reputation policy says so
|
||||
|
@ -27,6 +27,7 @@ def get_attrs(obj: SerializerModel) -> dict[str, Any]:
|
||||
"promptstage_set",
|
||||
"policybindingmodel_ptr_id",
|
||||
"export_url",
|
||||
"meta_model_name",
|
||||
)
|
||||
for to_remove_name in to_remove:
|
||||
if to_remove_name in data:
|
||||
|
@ -10,6 +10,7 @@ from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.http.request import QueryDict
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.clickjacking import xframe_options_sameorigin
|
||||
from django.views.generic import View
|
||||
@ -37,6 +38,7 @@ from authentik.flows.exceptions import EmptyFlowException, FlowNonApplicableExce
|
||||
from authentik.flows.models import (
|
||||
ConfigurableStage,
|
||||
Flow,
|
||||
FlowDeniedAction,
|
||||
FlowDesignation,
|
||||
FlowStageBinding,
|
||||
FlowToken,
|
||||
@ -54,6 +56,7 @@ from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.reflection import all_subclasses, class_to_path
|
||||
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -129,21 +132,27 @@ class FlowExecutorView(APIView):
|
||||
self._logger = get_logger().bind(flow_slug=flow_slug)
|
||||
set_tag("authentik.flow", self.flow.slug)
|
||||
|
||||
def handle_invalid_flow(self, exc: BaseException) -> HttpResponse:
|
||||
def handle_invalid_flow(self, exc: FlowNonApplicableException) -> HttpResponse:
|
||||
"""When a flow is non-applicable check if user is on the correct domain"""
|
||||
if NEXT_ARG_NAME in self.request.GET:
|
||||
if not is_url_absolute(self.request.GET.get(NEXT_ARG_NAME)):
|
||||
if self.flow.denied_action in [
|
||||
FlowDeniedAction.CONTINUE,
|
||||
FlowDeniedAction.MESSAGE_CONTINUE,
|
||||
]:
|
||||
next_url = self.request.GET.get(NEXT_ARG_NAME)
|
||||
if next_url and not is_url_absolute(next_url):
|
||||
self._logger.debug("f(exec): Redirecting to next on fail")
|
||||
return redirect(self.request.GET.get(NEXT_ARG_NAME))
|
||||
message = exc.__doc__ if exc.__doc__ else str(exc)
|
||||
return self.stage_invalid(error_message=message)
|
||||
return to_stage_response(self.request, redirect(next_url))
|
||||
if self.flow.denied_action == FlowDeniedAction.CONTINUE:
|
||||
return to_stage_response(
|
||||
self.request, redirect(reverse("authentik_core:root-redirect"))
|
||||
)
|
||||
return to_stage_response(self.request, self.stage_invalid(error_message=exc.messages))
|
||||
|
||||
def _check_flow_token(self, get_params: QueryDict):
|
||||
def _check_flow_token(self, key: str) -> Optional[FlowPlan]:
|
||||
"""Check if the user is using a flow token to restore a plan"""
|
||||
tokens = FlowToken.filter_not_expired(key=get_params[QS_KEY_TOKEN])
|
||||
if not tokens.exists():
|
||||
return False
|
||||
token: FlowToken = tokens.first()
|
||||
token: Optional[FlowToken] = FlowToken.filter_not_expired(key=key).first()
|
||||
if not token:
|
||||
return None
|
||||
try:
|
||||
plan = token.plan
|
||||
except (AttributeError, EOFError, ImportError, IndexError) as exc:
|
||||
@ -164,7 +173,7 @@ class FlowExecutorView(APIView):
|
||||
span.set_data("authentik Flow", self.flow.slug)
|
||||
get_params = QueryDict(request.GET.get("query", ""))
|
||||
if QS_KEY_TOKEN in get_params:
|
||||
plan = self._check_flow_token(get_params)
|
||||
plan = self._check_flow_token(get_params[QS_KEY_TOKEN])
|
||||
if plan:
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
# Early check if there's an active Plan for the current session
|
||||
@ -188,7 +197,7 @@ class FlowExecutorView(APIView):
|
||||
self.plan = self._initiate_plan()
|
||||
except FlowNonApplicableException as exc:
|
||||
self._logger.warning("f(exec): Flow not applicable to current user", exc=exc)
|
||||
return to_stage_response(self.request, self.handle_invalid_flow(exc))
|
||||
return self.handle_invalid_flow(exc)
|
||||
except EmptyFlowException as exc:
|
||||
self._logger.warning("f(exec): Flow is empty", exc=exc)
|
||||
# To match behaviour with loading an empty flow plan from cache,
|
||||
@ -471,6 +480,20 @@ class ToDefaultFlow(View):
|
||||
|
||||
designation: Optional[FlowDesignation] = None
|
||||
|
||||
def flow_by_policy(self, request: HttpRequest, **flow_filter) -> Optional[Flow]:
|
||||
"""Get a Flow by `**flow_filter` and check if the request from `request` can access it."""
|
||||
flows = Flow.objects.filter(**flow_filter).order_by("slug")
|
||||
for flow in flows:
|
||||
engine = PolicyEngine(flow, request.user, request)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if result.passing:
|
||||
LOGGER.debug("flow_by_policy: flow passing", flow=flow)
|
||||
return flow
|
||||
LOGGER.warning("flow_by_policy: flow not passing", flow=flow, messages=result.messages)
|
||||
LOGGER.debug("flow_by_policy: no flow found", filters=flow_filter)
|
||||
return None
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
tenant: Tenant = request.tenant
|
||||
flow = None
|
||||
@ -481,7 +504,7 @@ class ToDefaultFlow(View):
|
||||
flow = tenant.flow_invalidation
|
||||
# If no flow was set, get the first based on slug and policy
|
||||
if not flow:
|
||||
flow = Flow.with_policy(request, designation=self.designation)
|
||||
flow = self.flow_by_policy(request, designation=self.designation)
|
||||
# If we still don't have a flow, 404
|
||||
if not flow:
|
||||
raise Http404
|
||||
|
@ -1,3 +1,4 @@
|
||||
# update website/docs/installation/configuration.md
|
||||
# This is the default configuration file
|
||||
postgresql:
|
||||
host: localhost
|
||||
@ -57,6 +58,10 @@ outposts:
|
||||
container_image_base: ghcr.io/goauthentik/%(type)s:%(version)s
|
||||
discover: true
|
||||
|
||||
ldap:
|
||||
tls:
|
||||
ciphers: null
|
||||
|
||||
cookie_domain: null
|
||||
disable_update_check: false
|
||||
disable_startup_analytics: false
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""authentik sentry integration"""
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from aioredis.errors import ConnectionClosedError, ReplyError
|
||||
from billiard.exceptions import SoftTimeLimitExceeded, WorkerLostError
|
||||
@ -17,7 +17,7 @@ from ldap3.core.exceptions import LDAPException
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
from redis.exceptions import RedisError, ResponseError
|
||||
from rest_framework.exceptions import APIException
|
||||
from sentry_sdk import Hub
|
||||
from sentry_sdk import HttpTransport, Hub
|
||||
from sentry_sdk import init as sentry_sdk_init
|
||||
from sentry_sdk.api import set_tag
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
@ -30,6 +30,7 @@ from websockets.exceptions import WebSocketException
|
||||
|
||||
from authentik import __version__, get_build_hash
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.http import authentik_user_agent
|
||||
from authentik.lib.utils.reflection import class_to_path, get_env
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -52,11 +53,18 @@ class SentryIgnoredException(Exception):
|
||||
"""Base Class for all errors that are suppressed, and not sent to sentry."""
|
||||
|
||||
|
||||
class SentryTransport(HttpTransport):
|
||||
"""Custom sentry transport with custom user-agent"""
|
||||
|
||||
def __init__(self, options: dict[str, Any]) -> None:
|
||||
super().__init__(options)
|
||||
self._auth = self.parsed_dsn.to_auth(authentik_user_agent())
|
||||
|
||||
|
||||
def sentry_init(**sentry_init_kwargs):
|
||||
"""Configure sentry SDK"""
|
||||
sentry_env = CONFIG.y("error_reporting.environment", "customer")
|
||||
kwargs = {
|
||||
"traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.5)),
|
||||
"environment": sentry_env,
|
||||
"send_default_pii": CONFIG.y_bool("error_reporting.send_pii", False),
|
||||
}
|
||||
@ -71,7 +79,9 @@ def sentry_init(**sentry_init_kwargs):
|
||||
ThreadingIntegration(propagate_hub=True),
|
||||
],
|
||||
before_send=before_send,
|
||||
traces_sampler=traces_sampler,
|
||||
release=f"authentik@{__version__}",
|
||||
transport=SentryTransport,
|
||||
**kwargs,
|
||||
)
|
||||
set_tag("authentik.build_hash", get_build_hash("tagged"))
|
||||
@ -83,6 +93,15 @@ def sentry_init(**sentry_init_kwargs):
|
||||
)
|
||||
|
||||
|
||||
def traces_sampler(sampling_context: dict) -> float:
|
||||
"""Custom sampler to ignore certain routes"""
|
||||
path = sampling_context.get("asgi_scope", {}).get("path", "")
|
||||
# Ignore all healthcheck routes
|
||||
if path.startswith("/-/health") or path.startswith("/-/metrics"):
|
||||
return 0
|
||||
return float(CONFIG.y("error_reporting.sample_rate", 0.5))
|
||||
|
||||
|
||||
def before_send(event: dict, hint: dict) -> Optional[dict]:
|
||||
"""Check if error is database error, and ignore if so"""
|
||||
# pylint: disable=no-name-in-module
|
||||
|
@ -1,10 +1,18 @@
|
||||
"""error utils"""
|
||||
from traceback import format_tb
|
||||
from traceback import extract_tb
|
||||
|
||||
TRACEBACK_HEADER = "Traceback (most recent call last):\n"
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
|
||||
TRACEBACK_HEADER = "Traceback (most recent call last):"
|
||||
|
||||
|
||||
def exception_to_string(exc: Exception) -> str:
|
||||
"""Convert exception to string stackrace"""
|
||||
# Either use passed original exception or whatever we have
|
||||
return TRACEBACK_HEADER + "".join(format_tb(exc.__traceback__)) + str(exc)
|
||||
return "\n".join(
|
||||
[
|
||||
TRACEBACK_HEADER,
|
||||
*[x.rstrip() for x in extract_tb(exc.__traceback__).format()],
|
||||
f"{class_to_path(exc.__class__)}: {str(exc)}",
|
||||
]
|
||||
)
|
||||
|
@ -8,3 +8,8 @@ class AuthentikManagedConfig(AppConfig):
|
||||
name = "authentik.managed"
|
||||
label = "authentik_managed"
|
||||
verbose_name = "authentik Managed"
|
||||
|
||||
def ready(self) -> None:
|
||||
from authentik.managed.tasks import managed_reconcile
|
||||
|
||||
managed_reconcile.delay()
|
||||
|
@ -11,7 +11,11 @@ from authentik.events.monitored_tasks import (
|
||||
from authentik.managed.manager import ObjectManager
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
@CELERY_APP.task(
|
||||
bind=True,
|
||||
base=MonitoredTask,
|
||||
retry_backoff=True,
|
||||
)
|
||||
@prefill_task
|
||||
def managed_reconcile(self: MonitoredTask):
|
||||
"""Run ObjectManager to ensure objects are up-to-date"""
|
||||
@ -22,3 +26,4 @@ def managed_reconcile(self: MonitoredTask):
|
||||
)
|
||||
except DatabaseError as exc: # pragma: no cover
|
||||
self.set_status(TaskResult(TaskResultStatus.WARNING, [str(exc)]))
|
||||
self.retry()
|
||||
|
@ -2,10 +2,20 @@
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from prometheus_client import Gauge
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
GAUGE_OUTPOSTS_CONNECTED = Gauge(
|
||||
"authentik_outposts_connected", "Currently connected outposts", ["outpost", "uid", "expected"]
|
||||
)
|
||||
GAUGE_OUTPOSTS_LAST_UPDATE = Gauge(
|
||||
"authentik_outposts_last_update",
|
||||
"Last update from any outpost",
|
||||
["outpost", "uid", "version"],
|
||||
)
|
||||
|
||||
|
||||
class AuthentikOutpostConfig(AppConfig):
|
||||
"""authentik outposts app config"""
|
||||
|
@ -8,21 +8,12 @@ from channels.exceptions import DenyConnection
|
||||
from dacite import from_dict
|
||||
from dacite.data import Data
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from prometheus_client import Gauge
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.channels import AuthJsonConsumer
|
||||
from authentik.outposts.apps import GAUGE_OUTPOSTS_CONNECTED, GAUGE_OUTPOSTS_LAST_UPDATE
|
||||
from authentik.outposts.models import OUTPOST_HELLO_INTERVAL, Outpost, OutpostState
|
||||
|
||||
GAUGE_OUTPOSTS_CONNECTED = Gauge(
|
||||
"authentik_outposts_connected", "Currently connected outposts", ["outpost", "uid", "expected"]
|
||||
)
|
||||
GAUGE_OUTPOSTS_LAST_UPDATE = Gauge(
|
||||
"authentik_outposts_last_update",
|
||||
"Last update from any outpost",
|
||||
["outpost", "uid", "version"],
|
||||
)
|
||||
|
||||
|
||||
class WebsocketMessageInstruction(IntEnum):
|
||||
"""Commands which can be triggered over Websocket"""
|
||||
|
@ -20,6 +20,7 @@ from authentik import __version__, get_build_hash
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_CAN_OVERRIDE_IP,
|
||||
USER_ATTRIBUTE_SA,
|
||||
USER_PATH_SYSTEM_PREFIX,
|
||||
Provider,
|
||||
Token,
|
||||
TokenIntents,
|
||||
@ -39,6 +40,8 @@ OUR_VERSION = parse(__version__)
|
||||
OUTPOST_HELLO_INTERVAL = 10
|
||||
LOGGER = get_logger()
|
||||
|
||||
USER_PATH_OUTPOSTS = USER_PATH_SYSTEM_PREFIX + "/outposts"
|
||||
|
||||
|
||||
class ServiceConnectionInvalid(SentryIgnoredException):
|
||||
"""Exception raised when a Service Connection has invalid parameters"""
|
||||
@ -328,19 +331,18 @@ class Outpost(ManagedModel):
|
||||
@property
|
||||
def user(self) -> User:
|
||||
"""Get/create user with access to all required objects"""
|
||||
users = User.objects.filter(username=self.user_identifier)
|
||||
should_create_user = not users.exists()
|
||||
if should_create_user:
|
||||
user = User.objects.filter(username=self.user_identifier).first()
|
||||
user_created = False
|
||||
if not user:
|
||||
user: User = User.objects.create(username=self.user_identifier)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
else:
|
||||
user = users.first()
|
||||
user_created = True
|
||||
user.attributes[USER_ATTRIBUTE_SA] = True
|
||||
user.attributes[USER_ATTRIBUTE_CAN_OVERRIDE_IP] = True
|
||||
user.name = f"Outpost {self.name} Service-Account"
|
||||
user.path = USER_PATH_OUTPOSTS
|
||||
user.save()
|
||||
if should_create_user:
|
||||
if user_created:
|
||||
self.build_user_permissions(user)
|
||||
return user
|
||||
|
||||
|
@ -5,7 +5,6 @@ from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from guardian.shortcuts import get_objects_for_user
|
||||
from rest_framework import mixins
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
@ -158,7 +157,7 @@ class PolicyViewSet(
|
||||
pk=test_params.validated_data["user"].pk
|
||||
)
|
||||
if not users.exists():
|
||||
raise PermissionDenied()
|
||||
return Response(status=400)
|
||||
|
||||
p_request = PolicyRequest(users.first())
|
||||
p_request.debug = True
|
||||
|
@ -2,6 +2,29 @@
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from prometheus_client import Gauge, Histogram
|
||||
|
||||
GAUGE_POLICIES_CACHED = Gauge(
|
||||
"authentik_policies_cached",
|
||||
"Cached Policies",
|
||||
)
|
||||
HIST_POLICIES_BUILD_TIME = Histogram(
|
||||
"authentik_policies_build_time",
|
||||
"Execution times complete policy result to an object",
|
||||
["object_pk", "object_type"],
|
||||
)
|
||||
|
||||
HIST_POLICIES_EXECUTION_TIME = Histogram(
|
||||
"authentik_policies_execution_time",
|
||||
"Execution times for single policies",
|
||||
[
|
||||
"binding_order",
|
||||
"binding_target_type",
|
||||
"binding_target_name",
|
||||
"object_pk",
|
||||
"object_type",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class AuthentikPoliciesConfig(AppConfig):
|
||||
|
@ -5,26 +5,17 @@ from typing import Iterator, Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpRequest
|
||||
from prometheus_client import Gauge, Histogram
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.tracing import Span
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.policies.apps import HIST_POLICIES_BUILD_TIME
|
||||
from authentik.policies.models import Policy, PolicyBinding, PolicyBindingModel, PolicyEngineMode
|
||||
from authentik.policies.process import PolicyProcess, cache_key
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
|
||||
CURRENT_PROCESS = current_process()
|
||||
GAUGE_POLICIES_CACHED = Gauge(
|
||||
"authentik_policies_cached",
|
||||
"Cached Policies",
|
||||
)
|
||||
HIST_POLICIES_BUILD_TIME = Histogram(
|
||||
"authentik_policies_build_time",
|
||||
"Execution times complete policy result to an object",
|
||||
["object_pk", "object_type"],
|
||||
)
|
||||
|
||||
|
||||
class PolicyProcessInfo:
|
||||
|
@ -4,7 +4,6 @@ from multiprocessing.connection import Connection
|
||||
from typing import Optional
|
||||
|
||||
from django.core.cache import cache
|
||||
from prometheus_client import Histogram
|
||||
from sentry_sdk.hub import Hub
|
||||
from sentry_sdk.tracing import Span
|
||||
from structlog.stdlib import get_logger
|
||||
@ -12,6 +11,7 @@ from structlog.stdlib import get_logger
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.policies.apps import HIST_POLICIES_EXECUTION_TIME
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
@ -21,17 +21,6 @@ LOGGER = get_logger()
|
||||
FORK_CTX = get_context("fork")
|
||||
CACHE_TIMEOUT = int(CONFIG.y("redis.cache_timeout_policies"))
|
||||
PROCESS_CLASS = FORK_CTX.Process
|
||||
HIST_POLICIES_EXECUTION_TIME = Histogram(
|
||||
"authentik_policies_execution_time",
|
||||
"Execution times for single policies",
|
||||
[
|
||||
"binding_order",
|
||||
"binding_target_type",
|
||||
"binding_target_name",
|
||||
"object_pk",
|
||||
"object_type",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def cache_key(binding: PolicyBinding, request: PolicyRequest) -> str:
|
||||
|
@ -5,7 +5,7 @@ from django.dispatch import receiver
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.api.applications import user_app_cache_key
|
||||
from authentik.policies.engine import GAUGE_POLICIES_CACHED
|
||||
from authentik.policies.apps import GAUGE_POLICIES_CACHED
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
@ -1,11 +1,13 @@
|
||||
"""Test policies API"""
|
||||
from json import loads
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.policies.dummy.models import DummyPolicy
|
||||
from authentik.policies.types import PolicyResult
|
||||
|
||||
|
||||
class TestPoliciesAPI(APITestCase):
|
||||
@ -17,8 +19,10 @@ class TestPoliciesAPI(APITestCase):
|
||||
self.user = create_test_admin_user()
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_test_call(self):
|
||||
@patch("authentik.policies.dummy.models.DummyPolicy.passes")
|
||||
def test_test_call(self, passes_mock: MagicMock):
|
||||
"""Test Policy's test endpoint"""
|
||||
passes_mock.return_value = PolicyResult(True, "dummy")
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:policy-test", kwargs={"pk": self.policy.pk}),
|
||||
data={
|
||||
@ -28,6 +32,22 @@ class TestPoliciesAPI(APITestCase):
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["passing"], True)
|
||||
self.assertEqual(body["messages"], ["dummy"])
|
||||
self.assertEqual(body["log_messages"], [])
|
||||
|
||||
def test_test_call_invalid(self):
|
||||
"""Test invalid policy test"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:policy-test", kwargs={"pk": self.policy.pk}),
|
||||
data={},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:policy-test", kwargs={"pk": self.policy.pk}),
|
||||
data={
|
||||
"user": self.user.pk + 1,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_types(self):
|
||||
"""Test Policy's types endpoint"""
|
||||
@ -35,3 +55,17 @@ class TestPoliciesAPI(APITestCase):
|
||||
reverse("authentik_api:policy-types"),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_cache_info(self):
|
||||
"""Test Policy's cache_info endpoint"""
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:policy-cache-info"),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_cache_clear(self):
|
||||
"""Test Policy's cache_clear endpoint"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_api:policy-cache-clear"),
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
@ -117,8 +117,8 @@ class PolicyAccessView(AccessMixin, View):
|
||||
result = policy_engine.result
|
||||
LOGGER.debug(
|
||||
"PolicyAccessView user_has_access",
|
||||
user=user,
|
||||
app=self.application,
|
||||
user=user.username,
|
||||
app=self.application.slug,
|
||||
result=result,
|
||||
)
|
||||
if not result.passing:
|
||||
|
@ -34,7 +34,6 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
"sub_mode",
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
"verification_keys",
|
||||
"jwks_sources",
|
||||
]
|
||||
|
||||
|
@ -18,6 +18,8 @@ SCOPE_OPENID = "openid"
|
||||
SCOPE_OPENID_PROFILE = "profile"
|
||||
SCOPE_OPENID_EMAIL = "email"
|
||||
|
||||
SCOPE_AUTHENTIK_API = "goauthentik.io/api"
|
||||
|
||||
# Read/write full user (including email)
|
||||
SCOPE_GITHUB_USER = "user"
|
||||
# Read user (without email)
|
||||
|
@ -95,38 +95,45 @@ class TokenIntrospectionError(OAuth2Error):
|
||||
class AuthorizeError(OAuth2Error):
|
||||
"""General Authorization Errors"""
|
||||
|
||||
_errors = {
|
||||
errors = {
|
||||
# OAuth2 errors.
|
||||
# https://tools.ietf.org/html/rfc6749#section-4.1.2.1
|
||||
"invalid_request": "The request is otherwise malformed",
|
||||
"unauthorized_client": "The client is not authorized to request an "
|
||||
"authorization code using this method",
|
||||
"access_denied": "The resource owner or authorization server denied " "the request",
|
||||
"unsupported_response_type": "The authorization server does not "
|
||||
"support obtaining an authorization code "
|
||||
"using this method",
|
||||
"invalid_scope": "The requested scope is invalid, unknown, or " "malformed",
|
||||
"unauthorized_client": (
|
||||
"The client is not authorized to request an authorization code using this method"
|
||||
),
|
||||
"access_denied": "The resource owner or authorization server denied the request",
|
||||
"unsupported_response_type": (
|
||||
"The authorization server does not support obtaining an authorization code "
|
||||
"using this method"
|
||||
),
|
||||
"invalid_scope": "The requested scope is invalid, unknown, or malformed",
|
||||
"server_error": "The authorization server encountered an error",
|
||||
"temporarily_unavailable": "The authorization server is currently "
|
||||
"unable to handle the request due to a "
|
||||
"temporary overloading or maintenance of "
|
||||
"the server",
|
||||
"temporarily_unavailable": (
|
||||
"The authorization server is currently unable to handle the request due to a "
|
||||
"temporary overloading or maintenance of the server"
|
||||
),
|
||||
# OpenID errors.
|
||||
# http://openid.net/specs/openid-connect-core-1_0.html#AuthError
|
||||
"interaction_required": "The Authorization Server requires End-User "
|
||||
"interaction of some form to proceed",
|
||||
"login_required": "The Authorization Server requires End-User " "authentication",
|
||||
"account_selection_required": "The End-User is required to select a "
|
||||
"session at the Authorization Server",
|
||||
"consent_required": "The Authorization Server requires End-User" "consent",
|
||||
"invalid_request_uri": "The request_uri in the Authorization Request "
|
||||
"returns an error or contains invalid data",
|
||||
"invalid_request_object": "The request parameter contains an invalid " "Request Object",
|
||||
"request_not_supported": "The provider does not support use of the " "request parameter",
|
||||
"request_uri_not_supported": "The provider does not support use of the "
|
||||
"request_uri parameter",
|
||||
"registration_not_supported": "The provider does not support use of "
|
||||
"the registration parameter",
|
||||
"interaction_required": (
|
||||
"The Authorization Server requires End-User interaction of some form to proceed"
|
||||
),
|
||||
"login_required": "The Authorization Server requires End-User authentication",
|
||||
"account_selection_required": (
|
||||
"The End-User is required to select a session at the Authorization Server"
|
||||
),
|
||||
"consent_required": "The Authorization Server requires End-Userconsent",
|
||||
"invalid_request_uri": (
|
||||
"The request_uri in the Authorization Request returns an error or contains invalid data"
|
||||
),
|
||||
"invalid_request_object": "The request parameter contains an invalid Request Object",
|
||||
"request_not_supported": "The provider does not support use of the request parameter",
|
||||
"request_uri_not_supported": (
|
||||
"The provider does not support use of the request_uri parameter"
|
||||
),
|
||||
"registration_not_supported": (
|
||||
"The provider does not support use of the registration parameter"
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@ -138,7 +145,7 @@ class AuthorizeError(OAuth2Error):
|
||||
):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
self.description = self._errors[error]
|
||||
self.description = self.errors[error]
|
||||
self.redirect_uri = redirect_uri
|
||||
self.grant_type = grant_type
|
||||
self.state = state
|
||||
@ -170,19 +177,25 @@ class TokenError(OAuth2Error):
|
||||
|
||||
errors = {
|
||||
"invalid_request": "The request is otherwise malformed",
|
||||
"invalid_client": "Client authentication failed (e.g., unknown client, "
|
||||
"no client authentication included, or unsupported "
|
||||
"authentication method)",
|
||||
"invalid_grant": "The provided authorization grant or refresh token is "
|
||||
"invalid, expired, revoked, does not match the "
|
||||
"redirection URI used in the authorization request, "
|
||||
"or was issued to another client",
|
||||
"unauthorized_client": "The authenticated client is not authorized to "
|
||||
"use this authorization grant type",
|
||||
"unsupported_grant_type": "The authorization grant type is not "
|
||||
"supported by the authorization server",
|
||||
"invalid_scope": "The requested scope is invalid, unknown, malformed, "
|
||||
"or exceeds the scope granted by the resource owner",
|
||||
"invalid_client": (
|
||||
"Client authentication failed (e.g., unknown client, no client authentication "
|
||||
"included, or unsupported authentication method)"
|
||||
),
|
||||
"invalid_grant": (
|
||||
"The provided authorization grant or refresh token is invalid, expired, revoked, "
|
||||
"does not match the redirection URI used in the authorization request, "
|
||||
"or was issued to another client"
|
||||
),
|
||||
"unauthorized_client": (
|
||||
"The authenticated client is not authorized to use this authorization grant type"
|
||||
),
|
||||
"unsupported_grant_type": (
|
||||
"The authorization grant type is not supported by the authorization server"
|
||||
),
|
||||
"invalid_scope": (
|
||||
"The requested scope is invalid, unknown, malformed, or exceeds the scope "
|
||||
"granted by the resource owner"
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, error):
|
||||
@ -191,17 +204,39 @@ class TokenError(OAuth2Error):
|
||||
self.description = self.errors[error]
|
||||
|
||||
|
||||
class TokenRevocationError(OAuth2Error):
|
||||
"""
|
||||
Specific to the revocation endpoint.
|
||||
See https://tools.ietf.org/html/rfc7662
|
||||
"""
|
||||
|
||||
errors = TokenError.errors | {
|
||||
"unsupported_token_type": (
|
||||
"The authorization server does not support the revocation of the presented "
|
||||
"token type. That is, the client tried to revoke an access token on a server not"
|
||||
"supporting this feature."
|
||||
)
|
||||
}
|
||||
|
||||
def __init__(self, error: str):
|
||||
super().__init__()
|
||||
self.error = error
|
||||
self.description = self.errors[error]
|
||||
|
||||
|
||||
class BearerTokenError(OAuth2Error):
|
||||
"""
|
||||
OAuth2 errors.
|
||||
https://tools.ietf.org/html/rfc6750#section-3.1
|
||||
"""
|
||||
|
||||
_errors = {
|
||||
errors = {
|
||||
"invalid_request": ("The request is otherwise malformed", 400),
|
||||
"invalid_token": (
|
||||
"The access token provided is expired, revoked, malformed, "
|
||||
"or invalid for other reasons",
|
||||
(
|
||||
"The access token provided is expired, revoked, malformed, "
|
||||
"or invalid for other reasons"
|
||||
),
|
||||
401,
|
||||
),
|
||||
"insufficient_scope": (
|
||||
@ -213,6 +248,6 @@ class BearerTokenError(OAuth2Error):
|
||||
def __init__(self, code):
|
||||
super().__init__()
|
||||
self.code = code
|
||||
error_tuple = self._errors.get(code, ("", ""))
|
||||
error_tuple = self.errors.get(code, ("", ""))
|
||||
self.description = error_tuple[0]
|
||||
self.status = error_tuple[1]
|
||||
|
@ -0,0 +1,28 @@
|
||||
# Generated by Django 4.0.5 on 2022-06-04 21:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_oauth2", "0011_oauth2provider_jwks_sources_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="oauth2provider",
|
||||
name="verification_keys",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="client_type",
|
||||
field=models.CharField(
|
||||
choices=[("confidential", "Confidential"), ("public", "Public")],
|
||||
default="confidential",
|
||||
help_text="Confidential clients are capable of maintaining the confidentiality of their credentials. Public clients are incapable",
|
||||
max_length=30,
|
||||
verbose_name="Client Type",
|
||||
),
|
||||
),
|
||||
]
|
@ -143,7 +143,10 @@ class OAuth2Provider(Provider):
|
||||
choices=ClientTypes.choices,
|
||||
default=ClientTypes.CONFIDENTIAL,
|
||||
verbose_name=_("Client Type"),
|
||||
help_text=_(ClientTypes.__doc__),
|
||||
help_text=_(
|
||||
"Confidential clients are capable of maintaining the confidentiality "
|
||||
"of their credentials. Public clients are incapable"
|
||||
),
|
||||
)
|
||||
client_id = models.CharField(
|
||||
max_length=255,
|
||||
@ -222,19 +225,6 @@ class OAuth2Provider(Provider):
|
||||
),
|
||||
)
|
||||
|
||||
verification_keys = models.ManyToManyField(
|
||||
CertificateKeyPair,
|
||||
verbose_name=_("Allowed certificates for JWT-based client_credentials"),
|
||||
help_text=_(
|
||||
(
|
||||
"DEPRECATED. JWTs created with the configured "
|
||||
"certificates can authenticate with this provider."
|
||||
)
|
||||
),
|
||||
related_name="oauth2_providers",
|
||||
default=None,
|
||||
blank=True,
|
||||
)
|
||||
jwks_sources = models.ManyToManyField(
|
||||
OAuthSource,
|
||||
verbose_name=_(
|
||||
@ -252,7 +242,7 @@ class OAuth2Provider(Provider):
|
||||
token = RefreshToken(
|
||||
user=user,
|
||||
provider=self,
|
||||
refresh_token=generate_key(),
|
||||
refresh_token=base64.urlsafe_b64encode(generate_key().encode()).decode(),
|
||||
expires=timezone.now() + timedelta_from_string(self.token_validity),
|
||||
scope=scope,
|
||||
)
|
||||
|
@ -3,7 +3,7 @@ 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_cert, create_test_flow
|
||||
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, generate_key
|
||||
from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, RedirectUriError
|
||||
@ -39,7 +39,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
def test_request(self):
|
||||
"""test request param"""
|
||||
OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
@ -59,7 +59,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
def test_invalid_redirect_uri(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
@ -78,10 +78,55 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
def test_invalid_redirect_uri_empty(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "+",
|
||||
},
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
provider.refresh_from_db()
|
||||
self.assertEqual(provider.redirect_uris, "+")
|
||||
|
||||
def test_invalid_redirect_uri_regex(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid?",
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://localhost",
|
||||
},
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
def test_redirect_uri_invalid_regex(self):
|
||||
"""test missing/invalid redirect URI (invalid regex)"""
|
||||
OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="+",
|
||||
@ -103,7 +148,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
def test_empty_redirect_uri(self):
|
||||
"""test empty redirect URI (configure in provider)"""
|
||||
OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
)
|
||||
@ -123,7 +168,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
def test_response_type(self):
|
||||
"""test response_type"""
|
||||
OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid/Foo",
|
||||
@ -201,7 +246,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
"""Test full authorization"""
|
||||
flow = create_test_flow()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
authorization_flow=flow,
|
||||
redirect_uris="foo://localhost",
|
||||
@ -237,12 +282,12 @@ class TestAuthorize(OAuthTestCase):
|
||||
"""Test full authorization"""
|
||||
flow = create_test_flow()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=create_test_cert(),
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
state = generate_id()
|
||||
@ -277,18 +322,18 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
self.validate_jwt(token, provider)
|
||||
|
||||
def test_full_form_post(self):
|
||||
def test_full_form_post_id_token(self):
|
||||
"""Test full authorization (form_post response)"""
|
||||
flow = create_test_flow()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id="test",
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=create_test_cert(),
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", provider=provider)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
state = generate_id()
|
||||
user = create_test_admin_user()
|
||||
self.client.force_login(user)
|
||||
@ -298,7 +343,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
data={
|
||||
"response_type": "id_token",
|
||||
"response_mode": "form_post",
|
||||
"client_id": "test",
|
||||
"client_id": provider.client_id,
|
||||
"state": state,
|
||||
"scope": "openid",
|
||||
"redirect_uri": "http://localhost",
|
||||
@ -314,7 +359,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
"component": "ak-stage-autosubmit",
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"url": "http://localhost",
|
||||
"title": "Redirecting to app...",
|
||||
"title": f"Redirecting to {app.name}...",
|
||||
"attrs": {
|
||||
"access_token": token.access_token,
|
||||
"id_token": provider.encode(token.id_token.to_dict()),
|
||||
@ -325,3 +370,48 @@ class TestAuthorize(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
self.validate_jwt(token, provider)
|
||||
|
||||
def test_full_form_post_code(self):
|
||||
"""Test full authorization (form_post response, code type)"""
|
||||
flow = create_test_flow()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
|
||||
state = generate_id()
|
||||
user = create_test_admin_user()
|
||||
self.client.force_login(user)
|
||||
# Step 1, initiate params and get redirect to flow
|
||||
self.client.get(
|
||||
reverse("authentik_providers_oauth2:authorize"),
|
||||
data={
|
||||
"response_type": "code",
|
||||
"response_mode": "form_post",
|
||||
"client_id": provider.client_id,
|
||||
"state": state,
|
||||
"scope": "openid",
|
||||
"redirect_uri": "http://localhost",
|
||||
},
|
||||
)
|
||||
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": "ak-stage-autosubmit",
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"url": "http://localhost",
|
||||
"title": f"Redirecting to {app.name}...",
|
||||
"attrs": {
|
||||
"code": code.code,
|
||||
"state": state,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
98
authentik/providers/oauth2/tests/test_introspect.py
Normal file
98
authentik/providers/oauth2/tests/test_introspect.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Test introspect view"""
|
||||
import json
|
||||
from base64 import b64encode
|
||||
from dataclasses import asdict
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
class TesOAuth2Introspection(OAuthTestCase):
|
||||
"""Test introspect view"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.app = Application.objects.create(
|
||||
name=generate_id(), slug=generate_id(), provider=self.provider
|
||||
)
|
||||
self.app.save()
|
||||
self.user = create_test_admin_user()
|
||||
self.token: RefreshToken = RefreshToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
access_token=generate_id(),
|
||||
refresh_token=generate_id(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
IDToken("foo", "bar"),
|
||||
)
|
||||
),
|
||||
)
|
||||
self.auth = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
).decode()
|
||||
|
||||
def test_introspect(self):
|
||||
"""Test introspect"""
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-introspection"),
|
||||
HTTP_AUTHORIZATION=f"Basic {self.auth}",
|
||||
data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
{
|
||||
"aud": None,
|
||||
"sub": "bar",
|
||||
"exp": None,
|
||||
"iat": None,
|
||||
"iss": "foo",
|
||||
"active": True,
|
||||
"client_id": self.provider.client_id,
|
||||
},
|
||||
)
|
||||
|
||||
def test_introspect_invalid_token(self):
|
||||
"""Test introspect (invalid token)"""
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-introspection"),
|
||||
HTTP_AUTHORIZATION=f"Basic {self.auth}",
|
||||
data={"token": generate_id(), "token_type_hint": "refresh_token"},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
{
|
||||
"active": False,
|
||||
},
|
||||
)
|
||||
|
||||
def test_introspect_invalid_auth(self):
|
||||
"""Test introspect (invalid auth)"""
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-introspection"),
|
||||
HTTP_AUTHORIZATION="Basic qwerqrwe",
|
||||
data={"token": generate_id(), "token_type_hint": "refresh_token"},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertJSONEqual(
|
||||
res.content.decode(),
|
||||
{
|
||||
"active": False,
|
||||
},
|
||||
)
|
74
authentik/providers/oauth2/tests/test_revoke.py
Normal file
74
authentik/providers/oauth2/tests/test_revoke.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Test revoke view"""
|
||||
import json
|
||||
from base64 import b64encode
|
||||
from dataclasses import asdict
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
class TesOAuth2Revoke(OAuthTestCase):
|
||||
"""Test revoke view"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="",
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
self.app = Application.objects.create(
|
||||
name=generate_id(), slug=generate_id(), provider=self.provider
|
||||
)
|
||||
self.app.save()
|
||||
self.user = create_test_admin_user()
|
||||
self.token: RefreshToken = RefreshToken.objects.create(
|
||||
provider=self.provider,
|
||||
user=self.user,
|
||||
access_token=generate_id(),
|
||||
refresh_token=generate_id(),
|
||||
_scope="openid user profile",
|
||||
_id_token=json.dumps(
|
||||
asdict(
|
||||
IDToken("foo", "bar"),
|
||||
)
|
||||
),
|
||||
)
|
||||
self.auth = b64encode(
|
||||
f"{self.provider.client_id}:{self.provider.client_secret}".encode()
|
||||
).decode()
|
||||
|
||||
def test_revoke(self):
|
||||
"""Test revoke"""
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-revoke"),
|
||||
HTTP_AUTHORIZATION=f"Basic {self.auth}",
|
||||
data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_revoke_invalid(self):
|
||||
"""Test revoke (invalid token)"""
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-revoke"),
|
||||
HTTP_AUTHORIZATION=f"Basic {self.auth}",
|
||||
data={"token": self.token.refresh_token + "foo", "token_type_hint": "refresh_token"},
|
||||
)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_revoke_invalid_auth(self):
|
||||
"""Test revoke (invalid auth)"""
|
||||
res = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token-revoke"),
|
||||
HTTP_AUTHORIZATION="Basic fqewr",
|
||||
data={"token": self.token.refresh_token, "token_type_hint": "refresh_token"},
|
||||
)
|
||||
self.assertEqual(res.status_code, 401)
|
@ -5,7 +5,7 @@ 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_cert, create_test_flow
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.constants import (
|
||||
@ -24,17 +24,17 @@ class TestToken(OAuthTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.factory = RequestFactory()
|
||||
self.app = Application.objects.create(name="test", slug="test")
|
||||
self.app = Application.objects.create(name=generate_id(), slug="test")
|
||||
|
||||
def test_request_auth_code(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=create_test_cert(),
|
||||
redirect_uris="http://TestServer",
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
@ -44,7 +44,7 @@ class TestToken(OAuthTestCase):
|
||||
data={
|
||||
"grant_type": GRANT_TYPE_AUTHORIZATION_CODE,
|
||||
"code": code.code,
|
||||
"redirect_uri": "http://testserver",
|
||||
"redirect_uri": "http://TestServer",
|
||||
},
|
||||
HTTP_AUTHORIZATION=f"Basic {header}",
|
||||
)
|
||||
@ -56,12 +56,12 @@ class TestToken(OAuthTestCase):
|
||||
def test_request_auth_code_invalid(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=create_test_cert(),
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
request = self.factory.post(
|
||||
@ -79,12 +79,12 @@ class TestToken(OAuthTestCase):
|
||||
def test_request_refresh_token(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
signing_key=create_test_cert(),
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
@ -108,12 +108,12 @@ class TestToken(OAuthTestCase):
|
||||
def test_auth_code_view(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
signing_key=create_test_cert(),
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
self.app.provider = provider
|
||||
@ -150,12 +150,12 @@ class TestToken(OAuthTestCase):
|
||||
def test_refresh_token_view(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
signing_key=create_test_cert(),
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
self.app.provider = provider
|
||||
@ -199,12 +199,12 @@ class TestToken(OAuthTestCase):
|
||||
def test_refresh_token_view_invalid_origin(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://local.invalid",
|
||||
signing_key=create_test_cert(),
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
|
||||
user = create_test_admin_user()
|
||||
@ -244,12 +244,12 @@ class TestToken(OAuthTestCase):
|
||||
def test_refresh_token_revoke(self):
|
||||
"""test request param"""
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=create_test_cert(),
|
||||
signing_key=self.keypair,
|
||||
)
|
||||
# Needs to be assigned to an application for iss to be set
|
||||
self.app.provider = provider
|
||||
|
@ -1,203 +0,0 @@
|
||||
"""Test token view"""
|
||||
from datetime import datetime, timedelta
|
||||
from json import loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from jwt import decode
|
||||
|
||||
from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.constants import (
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
|
||||
|
||||
class TestTokenClientCredentialsJWT(OAuthTestCase):
|
||||
"""Test token (client_credentials, with JWT) view"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
ObjectManager().run()
|
||||
self.factory = RequestFactory()
|
||||
self.cert = create_test_cert()
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=self.cert,
|
||||
)
|
||||
self.provider.verification_keys.set([self.cert])
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
|
||||
def test_invalid_type(self):
|
||||
"""test invalid type"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "foo",
|
||||
"client_assertion": "foo.bar",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_jwt(self):
|
||||
"""test invalid JWT"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": "foo.bar",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_signature(self):
|
||||
"""test invalid JWT"""
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token + "foo",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_expired(self):
|
||||
"""test invalid JWT"""
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() - timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_no_app(self):
|
||||
"""test invalid JWT"""
|
||||
self.app.provider = None
|
||||
self.app.save()
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_access_denied(self):
|
||||
"""test invalid JWT"""
|
||||
group = Group.objects.create(name="foo")
|
||||
PolicyBinding.objects.create(
|
||||
group=group,
|
||||
target=self.app,
|
||||
order=0,
|
||||
)
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_successful(self):
|
||||
"""test successful"""
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], "bearer")
|
||||
_, alg = self.provider.get_jwt_key()
|
||||
jwt = decode(
|
||||
body["access_token"],
|
||||
key=self.provider.signing_key.public_key,
|
||||
algorithms=[alg],
|
||||
audience=self.provider.client_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
jwt["given_name"], "Autogenerated user from application test (client credentials JWT)"
|
||||
)
|
||||
self.assertEqual(jwt["preferred_username"], "test-foo")
|
@ -19,9 +19,9 @@ class TestUserinfo(OAuthTestCase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
ObjectManager().run()
|
||||
self.app = Application.objects.create(name="test", slug="test")
|
||||
self.app = Application.objects.create(name=generate_id(), slug=generate_id())
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
name=generate_id(),
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
|
@ -2,12 +2,15 @@
|
||||
from django.test import TestCase
|
||||
from jwt import decode
|
||||
|
||||
from authentik.core.tests.utils import create_test_cert
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.providers.oauth2.models import JWTAlgorithms, OAuth2Provider, RefreshToken
|
||||
|
||||
|
||||
class OAuthTestCase(TestCase):
|
||||
"""OAuth test helpers"""
|
||||
|
||||
keypair: CertificateKeyPair
|
||||
required_jwt_keys = [
|
||||
"exp",
|
||||
"iat",
|
||||
@ -17,6 +20,11 @@ class OAuthTestCase(TestCase):
|
||||
"iss",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.keypair = create_test_cert()
|
||||
super().setUpClass()
|
||||
|
||||
def validate_jwt(self, token: RefreshToken, provider: OAuth2Provider):
|
||||
"""Validate that all required fields are set"""
|
||||
key, alg = provider.get_jwt_key()
|
||||
|
@ -10,6 +10,7 @@ from authentik.providers.oauth2.views.introspection import TokenIntrospectionVie
|
||||
from authentik.providers.oauth2.views.jwks import JWKSView
|
||||
from authentik.providers.oauth2.views.provider import ProviderInfoView
|
||||
from authentik.providers.oauth2.views.token import TokenView
|
||||
from authentik.providers.oauth2.views.token_revoke import TokenRevokeView
|
||||
from authentik.providers.oauth2.views.userinfo import UserInfoView
|
||||
|
||||
urlpatterns = [
|
||||
@ -29,9 +30,14 @@ urlpatterns = [
|
||||
csrf_exempt(TokenIntrospectionView.as_view()),
|
||||
name="token-introspection",
|
||||
),
|
||||
path(
|
||||
"revoke/",
|
||||
csrf_exempt(TokenRevokeView.as_view()),
|
||||
name="token-revoke",
|
||||
),
|
||||
path(
|
||||
"<slug:application_slug>/end-session/",
|
||||
RedirectView.as_view(pattern_name="authentik_core:if-session-end"),
|
||||
RedirectView.as_view(pattern_name="authentik_core:if-session-end", query_string=True),
|
||||
name="end-session",
|
||||
),
|
||||
path("<slug:application_slug>/jwks/", JWKSView.as_view(), name="jwks"),
|
||||
|
@ -10,9 +10,10 @@ from django.http.response import HttpResponseRedirect
|
||||
from django.utils.cache import patch_vary_headers
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.middleware import KEY_USER
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.providers.oauth2.errors import BearerTokenError
|
||||
from authentik.providers.oauth2.models import RefreshToken
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -165,13 +166,30 @@ def protected_resource_view(scopes: list[str]):
|
||||
] = f'error="{error.code}", error_description="{error.description}"'
|
||||
return response
|
||||
kwargs["token"] = token
|
||||
return view(request, *args, **kwargs)
|
||||
response = view(request, *args, **kwargs)
|
||||
setattr(response, "ak_context", {})
|
||||
response.ak_context[KEY_USER] = token.user.username
|
||||
return response
|
||||
|
||||
return view_wrapper
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def authenticate_provider(request: HttpRequest) -> Optional[OAuth2Provider]:
|
||||
"""Attempt to authenticate via Basic auth of client_id:client_secret"""
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
if client_id == client_secret == "":
|
||||
return None
|
||||
provider: Optional[OAuth2Provider] = OAuth2Provider.objects.filter(client_id=client_id).first()
|
||||
if not provider:
|
||||
return None
|
||||
if client_id != provider.client_id or client_secret != provider.client_secret:
|
||||
LOGGER.debug("(basic) Provider for basic auth does not exist")
|
||||
return None
|
||||
return provider
|
||||
|
||||
|
||||
class HttpResponseRedirectScheme(HttpResponseRedirect):
|
||||
"""HTTP Response to redirect, can be to a non-http scheme"""
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from re import error as RegexError
|
||||
from re import escape, fullmatch
|
||||
from re import fullmatch
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
@ -55,6 +55,7 @@ from authentik.providers.oauth2.models import (
|
||||
OAuth2Provider,
|
||||
ResponseMode,
|
||||
ResponseTypes,
|
||||
ScopeMapping,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import HttpResponseRedirectScheme
|
||||
from authentik.providers.oauth2.views.userinfo import UserInfoView
|
||||
@ -181,7 +182,7 @@ class OAuthAuthorizationParams:
|
||||
|
||||
if self.provider.redirect_uris == "":
|
||||
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = escape(self.redirect_uri)
|
||||
self.provider.redirect_uris = self.redirect_uri
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
|
||||
@ -194,14 +195,20 @@ class OAuthAuthorizationParams:
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
"Invalid redirect uri (regex comparison)",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
except RegexError as exc:
|
||||
LOGGER.warning("Invalid regular expression configured", exc=exc)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
|
||||
if not any(x == self.redirect_uri for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (strict comparison)",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
if self.request:
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "request_not_supported", self.grant_type, self.state
|
||||
@ -209,6 +216,16 @@ class OAuthAuthorizationParams:
|
||||
|
||||
def check_scope(self):
|
||||
"""Ensure openid scope is set in Hybrid flows, or when requesting an id_token"""
|
||||
if len(self.scope) == 0:
|
||||
default_scope_names = set(
|
||||
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
|
||||
"scope_name", flat=True
|
||||
)
|
||||
)
|
||||
self.scope = default_scope_names
|
||||
LOGGER.info(
|
||||
"No scopes requested, defaulting to all configured scopes", scopes=self.scope
|
||||
)
|
||||
if SCOPE_OPENID not in self.scope and (
|
||||
self.grant_type == GrantTypes.HYBRID
|
||||
or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
|
||||
@ -234,11 +251,8 @@ class OAuthAuthorizationParams:
|
||||
|
||||
def check_code_challenge(self):
|
||||
"""PKCE validation of the transformation method."""
|
||||
if self.code_challenge:
|
||||
if not (self.code_challenge_method in ["plain", "S256"]):
|
||||
raise AuthorizeError(
|
||||
self.redirect_uri, "invalid_request", self.grant_type, self.state
|
||||
)
|
||||
if self.code_challenge and self.code_challenge_method not in ["plain", "S256"]:
|
||||
raise AuthorizeError(self.redirect_uri, "invalid_request", self.grant_type, self.state)
|
||||
|
||||
def create_code(self, request: HttpRequest) -> AuthorizationCode:
|
||||
"""Create an AuthorizationCode object for the request"""
|
||||
@ -459,7 +473,6 @@ class OAuthFulfillmentStage(StageView):
|
||||
def create_response_uri(self) -> str:
|
||||
"""Create a final Response URI the user is redirected to."""
|
||||
uri = urlsplit(self.params.redirect_uri)
|
||||
query_params = parse_qs(uri.query)
|
||||
|
||||
try:
|
||||
code = None
|
||||
@ -472,6 +485,7 @@ class OAuthFulfillmentStage(StageView):
|
||||
code.save(force_insert=True)
|
||||
|
||||
if self.params.response_mode == ResponseMode.QUERY:
|
||||
query_params = parse_qs(uri.query)
|
||||
query_params["code"] = code.code
|
||||
query_params["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
|
||||
@ -488,7 +502,12 @@ class OAuthFulfillmentStage(StageView):
|
||||
return urlunsplit(uri)
|
||||
|
||||
if self.params.response_mode == ResponseMode.FORM_POST:
|
||||
post_params = self.create_implicit_response(code)
|
||||
post_params = {}
|
||||
if self.params.grant_type in [GrantTypes.AUTHORIZATION_CODE]:
|
||||
post_params["code"] = code.code
|
||||
post_params["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
else:
|
||||
post_params = self.create_implicit_response(code)
|
||||
|
||||
uri = uri._replace(query=urlencode(post_params, doseq=True))
|
||||
|
||||
|
@ -7,11 +7,7 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.providers.oauth2.errors import TokenIntrospectionError
|
||||
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import (
|
||||
TokenResponse,
|
||||
extract_access_token,
|
||||
extract_client_auth,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -21,8 +17,8 @@ class TokenIntrospectionParams:
|
||||
"""Parameters for Token Introspection"""
|
||||
|
||||
token: RefreshToken
|
||||
provider: OAuth2Provider
|
||||
|
||||
provider: OAuth2Provider = field(init=False)
|
||||
id_token: IDToken = field(init=False)
|
||||
|
||||
def __post_init__(self):
|
||||
@ -30,7 +26,6 @@ class TokenIntrospectionParams:
|
||||
LOGGER.debug("Token is not valid")
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
self.provider = self.token.provider
|
||||
self.id_token = self.token.id_token
|
||||
|
||||
if not self.token.id_token:
|
||||
@ -40,30 +35,6 @@ class TokenIntrospectionParams:
|
||||
)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
def authenticate_basic(self, request: HttpRequest) -> bool:
|
||||
"""Attempt to authenticate via Basic auth of client_id:client_secret"""
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
if client_id == client_secret == "":
|
||||
return False
|
||||
if client_id != self.provider.client_id or client_secret != self.provider.client_secret:
|
||||
LOGGER.debug("(basic) Provider for basic auth does not exist")
|
||||
raise TokenIntrospectionError()
|
||||
return True
|
||||
|
||||
def authenticate_bearer(self, request: HttpRequest) -> bool:
|
||||
"""Attempt to authenticate via token sent as bearer header"""
|
||||
body_token = extract_access_token(request)
|
||||
if not body_token:
|
||||
return False
|
||||
tokens = RefreshToken.objects.filter(access_token=body_token).select_related("provider")
|
||||
if not tokens.exists():
|
||||
LOGGER.debug("(bearer) Token does not exist")
|
||||
raise TokenIntrospectionError()
|
||||
if tokens.first().provider != self.provider:
|
||||
LOGGER.debug("(bearer) Token providers don't match")
|
||||
raise TokenIntrospectionError()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: HttpRequest) -> "TokenIntrospectionParams":
|
||||
"""Extract required Parameters from HTTP Request"""
|
||||
@ -75,19 +46,17 @@ class TokenIntrospectionParams:
|
||||
LOGGER.debug("token_type_hint has invalid value", value=token_type_hint)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
provider = authenticate_provider(request)
|
||||
if not provider:
|
||||
raise TokenIntrospectionError
|
||||
|
||||
try:
|
||||
token: RefreshToken = RefreshToken.objects.select_related("provider").get(
|
||||
**token_filter
|
||||
)
|
||||
token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter)
|
||||
except RefreshToken.DoesNotExist:
|
||||
LOGGER.debug("Token does not exist", token=raw_token)
|
||||
raise TokenIntrospectionError()
|
||||
|
||||
params = TokenIntrospectionParams(token=token)
|
||||
if not any([params.authenticate_basic(request), params.authenticate_bearer(request)]):
|
||||
LOGGER.warning("Not authenticated")
|
||||
raise TokenIntrospectionError()
|
||||
return params
|
||||
return TokenIntrospectionParams(token=token, provider=provider)
|
||||
|
||||
|
||||
class TokenIntrospectionView(View):
|
||||
|
@ -58,6 +58,9 @@ class ProviderInfoView(View):
|
||||
"introspection_endpoint": self.request.build_absolute_uri(
|
||||
reverse("authentik_providers_oauth2:token-introspection")
|
||||
),
|
||||
"revocation_endpoint": self.request.build_absolute_uri(
|
||||
reverse("authentik_providers_oauth2:token-revoke")
|
||||
),
|
||||
"response_types_supported": [
|
||||
ResponseTypes.CODE,
|
||||
ResponseTypes.ID_TOKEN,
|
||||
|
@ -21,7 +21,6 @@ from authentik.core.models import (
|
||||
TokenIntents,
|
||||
User,
|
||||
)
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.utils.time import timedelta_from_string
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
@ -38,7 +37,6 @@ from authentik.providers.oauth2.errors import TokenError, UserAuthError
|
||||
from authentik.providers.oauth2.models import (
|
||||
AuthorizationCode,
|
||||
ClientTypes,
|
||||
JWTAlgorithms,
|
||||
OAuth2Provider,
|
||||
RefreshToken,
|
||||
)
|
||||
@ -89,7 +87,7 @@ class TokenParams:
|
||||
provider=provider,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=request.POST.get("redirect_uri", "").lower(),
|
||||
redirect_uri=request.POST.get("redirect_uri", ""),
|
||||
grant_type=request.POST.get("grant_type", ""),
|
||||
state=request.POST.get("state", ""),
|
||||
scope=request.POST.get("scope", "").split(),
|
||||
@ -154,7 +152,7 @@ class TokenParams:
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
"Invalid redirect uri (regex comparison)",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
@ -167,13 +165,19 @@ class TokenParams:
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
except RegexError as exc:
|
||||
LOGGER.warning("Invalid regular expression configured", exc=exc)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect_uri RegEx configured",
|
||||
provider=self.provider,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
LOGGER.info("Failed to parse regular expression, checking directly", exc=exc)
|
||||
if not any(x == self.redirect_uri for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri (strict comparison)",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect_uri configured",
|
||||
provider=self.provider,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
try:
|
||||
self.authorization_code = AuthorizationCode.objects.get(code=raw_code)
|
||||
@ -286,26 +290,6 @@ class TokenParams:
|
||||
|
||||
token = None
|
||||
|
||||
# TODO: Remove in 2022.7, deprecated field `verification_keys``
|
||||
for cert in self.provider.verification_keys.all():
|
||||
LOGGER.debug("verifying jwt with key", key=cert.name)
|
||||
cert: CertificateKeyPair
|
||||
public_key = cert.certificate.public_key()
|
||||
if cert.private_key:
|
||||
public_key = cert.private_key.public_key()
|
||||
try:
|
||||
token = decode(
|
||||
assertion,
|
||||
public_key,
|
||||
algorithms=[JWTAlgorithms.RS256, JWTAlgorithms.ES256],
|
||||
options={
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
except (PyJWTError, ValueError, TypeError) as exc:
|
||||
LOGGER.warning("failed to validate jwt", exc=exc)
|
||||
# TODO: End remove block
|
||||
|
||||
source: Optional[OAuthSource] = None
|
||||
parsed_key: Optional[PyJWK] = None
|
||||
for source in self.provider.jwks_sources.all():
|
||||
@ -345,7 +329,7 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
self.__check_policy_access(app, request, oauth_jwt=token)
|
||||
self.__create_user_from_jwt(token, app)
|
||||
self.__create_user_from_jwt(token, app, source)
|
||||
|
||||
method_args = {
|
||||
"jwt": token,
|
||||
@ -361,7 +345,7 @@ class TokenParams:
|
||||
PLAN_CONTEXT_APPLICATION=app,
|
||||
).from_http(request, user=self.user)
|
||||
|
||||
def __create_user_from_jwt(self, token: dict[str, Any], app: Application):
|
||||
def __create_user_from_jwt(self, token: dict[str, Any], app: Application, source: OAuthSource):
|
||||
"""Create user from JWT"""
|
||||
exp = token.get("exp")
|
||||
self.user, created = User.objects.update_or_create(
|
||||
@ -372,6 +356,7 @@ class TokenParams:
|
||||
},
|
||||
"last_login": now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials JWT)",
|
||||
"path": source.get_user_path(),
|
||||
},
|
||||
)
|
||||
if created and exp:
|
||||
|
66
authentik/providers/oauth2/views/token_revoke.py
Normal file
66
authentik/providers/oauth2/views/token_revoke.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Token revocation endpoint"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.views import View
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.providers.oauth2.errors import TokenRevocationError
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
|
||||
from authentik.providers.oauth2.utils import TokenResponse, authenticate_provider
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenRevocationParams:
|
||||
"""Parameters for Token Revocation"""
|
||||
|
||||
token: RefreshToken
|
||||
provider: OAuth2Provider
|
||||
|
||||
@staticmethod
|
||||
def from_request(request: HttpRequest) -> "TokenRevocationParams":
|
||||
"""Extract required Parameters from HTTP Request"""
|
||||
raw_token = request.POST.get("token")
|
||||
token_type_hint = request.POST.get("token_type_hint", "access_token")
|
||||
token_filter = {token_type_hint: raw_token}
|
||||
|
||||
if token_type_hint not in ["access_token", "refresh_token"]:
|
||||
LOGGER.debug("token_type_hint has invalid value", value=token_type_hint)
|
||||
raise TokenRevocationError("unsupported_token_type")
|
||||
|
||||
provider = authenticate_provider(request)
|
||||
if not provider:
|
||||
raise TokenRevocationError("invalid_client")
|
||||
|
||||
try:
|
||||
token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter)
|
||||
except RefreshToken.DoesNotExist:
|
||||
LOGGER.debug("Token does not exist", token=raw_token)
|
||||
raise Http404
|
||||
|
||||
return TokenRevocationParams(token=token, provider=provider)
|
||||
|
||||
|
||||
class TokenRevokeView(View):
|
||||
"""Token revoke endpoint
|
||||
https://datatracker.ietf.org/doc/html/rfc7009"""
|
||||
|
||||
token: RefreshToken
|
||||
params: TokenRevocationParams
|
||||
provider: OAuth2Provider
|
||||
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Revocation handler"""
|
||||
try:
|
||||
self.params = TokenRevocationParams.from_request(request)
|
||||
|
||||
self.params.token.delete()
|
||||
|
||||
return TokenResponse(data={}, status=200)
|
||||
except TokenRevocationError as exc:
|
||||
return TokenResponse(exc.create_dict(), status=401)
|
||||
except Http404:
|
||||
# Token not found should return a HTTP 200 according to the specs
|
||||
return TokenResponse(data={}, status=200)
|
@ -4,12 +4,15 @@ from typing import Any, Optional
|
||||
from deepmerge import always_merger
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views import View
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.challenge import PermissionDict
|
||||
from authentik.providers.oauth2.constants import (
|
||||
SCOPE_AUTHENTIK_API,
|
||||
SCOPE_GITHUB_ORG_READ,
|
||||
SCOPE_GITHUB_USER,
|
||||
SCOPE_GITHUB_USER_EMAIL,
|
||||
@ -27,23 +30,27 @@ class UserInfoView(View):
|
||||
|
||||
token: Optional[RefreshToken]
|
||||
|
||||
def get_scope_descriptions(self, scopes: list[str]) -> list[dict[str, str]]:
|
||||
def get_scope_descriptions(self, scopes: list[str]) -> list[PermissionDict]:
|
||||
"""Get a list of all Scopes's descriptions"""
|
||||
scope_descriptions = []
|
||||
for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by("scope_name"):
|
||||
if scope.description != "":
|
||||
scope_descriptions.append({"id": scope.scope_name, "name": scope.description})
|
||||
if scope.description == "":
|
||||
continue
|
||||
scope_descriptions.append(PermissionDict(id=scope.scope_name, name=scope.description))
|
||||
# GitHub Compatibility Scopes are handled differently, since they required custom paths
|
||||
# Hence they don't exist as Scope objects
|
||||
github_scope_map = {
|
||||
SCOPE_GITHUB_USER: ("GitHub Compatibility: Access your User Information"),
|
||||
SCOPE_GITHUB_USER_READ: ("GitHub Compatibility: Access your User Information"),
|
||||
SCOPE_GITHUB_USER_EMAIL: ("GitHub Compatibility: Access you Email addresses"),
|
||||
SCOPE_GITHUB_ORG_READ: ("GitHub Compatibility: Access your Groups"),
|
||||
special_scope_map = {
|
||||
SCOPE_GITHUB_USER: _("GitHub Compatibility: Access your User Information"),
|
||||
SCOPE_GITHUB_USER_READ: _("GitHub Compatibility: Access your User Information"),
|
||||
SCOPE_GITHUB_USER_EMAIL: _("GitHub Compatibility: Access you Email addresses"),
|
||||
SCOPE_GITHUB_ORG_READ: _("GitHub Compatibility: Access your Groups"),
|
||||
SCOPE_AUTHENTIK_API: _("authentik API Access on behalf of your user"),
|
||||
}
|
||||
for scope in scopes:
|
||||
if scope in github_scope_map:
|
||||
scope_descriptions.append({"id": scope, "name": github_scope_map[scope]})
|
||||
if scope in special_scope_map:
|
||||
scope_descriptions.append(
|
||||
PermissionDict(id=scope, name=str(special_scope_map[scope]))
|
||||
)
|
||||
return scope_descriptions
|
||||
|
||||
def get_claims(self, token: RefreshToken) -> dict[str, Any]:
|
||||
|
@ -11,11 +11,6 @@ from rest_framework.serializers import Serializer
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.lib.models import DomainlessURLValidator
|
||||
from authentik.outposts.models import OutpostModel
|
||||
from authentik.providers.oauth2.constants import (
|
||||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import ClientTypes, OAuth2Provider, ScopeMapping
|
||||
|
||||
SCOPE_AK_PROXY = "ak_proxy"
|
||||
@ -125,11 +120,11 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
||||
self.client_type = ClientTypes.CONFIDENTIAL
|
||||
self.signing_key = None
|
||||
scopes = ScopeMapping.objects.filter(
|
||||
scope_name__in=[
|
||||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_AK_PROXY,
|
||||
managed__in=[
|
||||
"goauthentik.io/providers/oauth2/scope-openid",
|
||||
"goauthentik.io/providers/oauth2/scope-profile",
|
||||
"goauthentik.io/providers/oauth2/scope-email",
|
||||
"goauthentik.io/providers/proxy/scope-proxy",
|
||||
]
|
||||
)
|
||||
self.property_mappings.add(*list(scopes))
|
||||
|
@ -2,6 +2,7 @@
|
||||
from xml.etree.ElementTree import ParseError # nosec
|
||||
|
||||
from defusedxml.ElementTree import fromstring
|
||||
from django.http import HttpRequest
|
||||
from django.http.response import Http404, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.urls import reverse
|
||||
@ -44,14 +45,58 @@ LOGGER = get_logger()
|
||||
class SAMLProviderSerializer(ProviderSerializer):
|
||||
"""SAMLProvider Serializer"""
|
||||
|
||||
metadata_download_url = SerializerMethodField()
|
||||
url_download_metadata = SerializerMethodField()
|
||||
|
||||
def get_metadata_download_url(self, instance: SAMLProvider) -> str:
|
||||
url_sso_post = SerializerMethodField()
|
||||
url_sso_redirect = SerializerMethodField()
|
||||
url_sso_init = SerializerMethodField()
|
||||
|
||||
def get_url_download_metadata(self, instance: SAMLProvider) -> str:
|
||||
"""Get metadata download URL"""
|
||||
return (
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
return request.build_absolute_uri(
|
||||
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": instance.pk}) + "?download"
|
||||
)
|
||||
|
||||
def get_url_sso_post(self, instance: SAMLProvider) -> str:
|
||||
"""Get SSO Post URL"""
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:sso-post",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||
return "-"
|
||||
|
||||
def get_url_sso_redirect(self, instance: SAMLProvider) -> str:
|
||||
"""Get SSO Redirect URL"""
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:sso-redirect",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||
return "-"
|
||||
|
||||
def get_url_sso_init(self, instance: SAMLProvider) -> str:
|
||||
"""Get SSO IDP-Initiated URL"""
|
||||
request: HttpRequest = self._context["request"]._request
|
||||
try:
|
||||
return request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_saml:sso-init",
|
||||
kwargs={"application_slug": instance.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||
return "-"
|
||||
|
||||
class Meta:
|
||||
|
||||
model = SAMLProvider
|
||||
@ -69,7 +114,10 @@ class SAMLProviderSerializer(ProviderSerializer):
|
||||
"signing_kp",
|
||||
"verification_kp",
|
||||
"sp_binding",
|
||||
"metadata_download_url",
|
||||
"url_download_metadata",
|
||||
"url_sso_post",
|
||||
"url_sso_redirect",
|
||||
"url_sso_init",
|
||||
]
|
||||
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""authentik core celery"""
|
||||
import os
|
||||
from logging.config import dictConfig
|
||||
from typing import Callable
|
||||
|
||||
from celery import Celery
|
||||
from celery.signals import (
|
||||
@ -76,23 +77,28 @@ def task_error_hook(task_id, exception: Exception, traceback, *args, **kwargs):
|
||||
Event.new(EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception)).save()
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def worker_ready_hook(*args, **kwargs):
|
||||
"""Run certain tasks on worker start"""
|
||||
def _get_startup_tasks() -> list[Callable]:
|
||||
"""Get all tasks to be run on startup"""
|
||||
from authentik.admin.tasks import clear_update_notifications
|
||||
from authentik.managed.tasks import managed_reconcile
|
||||
from authentik.outposts.tasks import outpost_controller_all, outpost_local_connection
|
||||
from authentik.providers.proxy.tasks import proxy_set_defaults
|
||||
|
||||
tasks = [
|
||||
return [
|
||||
clear_update_notifications,
|
||||
outpost_local_connection,
|
||||
outpost_controller_all,
|
||||
proxy_set_defaults,
|
||||
managed_reconcile,
|
||||
]
|
||||
|
||||
|
||||
@worker_ready.connect
|
||||
def worker_ready_hook(*args, **kwargs):
|
||||
"""Run certain tasks on worker start"""
|
||||
|
||||
LOGGER.info("Dispatching startup tasks...")
|
||||
for task in tasks:
|
||||
for task in _get_startup_tasks():
|
||||
try:
|
||||
task.delay()
|
||||
except ProgrammingError as exc:
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user