Compare commits
282 Commits
version/20
...
version-20
Author | SHA1 | Date | |
---|---|---|---|
1883402b3d | |||
88a8b7d2fa | |||
987f03c4be | |||
1b3aacfa1d | |||
3a994ab2a4 | |||
d7713357f4 | |||
e7c03fdb14 | |||
6105956847 | |||
89028f175a | |||
f121098957 | |||
4ff32af343 | |||
972868c15c | |||
0bc57f571b | |||
9de5b6f93e | |||
cc744dc581 | |||
47006fc9d2 | |||
a03e48c5ce | |||
816b0c7d83 | |||
0edf4296c4 | |||
b8fdda50ec | |||
ab1840dd66 | |||
482491e93c | |||
2ca991ba3d | |||
b20c384f5a | |||
9ce8edbcd6 | |||
cb5b2148a3 | |||
d5702c6282 | |||
61a876b582 | |||
8c9748e4a0 | |||
6460245d5e | |||
b7979ad48e | |||
cbd95848e7 | |||
4704de937a | |||
394d8e99a4 | |||
a26f25ccd6 | |||
94257e0f50 | |||
b2a42a68a4 | |||
7895d59da3 | |||
b54c60d7af | |||
6bab3bf68e | |||
fdc09c658a | |||
a690a02f99 | |||
0e912fd647 | |||
27af330932 | |||
7187d28905 | |||
ca832b6090 | |||
53bd6bf06e | |||
813f271bdd | |||
63dc8fe7dc | |||
383f4e4dcf | |||
2896652fef | |||
cfe2648b62 | |||
8d49705c87 | |||
c99e6d8f2c | |||
0996bb500c | |||
3d4a45c93f | |||
0642af0b78 | |||
dce623dd7c | |||
646d174dd2 | |||
b8fdb82adc | |||
75d6cd1674 | |||
5c91658484 | |||
ebb44c992b | |||
233bb35ebe | |||
f60d0b9753 | |||
7e95c756b9 | |||
be26b92927 | |||
dd3ed1bfb9 | |||
6f56a61a64 | |||
2dee8034d3 | |||
d9d42020cc | |||
90298a2b6c | |||
7c17e7d52f | |||
fbb3ca98c1 | |||
220d21c3e0 | |||
84e74bc21e | |||
ec15060c84 | |||
334898ae23 | |||
b43df2ae27 | |||
a52638d898 | |||
5bc893b890 | |||
fe5d9e4cd2 | |||
a7442e0043 | |||
8103bbf9af | |||
056b90b590 | |||
70221e3d14 | |||
d570feffac | |||
3d52266773 | |||
7bdecd2ee6 | |||
a500ff28ac | |||
263bcae050 | |||
8691a79204 | |||
3b0b6dcf29 | |||
11f7935155 | |||
450a26d1b5 | |||
3e42c1bad4 | |||
5abbb7657b | |||
75b0fb3393 | |||
538c2ca4d3 | |||
5080840ed9 | |||
eded9bfb2d | |||
b3a43ae37c | |||
dc78746825 | |||
3c6828cbba | |||
26646264dc | |||
f7ecfdd4b6 | |||
967c80069b | |||
f8b0c071b7 | |||
221ab47410 | |||
ffe162214f | |||
ad9d8d26ed | |||
35402ada17 | |||
086a44bdbd | |||
6494a0352f | |||
ca1fb737a8 | |||
9e91a0a85d | |||
4e68fe2fea | |||
a36eab81eb | |||
215b2a3224 | |||
4c3f8e446f | |||
4b9922e5b1 | |||
6324521424 | |||
d6b18f2833 | |||
333e58ce2f | |||
699d3ca067 | |||
296779ddf1 | |||
8669f498f1 | |||
4de2ac3248 | |||
eb4dce91c3 | |||
c64a99345b | |||
2e174a1be5 | |||
11ef500475 | |||
d4fd6153c8 | |||
85b6bfbe5f | |||
5ddd138c97 | |||
5644d5f3f7 | |||
be06adcb59 | |||
4da350ebfc | |||
f391c33bdf | |||
18f450bd49 | |||
ee36b7f3eb | |||
f56d619243 | |||
a9a62bbfc8 | |||
ddd785898b | |||
8ba45a5f6a | |||
7d41e6227b | |||
1363226697 | |||
25910bb577 | |||
62e54a3a51 | |||
5f5b4c962b | |||
4a9a19eacb | |||
d4abf5621e | |||
1cb71b5217 | |||
a884f23855 | |||
421b003218 | |||
25a4310bb1 | |||
e897307548 | |||
0fd959c5c0 | |||
ce7d18798f | |||
be3b034cb8 | |||
9f674442d3 | |||
c21793943d | |||
ec67b60219 | |||
2fe553785e | |||
fd1d38f844 | |||
4d755dc0f6 | |||
30c65f9e61 | |||
3554406aa5 | |||
5eeaac1ad9 | |||
5a172abdb9 | |||
8f861d8ecb | |||
f9fdcd2d07 | |||
ed58f21a21 | |||
45af8eb4be | |||
88573105a0 | |||
f9469e3f99 | |||
26d92d9259 | |||
9cb0d37d51 | |||
5a25e1524a | |||
9e1a518689 | |||
cf5771dad3 | |||
db5aafed36 | |||
4b0324220a | |||
0183d2c880 | |||
c1fe18a261 | |||
ab2299ba1e | |||
2678b381b9 | |||
d3ef7920cb | |||
860269acf0 | |||
d2bd177b8f | |||
32cc03832a | |||
948d2cbdca | |||
22026f0755 | |||
a7a7b5aacb | |||
03d5b9e7e9 | |||
30c7e6c94c | |||
1ba96586f7 | |||
607f632515 | |||
58b46fbfcd | |||
9b53e26ab0 | |||
832d3175aa | |||
ebea8369d6 | |||
a8508aac99 | |||
59df02b3b8 | |||
f00657f217 | |||
110bc762a1 | |||
f35e5f79aa | |||
3f32109706 | |||
0f042f2e4a | |||
34d1eb140b | |||
62f67aabe3 | |||
82c3eaa0f9 | |||
31ede2ae1d | |||
54c672256f | |||
5f47d46b6f | |||
3f23bc0b85 | |||
366142382b | |||
ddbe0aaf13 | |||
75320bf579 | |||
15d8988569 | |||
84930b4924 | |||
1ede972222 | |||
cd1d1b4402 | |||
79caba45cc | |||
c101357051 | |||
9bebb82bbf | |||
d95d2ca7fe | |||
c0a883f76f | |||
eb6cfd22a7 | |||
254249e38b | |||
da28bb7d3c | |||
391c1ff911 | |||
1d475d0982 | |||
f92fa61101 | |||
ccca397a77 | |||
162fd26f32 | |||
1d7a235766 | |||
01a8deb77f | |||
cba770a551 | |||
c67afc4084 | |||
4ed30fa61e | |||
db16a0ffbe | |||
99ec355710 | |||
9e1882cebd | |||
80912cace0 | |||
0882894dc3 | |||
c1582147d7 | |||
ab8b37a899 | |||
9077eff34d | |||
2399fa456b | |||
c8c69a9a56 | |||
1258f3bba2 | |||
5488120e84 | |||
0b4ac54363 | |||
1a1434bfda | |||
1328c3e62c | |||
1800b62cd6 | |||
32fa4c9fcb | |||
15f0045a00 | |||
ac2211d9da | |||
cbd5b0dbfd | |||
8e4896d261 | |||
9481df619a | |||
d283a5236c | |||
6add88654e | |||
e4486b98fc | |||
778065f468 | |||
70794d79dd | |||
6e5ac4bffc | |||
4bab42fb58 | |||
c97823fe49 | |||
a3bb5d89cc | |||
f4f9f525d7 | |||
555525ea9d | |||
e455e20312 | |||
4c14e88a25 | |||
7561ea15de | |||
8242b09394 | |||
6f0fa731c0 | |||
576bb013ed | |||
aefedfb836 | |||
4295ddb671 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2022.4.1
|
||||
current_version = 2022.5.3
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
||||
|
3
.github/stale.yml
vendored
3
.github/stale.yml
vendored
@ -8,6 +8,9 @@ exemptLabels:
|
||||
- security
|
||||
- pr_wanted
|
||||
- enhancement
|
||||
- bug/confirmed
|
||||
- enhancement/confirmed
|
||||
- question
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
|
16
.github/workflows/ci-main.yml
vendored
16
.github/workflows/ci-main.yml
vendored
@ -136,9 +136,9 @@ jobs:
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
working-directory: web
|
||||
run: |
|
||||
cd web
|
||||
npm i
|
||||
npm ci
|
||||
npm run build
|
||||
- name: run e2e
|
||||
run: |
|
||||
@ -169,9 +169,9 @@ jobs:
|
||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
||||
- name: prepare web ui
|
||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||
working-directory: web/
|
||||
run: |
|
||||
cd web
|
||||
npm i
|
||||
npm ci
|
||||
npm run build
|
||||
- name: run e2e
|
||||
run: |
|
||||
@ -207,23 +207,23 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: prepare variables
|
||||
id: ev
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
uses: ./.github/actions/docker-setup
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Building Docker Image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
tags: |
|
||||
|
32
.github/workflows/ci-outpost.yml
vendored
32
.github/workflows/ci-outpost.yml
vendored
@ -18,18 +18,16 @@ jobs:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- name: Run linter
|
||||
- name: Prepare and generate API
|
||||
run: |
|
||||
# Create folder structure for go embeds
|
||||
mkdir -p web/dist
|
||||
mkdir -p website/help
|
||||
touch web/dist/test website/help/test
|
||||
docker run \
|
||||
--rm \
|
||||
-v $(pwd):/app \
|
||||
-w /app \
|
||||
golangci/golangci-lint:v1.43 \
|
||||
golangci-lint run -v --timeout 200s
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
test-unittest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@ -37,6 +35,8 @@ jobs:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
- name: Go unittests
|
||||
run: |
|
||||
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./...
|
||||
@ -63,23 +63,25 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: prepare variables
|
||||
id: ev
|
||||
uses: ./.github/actions/docker-setup
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
- name: Login to Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
- name: Building Docker Image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||
tags: |
|
||||
@ -108,15 +110,17 @@ jobs:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Generate API
|
||||
run: make gen-client-go
|
||||
- name: Build web
|
||||
working-directory: web/
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
npm ci
|
||||
npm run build-proxy
|
||||
- name: Build outpost
|
||||
run: |
|
||||
|
56
.github/workflows/ci-web.yml
vendored
56
.github/workflows/ci-web.yml
vendored
@ -15,56 +15,50 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- run: |
|
||||
cd web
|
||||
npm install
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
- name: Generate API
|
||||
run: make gen-web
|
||||
run: make gen-client-web
|
||||
- name: Eslint
|
||||
run: |
|
||||
cd web
|
||||
npm run lint
|
||||
working-directory: web/
|
||||
run: npm run lint
|
||||
lint-prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- run: |
|
||||
cd web
|
||||
npm install
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
- name: Generate API
|
||||
run: make gen-web
|
||||
run: make gen-client-web
|
||||
- name: prettier
|
||||
run: |
|
||||
cd web
|
||||
npm run prettier-check
|
||||
working-directory: web/
|
||||
run: npm run prettier-check
|
||||
lint-lit-analyse:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- run: |
|
||||
cd web
|
||||
npm install
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
- name: Generate API
|
||||
run: make gen-web
|
||||
run: make gen-client-web
|
||||
- name: lit-analyse
|
||||
run: |
|
||||
cd web
|
||||
npm run lit-analyse
|
||||
working-directory: web/
|
||||
run: npm run lit-analyse
|
||||
ci-web-mark:
|
||||
needs:
|
||||
- lint-eslint
|
||||
@ -79,17 +73,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- run: |
|
||||
cd web
|
||||
npm install
|
||||
- working-directory: web/
|
||||
run: npm ci
|
||||
- name: Generate API
|
||||
run: make gen-web
|
||||
run: make gen-client-web
|
||||
- name: build
|
||||
run: |
|
||||
cd web
|
||||
npm run build
|
||||
working-directory: web/
|
||||
run: npm run build
|
||||
|
33
.github/workflows/ci-website.yml
vendored
Normal file
33
.github/workflows/ci-website.yml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: authentik-ci-website
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- next
|
||||
- version-*
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
lint-prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: website/package-lock.json
|
||||
- working-directory: website/
|
||||
run: npm ci
|
||||
- name: prettier
|
||||
working-directory: website/
|
||||
run: npm run prettier-check
|
||||
ci-website-mark:
|
||||
needs:
|
||||
- lint-prettier
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo mark
|
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@ -43,7 +43,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@ -57,4 +57,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
36
.github/workflows/release-publish.yml
vendored
36
.github/workflows/release-publish.yml
vendored
@ -11,28 +11,28 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Docker Login Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Building Docker Image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik:2022.4.1,
|
||||
beryju/authentik:2022.5.3,
|
||||
beryju/authentik:latest,
|
||||
ghcr.io/goauthentik/server:2022.4.1,
|
||||
ghcr.io/goauthentik/server:2022.5.3,
|
||||
ghcr.io/goauthentik/server:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@ -50,28 +50,28 @@ jobs:
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1.2.0
|
||||
uses: docker/setup-qemu-action@v2.0.0
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Docker Login Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Building Docker Image
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik-${{ matrix.type }}:2022.4.1,
|
||||
beryju/authentik-${{ matrix.type }}:2022.5.3,
|
||||
beryju/authentik-${{ matrix.type }}:latest,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.4.1,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.5.3,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@ -91,15 +91,15 @@ jobs:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: Build web
|
||||
working-directory: web/
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
npm ci
|
||||
npm run build-proxy
|
||||
- name: Build outpost
|
||||
run: |
|
||||
@ -152,7 +152,7 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
version: authentik@2022.4.1
|
||||
version: authentik@2022.5.3
|
||||
environment: beryjuorg-prod
|
||||
sourcemaps: './web/dist'
|
||||
url_prefix: '~/static/dist'
|
||||
|
13
.github/workflows/web-api-publish.yml
vendored
13
.github/workflows/web-api-publish.yml
vendored
@ -4,29 +4,30 @@ on:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- 'schema.yml'
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v3.1.1
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
with:
|
||||
node-version: '16'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Generate API Client
|
||||
run: make gen-web
|
||||
run: make gen-client-web
|
||||
- name: Publish package
|
||||
working-directory: gen-ts-api/
|
||||
run: |
|
||||
cd web-api/
|
||||
npm i
|
||||
npm ci
|
||||
npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
||||
- name: Upgrade /web
|
||||
working-directory: web/
|
||||
run: |
|
||||
cd web/
|
||||
export VERSION=`node -e 'console.log(require("../web-api/package.json").version)'`
|
||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||
npm i @goauthentik/api@$VERSION
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -202,5 +202,4 @@ media/
|
||||
*mmdb
|
||||
|
||||
.idea/
|
||||
/api/
|
||||
/web-api/
|
||||
/gen-*/
|
||||
|
48
Dockerfile
48
Dockerfile
@ -1,22 +1,35 @@
|
||||
# Stage 1: Build website
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:16 as website-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:18 as website-builder
|
||||
|
||||
COPY ./website /work/website/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN cd /work/website && npm i && npm run build-docs-only
|
||||
WORKDIR /work/website
|
||||
RUN npm ci && npm run build-docs-only
|
||||
|
||||
# Stage 2: Build webui
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:16 as web-builder
|
||||
FROM --platform=${BUILDPLATFORM} docker.io/node:18 as web-builder
|
||||
|
||||
COPY ./web /work/web/
|
||||
COPY ./website /work/website/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
RUN cd /work/web && npm i && npm run build
|
||||
WORKDIR /work/web
|
||||
RUN npm ci && npm run build
|
||||
|
||||
# Stage 3: Build go proxy
|
||||
FROM docker.io/golang:1.18.0-bullseye AS builder
|
||||
# Stage 3: Poetry to requirements.txt export
|
||||
FROM docker.io/python:3.10.4-slim-bullseye AS poetry-locker
|
||||
|
||||
WORKDIR /work
|
||||
COPY ./pyproject.toml /work
|
||||
COPY ./poetry.lock /work
|
||||
|
||||
RUN pip install --no-cache-dir poetry && \
|
||||
poetry export -f requirements.txt --output requirements.txt && \
|
||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||
|
||||
# Stage 4: Build go proxy
|
||||
FROM docker.io/golang:1.18.2-bullseye AS builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
@ -31,7 +44,7 @@ COPY ./go.sum /work/go.sum
|
||||
|
||||
RUN go build -o /work/authentik ./cmd/server/main.go
|
||||
|
||||
# Stage 4: Run
|
||||
# Stage 5: Run
|
||||
FROM docker.io/python:3.10.4-slim-bullseye
|
||||
|
||||
LABEL org.opencontainers.image.url https://goauthentik.io
|
||||
@ -43,19 +56,18 @@ WORKDIR /
|
||||
ARG GIT_BUILD_HASH
|
||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||
|
||||
COPY ./pyproject.toml /
|
||||
COPY ./poetry.lock /
|
||||
COPY --from=poetry-locker /work/requirements.txt /
|
||||
COPY --from=poetry-locker /work/requirements-dev.txt /
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates gnupg git runit libpq-dev \
|
||||
postgresql-client build-essential libxmlsec1-dev \
|
||||
pkg-config libmaxminddb0 && \
|
||||
pip install poetry && \
|
||||
poetry config virtualenvs.create false && \
|
||||
poetry install --no-dev && \
|
||||
rm -rf ~/.cache/pypoetry && \
|
||||
apt-get remove --purge -y build-essential git && \
|
||||
# Required for installing pip packages
|
||||
apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev && \
|
||||
# Required for runtime
|
||||
apt-get install -y --no-install-recommends libxmlsec1-openssl libmaxminddb0 && \
|
||||
# Required for bootstrap & healtcheck
|
||||
apt-get install -y --no-install-recommends curl runit && \
|
||||
pip install --no-cache-dir -r /requirements.txt && \
|
||||
apt-get remove --purge -y build-essential pkg-config libxmlsec1-dev && \
|
||||
apt-get autoremove --purge -y && \
|
||||
apt-get clean && \
|
||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
||||
|
61
Makefile
61
Makefile
@ -18,6 +18,15 @@ test-e2e-rest:
|
||||
test-go:
|
||||
go test -timeout 0 -v -race -cover ./...
|
||||
|
||||
test-docker:
|
||||
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
|
||||
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
|
||||
docker-compose pull -q
|
||||
docker-compose up --no-start
|
||||
docker-compose start postgresql redis
|
||||
docker-compose run -u root server test
|
||||
rm -f .env
|
||||
|
||||
test:
|
||||
coverage run manage.py test authentik
|
||||
coverage html
|
||||
@ -52,21 +61,21 @@ gen-clean:
|
||||
rm -rf web/api/src/
|
||||
rm -rf api/
|
||||
|
||||
gen-web:
|
||||
gen-client-web:
|
||||
docker run \
|
||||
--rm -v ${PWD}:/local \
|
||||
--user ${UID}:${GID} \
|
||||
openapitools/openapi-generator-cli:v6.0.0-beta generate \
|
||||
openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||
-i /local/schema.yml \
|
||||
-g typescript-fetch \
|
||||
-o /local/web-api \
|
||||
-o /local/gen-ts-api \
|
||||
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=@goauthentik/api,npmVersion=${NPM_VERSION}
|
||||
mkdir -p web/node_modules/@goauthentik/api
|
||||
\cp -fv scripts/web_api_readme.md web-api/README.md
|
||||
cd web-api && npm i
|
||||
\cp -rfv web-api/* web/node_modules/@goauthentik/api
|
||||
\cp -fv scripts/web_api_readme.md gen-ts-api/README.md
|
||||
cd gen-ts-api && npm i
|
||||
\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api
|
||||
|
||||
gen-outpost:
|
||||
gen-client-go:
|
||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O config.yaml
|
||||
mkdir -p templates
|
||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O templates/README.mustache
|
||||
@ -74,15 +83,15 @@ gen-outpost:
|
||||
docker run \
|
||||
--rm -v ${PWD}:/local \
|
||||
--user ${UID}:${GID} \
|
||||
openapitools/openapi-generator-cli:v6.0.0-beta generate \
|
||||
openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||
-i /local/schema.yml \
|
||||
-g go \
|
||||
-o /local/api \
|
||||
-o /local/gen-go-api \
|
||||
-c /local/config.yaml
|
||||
go mod edit -replace goauthentik.io/api=./api
|
||||
go mod edit -replace goauthentik.io/api/v3=./gen-go-api
|
||||
rm -rf config.yaml ./templates/
|
||||
|
||||
gen: gen-build gen-clean gen-web
|
||||
gen: gen-build gen-clean gen-client-web
|
||||
|
||||
migrate:
|
||||
python -m lifecycle.migrate
|
||||
@ -90,11 +99,18 @@ migrate:
|
||||
run:
|
||||
go run -v cmd/server/main.go
|
||||
|
||||
web-watch:
|
||||
cd web && npm run watch
|
||||
#########################
|
||||
## Web
|
||||
#########################
|
||||
|
||||
web: web-lint-fix web-lint web-extract
|
||||
|
||||
web-install:
|
||||
cd web && npm ci
|
||||
|
||||
web-watch:
|
||||
cd web && npm run watch
|
||||
|
||||
web-lint-fix:
|
||||
cd web && npm run prettier
|
||||
|
||||
@ -105,6 +121,21 @@ web-lint:
|
||||
web-extract:
|
||||
cd web && npm run extract
|
||||
|
||||
#########################
|
||||
## Website
|
||||
#########################
|
||||
|
||||
website: website-lint-fix
|
||||
|
||||
website-install:
|
||||
cd website && npm ci
|
||||
|
||||
website-lint-fix:
|
||||
cd website && npm run prettier
|
||||
|
||||
website-watch:
|
||||
cd website && npm run watch
|
||||
|
||||
# These targets are use by GitHub actions to allow usage of matrix
|
||||
# which makes the YAML File a lot smaller
|
||||
|
||||
@ -130,10 +161,8 @@ ci-pyright: ci--meta-debug
|
||||
ci-pending-migrations: ci--meta-debug
|
||||
./manage.py makemigrations --check
|
||||
|
||||
install:
|
||||
install: web-install website-install
|
||||
poetry install
|
||||
cd web && npm i
|
||||
cd website && npm i
|
||||
|
||||
a: install
|
||||
tmux \
|
||||
|
@ -2,13 +2,16 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2022.4.1"
|
||||
__version__ = "2022.5.3"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
def get_build_hash(fallback: Optional[str] = None) -> str:
|
||||
"""Get build hash"""
|
||||
return environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
|
||||
build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
|
||||
if build_hash == "" and fallback:
|
||||
return fallback
|
||||
return build_hash
|
||||
|
||||
|
||||
def get_full_version() -> str:
|
||||
|
@ -1,10 +1,12 @@
|
||||
"""authentik admin settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"admin_latest_version": {
|
||||
"task": "authentik.admin.tasks.update_latest_version",
|
||||
"schedule": crontab(minute="*/60"), # Run every hour
|
||||
"schedule": crontab(minute=fqdn_rand("admin_latest_version"), hour="*"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
}
|
||||
}
|
||||
|
@ -26,7 +26,7 @@ class TestAdminTasks(TestCase):
|
||||
|
||||
def test_version_valid_response(self):
|
||||
"""Test Update checker with valid response"""
|
||||
with Mocker() as mocker:
|
||||
with Mocker() as mocker, CONFIG.patch("disable_update_check", False):
|
||||
mocker.get("https://version.goauthentik.io/version.json", json=RESPONSE_VALID)
|
||||
update_latest_version.delay().get()
|
||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
|
||||
|
@ -12,6 +12,8 @@ class OwnerFilter(BaseFilterBackend):
|
||||
owner_key = "user"
|
||||
|
||||
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
|
||||
if request.user.is_superuser:
|
||||
return queryset
|
||||
return queryset.filter(**{self.owner_key: request.user})
|
||||
|
||||
|
||||
|
@ -8,9 +8,6 @@ API Browser - {{ tenant.branding_title }}
|
||||
|
||||
{% block head %}
|
||||
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<script>
|
||||
function getCookie(name) {
|
||||
let cookieValue = "";
|
||||
@ -34,16 +31,58 @@ window.addEventListener('DOMContentLoaded', (event) => {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
img.logo {
|
||||
width: 100%;
|
||||
padding: 1rem 0.5rem 1.5rem 0.5rem;
|
||||
min-height: 48px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<rapi-doc
|
||||
spec-url="{{ path }}"
|
||||
heading-text="authentik"
|
||||
theme="dark"
|
||||
render-style="view"
|
||||
heading-text=""
|
||||
theme="light"
|
||||
render-style="read"
|
||||
default-schema-tab="schema"
|
||||
primary-color="#fd4b2d"
|
||||
nav-bg-color="#212427"
|
||||
bg-color="#000000"
|
||||
text-color="#000000"
|
||||
nav-text-color="#ffffff"
|
||||
nav-hover-bg-color="#3c3f42"
|
||||
nav-accent-color="#4f5255"
|
||||
nav-hover-text-color="#ffffff"
|
||||
use-path-in-nav-bar="true"
|
||||
nav-item-spacing="relaxed"
|
||||
allow-server-selection="false"
|
||||
show-header="false"
|
||||
allow-spec-url-load="false"
|
||||
allow-spec-file-load="false">
|
||||
<div slot="logo">
|
||||
<img src="{% static 'dist/assets/icons/icon.png' %}" style="width:50px; height:50px" />
|
||||
<div slot="nav-logo">
|
||||
<img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
||||
</div>
|
||||
</rapi-doc>
|
||||
<script>
|
||||
const rapidoc = document.querySelector("rapi-doc");
|
||||
const matcher = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const changer = (ev) => {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
let bg, text = "";
|
||||
if (matcher.matches) {
|
||||
bg = style.getPropertyValue('--pf-global--BackgroundColor--light-300');
|
||||
text = style.getPropertyValue('--pf-global--Color--300');
|
||||
} else {
|
||||
bg = style.getPropertyValue('--ak-dark-background');
|
||||
text = style.getPropertyValue('--ak-dark-foreground');
|
||||
}
|
||||
rapidoc.attributes.getNamedItem("bg-color").value = bg.trim();
|
||||
rapidoc.attributes.getNamedItem("text-color").value = text.trim();
|
||||
rapidoc.requestUpdate();
|
||||
};
|
||||
matcher.addEventListener("change", changer);
|
||||
window.addEventListener("load", changer);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
29
authentik/api/tests/test_viewsets.py
Normal file
29
authentik/api/tests/test_viewsets.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""authentik API Modelviewset tests"""
|
||||
from typing import Callable
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from authentik.api.v3.urls import router
|
||||
|
||||
|
||||
class TestModelViewSets(TestCase):
|
||||
"""Test Viewset"""
|
||||
|
||||
|
||||
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
|
||||
"""Test Viewset"""
|
||||
|
||||
def tester(self: TestModelViewSets):
|
||||
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
|
||||
filterset_class = getattr(test_viewset, "filterset_class", None)
|
||||
if not filterset_class:
|
||||
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
for _, viewset, _ in router.registry:
|
||||
if not issubclass(viewset, (ModelViewSet, ReadOnlyModelViewSet)):
|
||||
continue
|
||||
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))
|
@ -27,6 +27,7 @@ class Capabilities(models.TextChoices):
|
||||
|
||||
CAN_SAVE_MEDIA = "can_save_media"
|
||||
CAN_GEO_IP = "can_geo_ip"
|
||||
CAN_IMPERSONATE = "can_impersonate"
|
||||
|
||||
|
||||
class ErrorReportingConfigSerializer(PassiveSerializer):
|
||||
@ -63,6 +64,8 @@ class ConfigView(APIView):
|
||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||
if GEOIP_READER.enabled:
|
||||
caps.append(Capabilities.CAN_GEO_IP)
|
||||
if CONFIG.y_bool("impersonation"):
|
||||
caps.append(Capabilities.CAN_IMPERSONATE)
|
||||
return caps
|
||||
|
||||
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
||||
|
@ -22,11 +22,11 @@ from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSe
|
||||
from authentik.core.api.tokens import TokenViewSet
|
||||
from authentik.core.api.users import UserViewSet
|
||||
from authentik.crypto.api import CertificateKeyPairViewSet
|
||||
from authentik.events.api.event import EventViewSet
|
||||
from authentik.events.api.notification import NotificationViewSet
|
||||
from authentik.events.api.notification_mapping import NotificationWebhookMappingViewSet
|
||||
from authentik.events.api.notification_rule import NotificationRuleViewSet
|
||||
from authentik.events.api.notification_transport import NotificationTransportViewSet
|
||||
from authentik.events.api.events import EventViewSet
|
||||
from authentik.events.api.notification_mappings import NotificationWebhookMappingViewSet
|
||||
from authentik.events.api.notification_rules import NotificationRuleViewSet
|
||||
from authentik.events.api.notification_transports import NotificationTransportViewSet
|
||||
from authentik.events.api.notifications import NotificationViewSet
|
||||
from authentik.flows.api.bindings import FlowStageBindingViewSet
|
||||
from authentik.flows.api.flows import FlowViewSet
|
||||
from authentik.flows.api.stages import StageViewSet
|
||||
|
@ -89,6 +89,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
"group",
|
||||
]
|
||||
lookup_field = "slug"
|
||||
filterset_fields = ["name", "slug"]
|
||||
ordering = ["name"]
|
||||
|
||||
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
||||
|
@ -12,7 +12,7 @@ from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
|
||||
from authentik.core.models import Source, UserSourceConnection
|
||||
@ -66,6 +66,7 @@ class SourceViewSet(
|
||||
queryset = Source.objects.none()
|
||||
serializer_class = SourceSerializer
|
||||
lookup_field = "slug"
|
||||
search_fields = ["slug", "name"]
|
||||
|
||||
def get_queryset(self): # pragma: no cover
|
||||
return Source.objects.select_subclasses()
|
||||
@ -150,6 +151,6 @@ class UserSourceConnectionViewSet(
|
||||
|
||||
queryset = UserSourceConnection.objects.all()
|
||||
serializer_class = UserSourceConnectionSerializer
|
||||
permission_classes = [OwnerPermissions]
|
||||
permission_classes = [OwnerSuperuserPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||
ordering = ["pk"]
|
||||
|
@ -17,6 +17,7 @@ from django_filters.filterset import FilterSet
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import (
|
||||
OpenApiParameter,
|
||||
OpenApiResponse,
|
||||
extend_schema,
|
||||
extend_schema_field,
|
||||
inline_serializer,
|
||||
@ -31,7 +32,6 @@ from rest_framework.serializers import (
|
||||
ListSerializer,
|
||||
ModelSerializer,
|
||||
PrimaryKeyRelatedField,
|
||||
Serializer,
|
||||
ValidationError,
|
||||
)
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
@ -72,6 +72,7 @@ class UserSerializer(ModelSerializer):
|
||||
)
|
||||
groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
|
||||
uid = CharField(read_only=True)
|
||||
username = CharField(max_length=150)
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -351,8 +352,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
},
|
||||
),
|
||||
responses={
|
||||
204: "",
|
||||
400: "",
|
||||
204: OpenApiResponse(description="Successfully changed password"),
|
||||
400: OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(detail=True, methods=["POST"])
|
||||
@ -410,8 +411,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
)
|
||||
],
|
||||
responses={
|
||||
"204": Serializer(),
|
||||
"404": Serializer(),
|
||||
"204": OpenApiResponse(description="Successfully sent recover email"),
|
||||
"404": OpenApiResponse(description="Bad request"),
|
||||
},
|
||||
)
|
||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||
|
0
authentik/core/management/__init__.py
Normal file
0
authentik/core/management/__init__.py
Normal file
0
authentik/core/management/commands/__init__.py
Normal file
0
authentik/core/management/commands/__init__.py
Normal file
106
authentik/core/management/commands/shell.py
Normal file
106
authentik/core/management/commands/shell.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""authentik shell command"""
|
||||
import code
|
||||
import platform
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.core.models import User
|
||||
from authentik.events.middleware import IGNORED_MODELS
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import model_to_dict
|
||||
|
||||
BANNER_TEXT = """### authentik shell ({authentik})
|
||||
### Node {node} | Arch {arch} | Python {python} """.format(
|
||||
node=platform.node(),
|
||||
python=platform.python_version(),
|
||||
arch=platform.machine(),
|
||||
authentik=__version__,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand): # pragma: no cover
|
||||
"""Start the Django shell with all authentik models already imported"""
|
||||
|
||||
django_models = {}
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--command",
|
||||
help="Python code to execute (instead of starting an interactive shell)",
|
||||
)
|
||||
|
||||
def get_namespace(self):
|
||||
"""Prepare namespace with all models"""
|
||||
namespace = {}
|
||||
|
||||
# Gather Django models and constants from each app
|
||||
for app in apps.get_app_configs():
|
||||
if not app.name.startswith("authentik"):
|
||||
continue
|
||||
|
||||
# Load models from each app
|
||||
for model in app.get_models():
|
||||
namespace[model.__name__] = model
|
||||
|
||||
return namespace
|
||||
|
||||
@staticmethod
|
||||
# pylint: disable=unused-argument
|
||||
def post_save_handler(sender, instance: Model, created: bool, **_):
|
||||
"""Signal handler for all object's post_save"""
|
||||
if isinstance(instance, IGNORED_MODELS):
|
||||
return
|
||||
|
||||
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
|
||||
Event.new(action, model=model_to_dict(instance)).set_user(
|
||||
User(
|
||||
username="authentik-shell",
|
||||
pk=0,
|
||||
email="",
|
||||
)
|
||||
).save()
|
||||
|
||||
@staticmethod
|
||||
# pylint: disable=unused-argument
|
||||
def pre_delete_handler(sender, instance: Model, **_):
|
||||
"""Signal handler for all object's pre_delete"""
|
||||
if isinstance(instance, IGNORED_MODELS): # pragma: no cover
|
||||
return
|
||||
|
||||
Event.new(EventAction.MODEL_DELETED, model=model_to_dict(instance)).set_user(
|
||||
User(
|
||||
username="authentik-shell",
|
||||
pk=0,
|
||||
email="",
|
||||
)
|
||||
).save()
|
||||
|
||||
def handle(self, **options):
|
||||
namespace = self.get_namespace()
|
||||
|
||||
post_save.connect(Command.post_save_handler)
|
||||
pre_delete.connect(Command.pre_delete_handler)
|
||||
|
||||
# If Python code has been passed, execute it and exit.
|
||||
if options["command"]:
|
||||
# pylint: disable=exec-used
|
||||
exec(options["command"], namespace) # nosec # noqa
|
||||
return
|
||||
|
||||
# Try to enable tab-complete
|
||||
try:
|
||||
import readline
|
||||
import rlcompleter
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
else:
|
||||
readline.set_completer(rlcompleter.Completer(namespace).complete)
|
||||
readline.parse_and_bind("tab: complete")
|
||||
|
||||
# Run interactive shell
|
||||
code.interact(banner=BANNER_TEXT, local=namespace)
|
@ -34,9 +34,9 @@ def clean_expired_models(self: MonitoredTask):
|
||||
objects = (
|
||||
cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now())
|
||||
)
|
||||
amount = objects.count()
|
||||
for obj in objects:
|
||||
obj.expire_action()
|
||||
amount = objects.count()
|
||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||
# Special case
|
||||
|
@ -8,6 +8,12 @@
|
||||
{% if flow.compatibility_mode and not inspector %}
|
||||
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
||||
{% endif %}
|
||||
<script>
|
||||
window.authentik = {};
|
||||
window.authentik.flow = {
|
||||
"layout": "{{ flow.layout }}",
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
|
@ -12,6 +12,25 @@
|
||||
.pf-c-background-image::before {
|
||||
--ak-flow-background: url("/static/dist/assets/images/flow_background.jpg");
|
||||
}
|
||||
/* Form with user */
|
||||
.form-control-static {
|
||||
margin-top: var(--pf-global--spacer--sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.form-control-static .avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.form-control-static img {
|
||||
margin-right: var(--pf-global--spacer--xs);
|
||||
}
|
||||
.form-control-static a {
|
||||
padding-top: var(--pf-global--spacer--xs);
|
||||
padding-bottom: var(--pf-global--spacer--xs);
|
||||
line-height: var(--pf-global--spacer--xl);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -59,13 +78,11 @@
|
||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if tenant.branding_title != "authentik" %}
|
||||
<li>
|
||||
<a href="https://goauthentik.io?utm_source=authentik">
|
||||
{% trans 'Powered by authentik' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</footer>
|
||||
</div>
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""authentik URL Configuration"""
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
@ -6,6 +7,7 @@ from django.views.generic import RedirectView
|
||||
from django.views.generic.base import TemplateView
|
||||
|
||||
from authentik.core.views import apps, impersonate
|
||||
from authentik.core.views.debug import AccessDeniedView
|
||||
from authentik.core.views.interface import FlowInterfaceView
|
||||
from authentik.core.views.session import EndSessionView
|
||||
|
||||
@ -60,3 +62,8 @@ urlpatterns = [
|
||||
TemplateView.as_view(template_name="if/admin.html"),
|
||||
),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += [
|
||||
path("debug/policy/deny/", AccessDeniedView.as_view(), name="debug-policy-deny"),
|
||||
]
|
||||
|
12
authentik/core/views/debug.py
Normal file
12
authentik/core/views/debug.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""debug view"""
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views.generic import View
|
||||
|
||||
from authentik.policies.denied import AccessDeniedResponse
|
||||
|
||||
|
||||
class AccessDeniedView(View):
|
||||
"""Easily access AccessDeniedResponse"""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
return AccessDeniedResponse(request)
|
@ -8,6 +8,7 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.config import CONFIG
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -17,6 +18,9 @@ class ImpersonateInitView(View):
|
||||
|
||||
def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
|
||||
"""Impersonation handler, checks permissions"""
|
||||
if not CONFIG.y_bool("impersonation"):
|
||||
LOGGER.debug("User attempted to impersonate", user=request.user)
|
||||
return HttpResponse("Unauthorized", status=401)
|
||||
if not request.user.has_perm("impersonate"):
|
||||
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
||||
return HttpResponse("Unauthorized", status=401)
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
def create_self_signed(apps, schema_editor):
|
||||
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
|
||||
@ -9,7 +11,7 @@ def create_self_signed(apps, schema_editor):
|
||||
from authentik.crypto.builder import CertificateBuilder
|
||||
|
||||
builder = CertificateBuilder()
|
||||
builder.build()
|
||||
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
||||
CertificateKeyPair.objects.using(db_alias).create(
|
||||
name="authentik Self-signed Certificate",
|
||||
certificate_data=builder.certificate,
|
||||
|
@ -1,10 +1,12 @@
|
||||
"""Crypto task Settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"crypto_certificate_discovery": {
|
||||
"task": "authentik.crypto.tasks.certificate_discovery",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"schedule": crontab(minute=fqdn_rand("crypto_certificate_discovery"), hour="*"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
||||
|
@ -26,3 +26,4 @@ class NotificationWebhookMappingViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = NotificationWebhookMappingSerializer
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
@ -32,3 +32,4 @@ class NotificationRuleViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = NotificationRuleSerializer
|
||||
filterset_fields = ["name", "severity", "group__name"]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name", "group__name"]
|
@ -68,6 +68,7 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = NotificationTransport.objects.all()
|
||||
serializer_class = NotificationTransportSerializer
|
||||
filterset_fields = ["name", "mode", "webhook_url", "send_once"]
|
||||
search_fields = ["name", "mode", "webhook_url"]
|
||||
ordering = ["name"]
|
||||
|
||||
@permission_required("authentik_events.change_notificationtransport")
|
@ -13,7 +13,7 @@ from rest_framework.viewsets import GenericViewSet
|
||||
|
||||
from authentik.api.authorization import OwnerFilter, OwnerPermissions
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.events.api.event import EventSerializer
|
||||
from authentik.events.api.events import EventSerializer
|
||||
from authentik.events.models import Notification
|
||||
|
||||
|
||||
@ -55,6 +55,7 @@ class NotificationViewSet(
|
||||
"created",
|
||||
"event",
|
||||
"seen",
|
||||
"user",
|
||||
]
|
||||
permission_classes = [OwnerPermissions]
|
||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
@ -18,13 +18,18 @@ from authentik.events.utils import model_to_dict
|
||||
from authentik.lib.sentry import before_send
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
|
||||
IGNORED_MODELS = (
|
||||
IGNORED_MODELS = [
|
||||
Event,
|
||||
Notification,
|
||||
UserObjectPermission,
|
||||
AuthenticatedSession,
|
||||
StaticToken,
|
||||
)
|
||||
]
|
||||
if settings.DEBUG:
|
||||
from silk.models import Request, Response, SQLQuery
|
||||
|
||||
IGNORED_MODELS += [Request, Response, SQLQuery]
|
||||
IGNORED_MODELS = tuple(IGNORED_MODELS)
|
||||
|
||||
|
||||
class AuditMiddleware:
|
||||
|
@ -383,6 +383,7 @@ class Migration(migrations.Migration):
|
||||
models.ManyToManyField(
|
||||
help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
|
||||
to="authentik_events.NotificationTransport",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -261,7 +261,7 @@ class Event(ExpiringModel):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self._state.adding:
|
||||
LOGGER.debug(
|
||||
LOGGER.info(
|
||||
"Created Event",
|
||||
action=self.action,
|
||||
context=self.context,
|
||||
@ -481,6 +481,7 @@ class NotificationRule(PolicyBindingModel):
|
||||
"selected, the notification will only be shown in the authentik UI."
|
||||
)
|
||||
),
|
||||
blank=True,
|
||||
)
|
||||
severity = models.TextField(
|
||||
choices=NotificationSeverity.choices,
|
||||
@ -518,7 +519,7 @@ class NotificationWebhookMapping(PropertyMapping):
|
||||
|
||||
@property
|
||||
def serializer(self) -> type["Serializer"]:
|
||||
from authentik.events.api.notification_mapping import NotificationWebhookMappingSerializer
|
||||
from authentik.events.api.notification_mappings import NotificationWebhookMappingSerializer
|
||||
|
||||
return NotificationWebhookMappingSerializer
|
||||
|
||||
|
12
authentik/events/settings.py
Normal file
12
authentik/events/settings.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Event Settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"events_notification_cleanup": {
|
||||
"task": "authentik.events.tasks.notification_cleanup",
|
||||
"schedule": crontab(minute=fqdn_rand("notification_cleanup"), hour="*/8"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
"""Event notification tasks"""
|
||||
from django.db.models.query_utils import Q
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@ -10,7 +11,12 @@ from authentik.events.models import (
|
||||
NotificationTransport,
|
||||
NotificationTransportError,
|
||||
)
|
||||
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
|
||||
from authentik.events.monitored_tasks import (
|
||||
MonitoredTask,
|
||||
TaskResult,
|
||||
TaskResultStatus,
|
||||
prefill_task,
|
||||
)
|
||||
from authentik.policies.engine import PolicyEngine
|
||||
from authentik.policies.models import PolicyBinding, PolicyEngineMode
|
||||
from authentik.root.celery import CELERY_APP
|
||||
@ -114,3 +120,15 @@ def gdpr_cleanup(user_pk: int):
|
||||
events = Event.objects.filter(user__pk=user_pk)
|
||||
LOGGER.debug("GDPR cleanup, removing events from user", events=events.count())
|
||||
events.delete()
|
||||
|
||||
|
||||
@CELERY_APP.task(bind=True, base=MonitoredTask)
|
||||
@prefill_task
|
||||
def notification_cleanup(self: MonitoredTask):
|
||||
"""Cleanup seen notifications and notifications whose event expired."""
|
||||
notifications = Notification.objects.filter(Q(event=None) | Q(seen=True))
|
||||
amount = notifications.count()
|
||||
for notification in notifications:
|
||||
notification.delete()
|
||||
LOGGER.debug("Expired notifications", amount=amount)
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, [f"Expired {amount} Notifications"]))
|
||||
|
@ -35,3 +35,4 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = FlowStageBinding.objects.all()
|
||||
serializer_class = FlowStageBindingSerializer
|
||||
filterset_fields = "__all__"
|
||||
search_fields = ["stage__name"]
|
||||
|
@ -72,6 +72,7 @@ class FlowSerializer(ModelSerializer):
|
||||
"policy_engine_mode",
|
||||
"compatibility_mode",
|
||||
"export_url",
|
||||
"layout",
|
||||
]
|
||||
extra_kwargs = {
|
||||
"background": {"read_only": True},
|
||||
@ -211,12 +212,30 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
||||
]
|
||||
body: list[DiagramElement] = []
|
||||
footer = []
|
||||
# First, collect all elements we need
|
||||
# Collect all elements we need
|
||||
# First, policies bound to the flow itself
|
||||
for p_index, policy_binding in enumerate(
|
||||
get_objects_for_user(request.user, "authentik_policies.view_policybinding")
|
||||
.filter(target=flow)
|
||||
.exclude(policy__isnull=True)
|
||||
.order_by("order")
|
||||
):
|
||||
body.append(
|
||||
DiagramElement(
|
||||
f"flow_policy_{p_index}",
|
||||
"condition",
|
||||
_("Policy (%(type)s)" % {"type": policy_binding.policy._meta.verbose_name})
|
||||
+ "\n"
|
||||
+ policy_binding.policy.name,
|
||||
)
|
||||
)
|
||||
# Collect all stages
|
||||
for s_index, stage_binding in enumerate(
|
||||
get_objects_for_user(request.user, "authentik_flows.view_flowstagebinding")
|
||||
.filter(target=flow)
|
||||
.order_by("order")
|
||||
):
|
||||
# First all policies bound to stages since they execute before stages
|
||||
for p_index, policy_binding in enumerate(
|
||||
get_objects_for_user(request.user, "authentik_policies.view_policybinding")
|
||||
.filter(target=stage_binding)
|
||||
@ -227,14 +246,18 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
||||
DiagramElement(
|
||||
f"stage_{s_index}_policy_{p_index}",
|
||||
"condition",
|
||||
f"Policy\n{policy_binding.policy.name}",
|
||||
_("Policy (%(type)s)" % {"type": policy_binding.policy._meta.verbose_name})
|
||||
+ "\n"
|
||||
+ policy_binding.policy.name,
|
||||
)
|
||||
)
|
||||
body.append(
|
||||
DiagramElement(
|
||||
f"stage_{s_index}",
|
||||
"operation",
|
||||
f"Stage\n{stage_binding.stage.name}",
|
||||
_("Stage (%(type)s)" % {"type": stage_binding.stage._meta.verbose_name})
|
||||
+ "\n"
|
||||
+ stage_binding.stage.name,
|
||||
)
|
||||
)
|
||||
# If the 2nd last element is a policy, we need to have an item to point to
|
||||
|
@ -2,6 +2,7 @@
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from django.db import models
|
||||
from django.http import JsonResponse
|
||||
from rest_framework.fields import ChoiceField, DictField
|
||||
from rest_framework.serializers import CharField
|
||||
@ -12,6 +13,20 @@ from authentik.flows.transfer.common import DataclassEncoder
|
||||
if TYPE_CHECKING:
|
||||
from authentik.flows.stage import StageView
|
||||
|
||||
PLAN_CONTEXT_TITLE = "title"
|
||||
PLAN_CONTEXT_URL = "url"
|
||||
PLAN_CONTEXT_ATTRS = "attrs"
|
||||
|
||||
|
||||
class FlowLayout(models.TextChoices):
|
||||
"""Flow layouts"""
|
||||
|
||||
STACKED = "stacked"
|
||||
CONTENT_LEFT = "content_left"
|
||||
CONTENT_RIGHT = "content_right"
|
||||
SIDEBAR_LEFT = "sidebar_left"
|
||||
SIDEBAR_RIGHT = "sidebar_right"
|
||||
|
||||
|
||||
class ChallengeTypes(Enum):
|
||||
"""Currently defined challenge types"""
|
||||
@ -34,6 +49,7 @@ class ContextualFlowInfo(PassiveSerializer):
|
||||
title = CharField(required=False, allow_blank=True)
|
||||
background = CharField(required=False)
|
||||
cancel_url = CharField()
|
||||
layout = ChoiceField(choices=[(x.value, x.name) for x in FlowLayout])
|
||||
|
||||
|
||||
class Challenge(PassiveSerializer):
|
||||
@ -97,6 +113,21 @@ class ChallengeResponse(PassiveSerializer):
|
||||
super().__init__(instance=instance, data=data, **kwargs)
|
||||
|
||||
|
||||
class AutosubmitChallenge(Challenge):
|
||||
"""Autosubmit challenge used to send and navigate a POST request"""
|
||||
|
||||
url = CharField()
|
||||
attrs = DictField(child=CharField())
|
||||
title = CharField(required=False)
|
||||
component = CharField(default="ak-stage-autosubmit")
|
||||
|
||||
|
||||
class AutoSubmitChallengeResponse(ChallengeResponse):
|
||||
"""Pseudo class for autosubmit response"""
|
||||
|
||||
component = CharField(default="ak-stage-autosubmit")
|
||||
|
||||
|
||||
class HttpChallengeResponse(JsonResponse):
|
||||
"""Subclass of JsonResponse that uses the `DataclassEncoder`"""
|
||||
|
||||
|
@ -12,3 +12,7 @@ class FlowNonApplicableException(SentryIgnoredException):
|
||||
|
||||
class EmptyFlowException(SentryIgnoredException):
|
||||
"""Flow has no stages."""
|
||||
|
||||
|
||||
class FlowSkipStageException(SentryIgnoredException):
|
||||
"""Exception to skip a stage"""
|
||||
|
@ -130,7 +130,7 @@ class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("authentik_flows", "0017_auto_20210329_1334"),
|
||||
("authentik_stages_user_write", "0002_auto_20200918_1653"),
|
||||
("authentik_stages_user_login", "__latest__"),
|
||||
("authentik_stages_user_login", "0003_session_duration_delta"),
|
||||
("authentik_stages_password", "0002_passwordstage_change_flow"),
|
||||
("authentik_policies", "0001_initial"),
|
||||
("authentik_policies_expression", "0001_initial"),
|
||||
|
27
authentik/flows/migrations/0022_flow_layout.py
Normal file
27
authentik/flows/migrations/0022_flow_layout.py
Normal file
@ -0,0 +1,27 @@
|
||||
# Generated by Django 4.0.4 on 2022-05-15 19:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0021_auto_20211227_2103"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="flow",
|
||||
name="layout",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("stacked", "Stacked"),
|
||||
("content_left", "Content Left"),
|
||||
("content_right", "Content Right"),
|
||||
("sidebar_left", "Sidebar Left"),
|
||||
("sidebar_right", "Sidebar Right"),
|
||||
],
|
||||
default="stacked",
|
||||
),
|
||||
),
|
||||
]
|
@ -13,6 +13,7 @@ from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import Token
|
||||
from authentik.core.types import UserSettingSerializer
|
||||
from authentik.flows.challenge import FlowLayout
|
||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||
from authentik.policies.models import PolicyBindingModel
|
||||
|
||||
@ -107,6 +108,7 @@ class Flow(SerializerModel, PolicyBindingModel):
|
||||
slug = models.SlugField(unique=True, help_text=_("Visible in the URL."))
|
||||
|
||||
title = models.TextField(help_text=_("Shown as the Title in Flow pages."))
|
||||
layout = models.TextField(default=FlowLayout.STACKED, choices=FlowLayout.choices)
|
||||
|
||||
designation = models.CharField(
|
||||
max_length=100,
|
||||
@ -231,7 +233,7 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
|
||||
return FlowStageBindingSerializer
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"Flow-stage binding #{self.order} to {self.target}"
|
||||
return f"Flow-stage binding #{self.order} to {self.target_id}"
|
||||
|
||||
class Meta:
|
||||
|
||||
|
@ -120,9 +120,12 @@ class ChallengeStageView(StageView):
|
||||
return self.executor.flow.title
|
||||
try:
|
||||
return self.executor.flow.title % {
|
||||
"app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, "")
|
||||
"app": self.executor.plan.context.get(PLAN_CONTEXT_APPLICATION, ""),
|
||||
"user": self.get_pending_user(for_display=True),
|
||||
}
|
||||
except ValueError:
|
||||
# pylint: disable=broad-except
|
||||
except Exception as exc:
|
||||
LOGGER.warning("failed to template title", exc=exc)
|
||||
return self.executor.flow.title
|
||||
|
||||
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
@ -131,25 +134,32 @@ class ChallengeStageView(StageView):
|
||||
description=self.__class__.__name__,
|
||||
):
|
||||
challenge = self.get_challenge(*args, **kwargs)
|
||||
if "flow_info" not in challenge.initial_data:
|
||||
flow_info = ContextualFlowInfo(
|
||||
data={
|
||||
"title": self.format_title(),
|
||||
"background": self.executor.flow.background_url,
|
||||
"cancel_url": reverse("authentik_flows:cancel"),
|
||||
}
|
||||
)
|
||||
flow_info.is_valid()
|
||||
challenge.initial_data["flow_info"] = flow_info.data
|
||||
if isinstance(challenge, WithUserInfoChallenge):
|
||||
# If there's a pending user, update the `username` field
|
||||
# this field is only used by password managers.
|
||||
# If there's no user set, an error is raised later.
|
||||
if user := self.get_pending_user(for_display=True):
|
||||
challenge.initial_data["pending_user"] = user.username
|
||||
challenge.initial_data["pending_user_avatar"] = DEFAULT_AVATAR
|
||||
if not isinstance(user, AnonymousUser):
|
||||
challenge.initial_data["pending_user_avatar"] = user.avatar
|
||||
with Hub.current.start_span(
|
||||
op="authentik.flow.stage._get_challenge",
|
||||
description=self.__class__.__name__,
|
||||
):
|
||||
if not hasattr(challenge, "initial_data"):
|
||||
challenge.initial_data = {}
|
||||
if "flow_info" not in challenge.initial_data:
|
||||
flow_info = ContextualFlowInfo(
|
||||
data={
|
||||
"title": self.format_title(),
|
||||
"background": self.executor.flow.background_url,
|
||||
"cancel_url": reverse("authentik_flows:cancel"),
|
||||
"layout": self.executor.flow.layout,
|
||||
}
|
||||
)
|
||||
flow_info.is_valid()
|
||||
challenge.initial_data["flow_info"] = flow_info.data
|
||||
if isinstance(challenge, WithUserInfoChallenge):
|
||||
# If there's a pending user, update the `username` field
|
||||
# this field is only used by password managers.
|
||||
# If there's no user set, an error is raised later.
|
||||
if user := self.get_pending_user(for_display=True):
|
||||
challenge.initial_data["pending_user"] = user.username
|
||||
challenge.initial_data["pending_user_avatar"] = DEFAULT_AVATAR
|
||||
if not isinstance(user, AnonymousUser):
|
||||
challenge.initial_data["pending_user_avatar"] = user.avatar
|
||||
return challenge
|
||||
|
||||
def get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
|
@ -23,6 +23,7 @@ class FlowTestCase(APITestCase):
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
"""Assert various attributes of a stage response"""
|
||||
self.assertEqual(response.status_code, 200)
|
||||
raw_response = loads(response.content.decode())
|
||||
self.assertIsNotNone(raw_response["component"])
|
||||
self.assertIsNotNone(raw_response["type"])
|
||||
|
@ -10,11 +10,11 @@ from authentik.policies.models import PolicyBinding
|
||||
from authentik.stages.dummy.models import DummyStage
|
||||
|
||||
DIAGRAM_EXPECTED = """st=>start: Start
|
||||
stage_0=>operation: Stage
|
||||
stage_0=>operation: Stage (Dummy Stage)
|
||||
dummy1
|
||||
stage_1_policy_0=>condition: Policy
|
||||
None
|
||||
stage_1=>operation: Stage
|
||||
stage_1_policy_0=>condition: Policy (Dummy Policy)
|
||||
test
|
||||
stage_1=>operation: Stage (Dummy Stage)
|
||||
dummy2
|
||||
e=>end: End|future
|
||||
st(right)->stage_0
|
||||
@ -55,7 +55,7 @@ class TestFlowsAPI(APITestCase):
|
||||
slug="test-default-context",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
false_policy = DummyPolicy.objects.create(result=False, wait_min=1, wait_max=2)
|
||||
false_policy = DummyPolicy.objects.create(name="test", result=False, wait_min=1, wait_max=2)
|
||||
|
||||
FlowStageBinding.objects.create(
|
||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||
|
@ -87,7 +87,6 @@ class TestFlowExecutor(FlowTestCase):
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow=flow,
|
||||
@ -406,7 +405,6 @@ class TestFlowExecutor(FlowTestCase):
|
||||
# A get request will evaluate the policies and this will return stage 4
|
||||
# but it won't save it, hence we can't check the plan
|
||||
response = self.client.get(exec_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-dummy")
|
||||
|
||||
# fourth request, this confirms the last stage (dummy4)
|
||||
@ -479,7 +477,6 @@ class TestFlowExecutor(FlowTestCase):
|
||||
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||
# First request, run the planner
|
||||
response = self.client.get(exec_url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
flow,
|
||||
@ -491,5 +488,4 @@ class TestFlowExecutor(FlowTestCase):
|
||||
user_fields=[UserFields.E_MAIL],
|
||||
)
|
||||
response = self.client.post(exec_url, {"uid_field": "invalid-string"}, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(response, flow, component="ak-stage-access-denied")
|
||||
|
@ -9,6 +9,7 @@ from rest_framework.test import APITestCase
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.flows.challenge import ChallengeTypes
|
||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.dummy.models import DummyStage
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
@ -24,8 +25,8 @@ class TestFlowInspector(APITestCase):
|
||||
def test(self):
|
||||
"""test inspector"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-full",
|
||||
slug="test-full",
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
@ -55,6 +56,7 @@ class TestFlowInspector(APITestCase):
|
||||
"background": flow.background_url,
|
||||
"cancel_url": reverse("authentik_flows:cancel"),
|
||||
"title": "",
|
||||
"layout": "stacked",
|
||||
},
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"password_fields": False,
|
||||
|
@ -13,6 +13,26 @@ from authentik.policies.models import PolicyBinding
|
||||
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
||||
from authentik.stages.user_login.models import UserLoginStage
|
||||
|
||||
STATIC_PROMPT_EXPORT = """{
|
||||
"version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"identifiers": {
|
||||
"pk": "cb954fd4-65a5-4ad9-b1ee-180ee9559cf4"
|
||||
},
|
||||
"model": "authentik_stages_prompt.prompt",
|
||||
"attrs": {
|
||||
"field_key": "username",
|
||||
"label": "Username",
|
||||
"type": "username",
|
||||
"required": true,
|
||||
"placeholder": "Username",
|
||||
"order": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
|
||||
class TestFlowTransfer(TransactionTestCase):
|
||||
"""Test flow transfer"""
|
||||
@ -58,6 +78,22 @@ class TestFlowTransfer(TransactionTestCase):
|
||||
|
||||
self.assertTrue(Flow.objects.filter(slug=flow_slug).exists())
|
||||
|
||||
def test_export_validate_import_re_import(self):
|
||||
"""Test export and import it twice"""
|
||||
count_initial = Prompt.objects.filter(field_key="username").count()
|
||||
|
||||
importer = FlowImporter(STATIC_PROMPT_EXPORT)
|
||||
self.assertTrue(importer.validate())
|
||||
self.assertTrue(importer.apply())
|
||||
|
||||
count_before = Prompt.objects.filter(field_key="username").count()
|
||||
self.assertEqual(count_initial + 1, count_before)
|
||||
|
||||
importer = FlowImporter(STATIC_PROMPT_EXPORT)
|
||||
self.assertTrue(importer.apply())
|
||||
|
||||
self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before)
|
||||
|
||||
def test_export_validate_import_policies(self):
|
||||
"""Test export and validate it"""
|
||||
flow_slug = generate_id()
|
||||
|
@ -115,6 +115,11 @@ class FlowImporter:
|
||||
serializer_kwargs["instance"] = model_instance
|
||||
else:
|
||||
self.logger.debug("initialise new instance", model=model, **updated_identifiers)
|
||||
model_instance = model()
|
||||
# pk needs to be set on the model instance otherwise a new one will be generated
|
||||
if "pk" in updated_identifiers:
|
||||
model_instance.pk = updated_identifiers["pk"]
|
||||
serializer_kwargs["instance"] = model_instance
|
||||
full_data = self.__update_pks_for_attrs(entry.attrs)
|
||||
full_data.update(updated_identifiers)
|
||||
serializer_kwargs["data"] = full_data
|
||||
@ -167,7 +172,7 @@ class FlowImporter:
|
||||
def validate(self) -> bool:
|
||||
"""Validate loaded flow export, ensure all models are allowed
|
||||
and serializers have no errors"""
|
||||
self.logger.debug("Starting flow import validaton")
|
||||
self.logger.debug("Starting flow import validation")
|
||||
if self.__import.version != 1:
|
||||
self.logger.warning("Invalid bundle version")
|
||||
return False
|
||||
|
@ -169,10 +169,11 @@ class FlowExecutorView(APIView):
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
# Early check if there's an active Plan for the current session
|
||||
if SESSION_KEY_PLAN in self.request.session:
|
||||
self.plan = self.request.session[SESSION_KEY_PLAN]
|
||||
self.plan: FlowPlan = self.request.session[SESSION_KEY_PLAN]
|
||||
if self.plan.flow_pk != self.flow.pk.hex:
|
||||
self._logger.warning(
|
||||
"f(exec): Found existing plan for other flow, deleting plan",
|
||||
other_flow=self.plan.flow_pk,
|
||||
)
|
||||
# Existing plan is deleted from session and instance
|
||||
self.plan = None
|
||||
|
@ -72,3 +72,4 @@ default_user_change_username: true
|
||||
gdpr_compliance: true
|
||||
cert_discovery_dir: /certs
|
||||
default_token_length: 128
|
||||
impersonation: true
|
||||
|
@ -18,13 +18,22 @@ 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 init as sentry_sdk_init
|
||||
from sentry_sdk.api import set_tag
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from sentry_sdk.integrations.threading import ThreadingIntegration
|
||||
from sentry_sdk.tracing import Transaction
|
||||
from structlog.stdlib import get_logger
|
||||
from websockets.exceptions import WebSocketException
|
||||
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik import __version__, get_build_hash
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.reflection import class_to_path, get_env
|
||||
|
||||
LOGGER = get_logger()
|
||||
SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8"
|
||||
|
||||
|
||||
class SentryWSMiddleware(BaseMiddleware):
|
||||
@ -43,6 +52,37 @@ class SentryIgnoredException(Exception):
|
||||
"""Base Class for all errors that are suppressed, and not sent to sentry."""
|
||||
|
||||
|
||||
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),
|
||||
}
|
||||
kwargs.update(**sentry_init_kwargs)
|
||||
# pylint: disable=abstract-class-instantiated
|
||||
sentry_sdk_init(
|
||||
dsn=SENTRY_DSN,
|
||||
integrations=[
|
||||
DjangoIntegration(transaction_style="function_name"),
|
||||
CeleryIntegration(),
|
||||
RedisIntegration(),
|
||||
ThreadingIntegration(propagate_hub=True),
|
||||
],
|
||||
before_send=before_send,
|
||||
release=f"authentik@{__version__}",
|
||||
**kwargs,
|
||||
)
|
||||
set_tag("authentik.build_hash", get_build_hash("tagged"))
|
||||
set_tag("authentik.env", get_env())
|
||||
set_tag("authentik.component", "backend")
|
||||
LOGGER.info(
|
||||
"Error reporting is enabled",
|
||||
env=kwargs["environment"],
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
@ -108,6 +148,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
|
||||
]:
|
||||
return None
|
||||
LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", None))
|
||||
if settings.DEBUG or settings.TEST:
|
||||
if settings.DEBUG:
|
||||
return None
|
||||
return event
|
||||
|
@ -13,4 +13,4 @@ class TestSentry(TestCase):
|
||||
|
||||
def test_error_sent(self):
|
||||
"""Test error sent"""
|
||||
self.assertEqual(None, before_send({}, {"exc_info": (0, ValueError(), 0)}))
|
||||
self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)}))
|
||||
|
@ -1,5 +1,8 @@
|
||||
"""Time utilities"""
|
||||
import datetime
|
||||
from hashlib import sha256
|
||||
from random import randrange, seed
|
||||
from socket import getfqdn
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@ -38,3 +41,12 @@ def timedelta_from_string(expr: str) -> datetime.timedelta:
|
||||
if len(kwargs) < 1:
|
||||
raise ValueError("No valid keys to pass to timedelta")
|
||||
return datetime.timedelta(**kwargs)
|
||||
|
||||
|
||||
def fqdn_rand(task: str, stop: int = 60) -> int:
|
||||
"""Get a random number within max based on the FQDN and task name"""
|
||||
entropy = f"{getfqdn()}:{task}"
|
||||
hasher = sha256()
|
||||
hasher.update(entropy.encode("utf-8"))
|
||||
seed(hasher.hexdigest())
|
||||
return randrange(0, stop) # nosec
|
||||
|
@ -1,7 +1,8 @@
|
||||
"""URL-related utils"""
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.http import HttpResponse
|
||||
from django.http import HttpResponse, QueryDict
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.utils.http import urlencode
|
||||
@ -15,7 +16,9 @@ def is_url_absolute(url):
|
||||
return bool(urlparse(url).netloc)
|
||||
|
||||
|
||||
def redirect_with_qs(view: str, get_query_set=None, **kwargs) -> HttpResponse:
|
||||
def redirect_with_qs(
|
||||
view: str, get_query_set: Optional[QueryDict] = None, **kwargs
|
||||
) -> HttpResponse:
|
||||
"""Wrapper to redirect whilst keeping GET Parameters"""
|
||||
try:
|
||||
target = reverse(view, kwargs=kwargs)
|
||||
@ -28,3 +31,11 @@ def redirect_with_qs(view: str, get_query_set=None, **kwargs) -> HttpResponse:
|
||||
if get_query_set:
|
||||
target += "?" + urlencode(get_query_set.items())
|
||||
return redirect(target)
|
||||
|
||||
|
||||
def reverse_with_qs(view: str, query: Optional[QueryDict] = None, **kwargs) -> str:
|
||||
"""Reverse a view to it's url but include get params"""
|
||||
url = reverse(view, **kwargs)
|
||||
if query:
|
||||
url += "?" + urlencode(query.items())
|
||||
return url
|
||||
|
@ -1,10 +1,12 @@
|
||||
"""managed Settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"managed_reconcile": {
|
||||
"task": "authentik.managed.tasks.managed_reconcile",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"schedule": crontab(minute=fqdn_rand("managed_reconcile"), hour="*/4"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
||||
|
@ -118,6 +118,7 @@ class DockerServiceConnectionViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = DockerServiceConnectionSerializer
|
||||
filterset_fields = ["name", "local", "url", "tls_verification", "tls_authentication"]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
||||
|
||||
class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
|
||||
@ -152,3 +153,4 @@ class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = KubernetesServiceConnectionSerializer
|
||||
filterset_fields = ["name", "local"]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -15,7 +15,7 @@ from yaml import safe_dump
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
|
||||
from authentik.outposts.docker_ssh import DockerInlineSSH
|
||||
from authentik.outposts.docker_ssh import DockerInlineSSH, SSHManagedExternallyException
|
||||
from authentik.outposts.docker_tls import DockerInlineTLS
|
||||
from authentik.outposts.managed import MANAGED_OUTPOST
|
||||
from authentik.outposts.models import (
|
||||
@ -35,6 +35,7 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
||||
def __init__(self, connection: DockerServiceConnection):
|
||||
self.tls = None
|
||||
self.ssh = None
|
||||
self.logger = get_logger()
|
||||
if connection.local:
|
||||
# Same result as DockerClient.from_env
|
||||
super().__init__(**kwargs_from_env())
|
||||
@ -42,8 +43,12 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
||||
parsed_url = urlparse(connection.url)
|
||||
tls_config = False
|
||||
if parsed_url.scheme == "ssh":
|
||||
self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
|
||||
self.ssh.write()
|
||||
try:
|
||||
self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
|
||||
self.ssh.write()
|
||||
except SSHManagedExternallyException as exc:
|
||||
# SSH config is managed externally
|
||||
self.logger.info(f"SSH Managed externally: {exc}")
|
||||
else:
|
||||
self.tls = DockerInlineTLS(
|
||||
verification_kp=connection.tls_verification,
|
||||
@ -57,7 +62,6 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
||||
)
|
||||
except SSHException as exc:
|
||||
raise ServiceConnectionInvalid from exc
|
||||
self.logger = get_logger()
|
||||
# Ensure the client actually works
|
||||
self.containers.list()
|
||||
|
||||
|
@ -16,6 +16,10 @@ def opener(path, flags):
|
||||
return os.open(path, flags, 0o700)
|
||||
|
||||
|
||||
class SSHManagedExternallyException(DockerException):
|
||||
"""Raised when the ssh config file is managed externally."""
|
||||
|
||||
|
||||
class DockerInlineSSH:
|
||||
"""Create paramiko ssh config from CertificateKeyPair"""
|
||||
|
||||
@ -29,9 +33,15 @@ class DockerInlineSSH:
|
||||
def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
|
||||
self.host = host
|
||||
self.keypair = keypair
|
||||
self.config_path = Path("~/.ssh/config").expanduser()
|
||||
if self.config_path.exists() and HEADER not in self.config_path.read_text(encoding="utf-8"):
|
||||
# SSH Config file already exists and there's no header from us, meaning that it's
|
||||
# been externally mapped into the container for more complex configs
|
||||
raise SSHManagedExternallyException(
|
||||
"SSH Config exists and does not contain authentik header"
|
||||
)
|
||||
if not self.keypair:
|
||||
raise DockerException("keypair must be set for SSH connections")
|
||||
self.config_path = Path("~/.ssh/config").expanduser()
|
||||
self.header = f"{HEADER} - {self.host}\n"
|
||||
|
||||
def write_config(self, key_path: str) -> bool:
|
||||
|
@ -1,25 +1,27 @@
|
||||
"""Outposts Settings"""
|
||||
from celery.schedules import crontab
|
||||
|
||||
from authentik.lib.utils.time import fqdn_rand
|
||||
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"outposts_controller": {
|
||||
"task": "authentik.outposts.tasks.outpost_controller_all",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"schedule": crontab(minute=fqdn_rand("outposts_controller"), hour="*/4"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
"outposts_service_connection_check": {
|
||||
"task": "authentik.outposts.tasks.outpost_service_connection_monitor",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"schedule": crontab(minute="3-59/15"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
"outpost_token_ensurer": {
|
||||
"task": "authentik.outposts.tasks.outpost_token_ensurer",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"schedule": crontab(minute=fqdn_rand("outpost_token_ensurer"), hour="*/8"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
"outpost_local_connection": {
|
||||
"task": "authentik.outposts.tasks.outpost_local_connection",
|
||||
"schedule": crontab(minute="*/60"),
|
||||
"schedule": crontab(minute=fqdn_rand("outpost_local_connection"), hour="*/8"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
||||
|
@ -52,7 +52,7 @@ def m2m_changed_update(sender, instance: Model, action: str, **_):
|
||||
|
||||
@receiver(post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def post_save_update(sender, instance: Model, **_):
|
||||
def post_save_update(sender, instance: Model, created: bool, **_):
|
||||
"""If an Outpost is saved, Ensure that token is created/updated
|
||||
|
||||
If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved,
|
||||
@ -63,6 +63,9 @@ def post_save_update(sender, instance: Model, **_):
|
||||
return
|
||||
if not isinstance(instance, UPDATE_TRIGGERING_MODELS):
|
||||
return
|
||||
if isinstance(instance, Outpost) and created:
|
||||
LOGGER.info("New outpost saved, ensuring initial token and user are created")
|
||||
_ = instance.token
|
||||
outpost_post_save.delay(class_to_path(instance.__class__), instance.pk)
|
||||
|
||||
|
||||
|
@ -3,6 +3,7 @@ from typing import Any, Optional
|
||||
|
||||
from django.http.request import HttpRequest
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_DEBUG
|
||||
@ -37,4 +38,5 @@ class AccessDeniedResponse(TemplateResponse):
|
||||
self._request
|
||||
).get(USER_ATTRIBUTE_DEBUG, False):
|
||||
context["policy_result"] = self.policy_result
|
||||
context["cancel"] = reverse("authentik_flows:cancel")
|
||||
return context
|
||||
|
@ -21,3 +21,4 @@ class DummyPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = DummyPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -25,3 +25,4 @@ class EventMatcherPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = EventMatcherPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -21,3 +21,4 @@ class PasswordExpiryPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = PasswordExpiryPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -28,3 +28,4 @@ class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = ExpressionPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -20,4 +20,5 @@ class HaveIBeenPwendPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = HaveIBeenPwendPolicy.objects.all()
|
||||
serializer_class = HaveIBeenPwendPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
search_fields = ["name", "password_field"]
|
||||
ordering = ["name"]
|
||||
|
@ -9,6 +9,7 @@ from structlog.stdlib import get_logger
|
||||
from authentik.lib.utils.http import get_http_session
|
||||
from authentik.policies.models import Policy, PolicyResult
|
||||
from authentik.policies.types import PolicyRequest
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -38,14 +39,17 @@ class HaveIBeenPwendPolicy(Policy):
|
||||
"""Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5
|
||||
characters of Password in request and checks if full hash is in response. Returns 0
|
||||
if Password is not in result otherwise the count of how many times it was used."""
|
||||
if self.password_field not in request.context:
|
||||
password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get(
|
||||
self.password_field, request.context.get(self.password_field)
|
||||
)
|
||||
if not password:
|
||||
LOGGER.warning(
|
||||
"Password field not set in Policy Request",
|
||||
field=self.password_field,
|
||||
fields=request.context.keys(),
|
||||
)
|
||||
return PolicyResult(False, _("Password not set in context"))
|
||||
password = str(request.context[self.password_field])
|
||||
password = str(password)
|
||||
|
||||
pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec
|
||||
url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}"
|
||||
|
@ -5,6 +5,7 @@ from guardian.shortcuts import get_anonymous_user
|
||||
from authentik.lib.generators import generate_key
|
||||
from authentik.policies.hibp.models import HaveIBeenPwendPolicy
|
||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||
|
||||
|
||||
class TestHIBPPolicy(TestCase):
|
||||
@ -26,7 +27,7 @@ class TestHIBPPolicy(TestCase):
|
||||
name="test_false",
|
||||
)
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["password"] = "password" # nosec
|
||||
request.context[PLAN_CONTEXT_PROMPT] = {"password": "password"} # nosec
|
||||
result: PolicyResult = policy.passes(request)
|
||||
self.assertFalse(result.passing)
|
||||
self.assertTrue(result.messages[0].startswith("Password exists on "))
|
||||
@ -37,7 +38,7 @@ class TestHIBPPolicy(TestCase):
|
||||
name="test_true",
|
||||
)
|
||||
request = PolicyRequest(get_anonymous_user())
|
||||
request.context["password"] = generate_key()
|
||||
request.context[PLAN_CONTEXT_PROMPT] = {"password": generate_key()}
|
||||
result: PolicyResult = policy.passes(request)
|
||||
self.assertTrue(result.passing)
|
||||
self.assertEqual(result.messages, tuple())
|
||||
|
@ -30,3 +30,4 @@ class PasswordPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = PasswordPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -50,7 +50,6 @@ class TestPasswordPolicyFlow(FlowTestCase):
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||
{"password": "akadmin"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertStageResponse(
|
||||
response,
|
||||
self.flow,
|
||||
|
@ -151,5 +151,5 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
try:
|
||||
self.connection.send(self.profiling_wrapper())
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.warning(str(exc))
|
||||
LOGGER.warning("Policy failed to run", exc=exc)
|
||||
self.connection.send(PolicyResult(False, str(exc)))
|
||||
|
@ -26,6 +26,7 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = ReputationPolicy.objects.all()
|
||||
serializer_class = ReputationPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
search_fields = ["name", "threshold"]
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
|
@ -4,7 +4,7 @@ from celery.schedules import crontab
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"policies_reputation_save": {
|
||||
"task": "authentik.policies.reputation.tasks.save_reputation",
|
||||
"schedule": crontab(minute="*/5"),
|
||||
"schedule": crontab(minute="1-59/5"),
|
||||
"options": {"queue": "authentik_scheduled"},
|
||||
},
|
||||
}
|
||||
|
@ -12,8 +12,21 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block card %}
|
||||
<form method="POST" class="pf-c-form">
|
||||
<form class="pf-c-form">
|
||||
{% csrf_token %}
|
||||
{% if user.is_authenticated %}
|
||||
<div class="pf-c-form__group">
|
||||
<div class="form-control-static">
|
||||
<div class="avatar">
|
||||
<img class="pf-c-avatar" src="{{ user.avatar }}" alt="{% trans "User's avatar" %}" />
|
||||
{{ user.username }}
|
||||
</div>
|
||||
<div slot="link">
|
||||
<a href="{{ cancel }}">{% trans "Not you?" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="pf-c-form__group">
|
||||
<p>
|
||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||
|
@ -25,6 +25,7 @@ class LDAPProviderSerializer(ProviderSerializer):
|
||||
"gid_start_number",
|
||||
"outpost_set",
|
||||
"search_mode",
|
||||
"bind_mode",
|
||||
]
|
||||
|
||||
|
||||
@ -46,6 +47,7 @@ class LDAPProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"uid_start_number": ["iexact"],
|
||||
"gid_start_number": ["iexact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
@ -70,6 +72,7 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
|
||||
"uid_start_number",
|
||||
"gid_start_number",
|
||||
"search_mode",
|
||||
"bind_mode",
|
||||
]
|
||||
|
||||
|
||||
@ -79,3 +82,5 @@ class LDAPOutpostConfigViewSet(ReadOnlyModelViewSet):
|
||||
queryset = LDAPProvider.objects.filter(application__isnull=False)
|
||||
serializer_class = LDAPOutpostConfigSerializer
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
|
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.0.4 on 2022-05-08 13:43
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_providers_ldap", "0001_squashed_0005_ldapprovider_search_mode"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="ldapprovider",
|
||||
name="bind_mode",
|
||||
field=models.TextField(
|
||||
choices=[("direct", "Direct"), ("cached", "Cached")], default="direct"
|
||||
),
|
||||
),
|
||||
]
|
@ -10,8 +10,8 @@ from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.outposts.models import OutpostModel
|
||||
|
||||
|
||||
class SearchModes(models.TextChoices):
|
||||
"""Search modes"""
|
||||
class APIAccessMode(models.TextChoices):
|
||||
"""API Access modes"""
|
||||
|
||||
DIRECT = "direct"
|
||||
CACHED = "cached"
|
||||
@ -66,7 +66,8 @@ class LDAPProvider(OutpostModel, Provider):
|
||||
),
|
||||
)
|
||||
|
||||
search_mode = models.TextField(default=SearchModes.DIRECT, choices=SearchModes.choices)
|
||||
bind_mode = models.TextField(default=APIAccessMode.DIRECT, choices=APIAccessMode.choices)
|
||||
search_mode = models.TextField(default=APIAccessMode.DIRECT, choices=APIAccessMode.choices)
|
||||
|
||||
@property
|
||||
def launch_url(self) -> Optional[str]:
|
||||
|
@ -71,6 +71,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
]
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
||||
@extend_schema(
|
||||
|
@ -39,3 +39,4 @@ class ScopeMappingViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = ScopeMappingSerializer
|
||||
filterset_class = ScopeMappingFilter
|
||||
ordering = ["scope_name", "name"]
|
||||
search_fields = ["name", "scope_name"]
|
||||
|
@ -11,7 +11,7 @@ CLIENT_ASSERTION = "client_assertion"
|
||||
CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||
|
||||
PROMPT_NONE = "none"
|
||||
PROMPT_CONSNET = "consent"
|
||||
PROMPT_CONSENT = "consent"
|
||||
PROMPT_LOGIN = "login"
|
||||
|
||||
SCOPE_OPENID = "openid"
|
||||
|
@ -24,7 +24,7 @@ class OAuth2Error(SentryIgnoredException):
|
||||
return self.error
|
||||
|
||||
def to_event(self, message: Optional[str] = None, **kwargs) -> Event:
|
||||
"""Create configuration_error Event and save it."""
|
||||
"""Create configuration_error Event."""
|
||||
return Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message=message or self.description,
|
||||
|
@ -50,6 +50,7 @@ class ResponseMode(models.TextChoices):
|
||||
|
||||
QUERY = "query"
|
||||
FRAGMENT = "fragment"
|
||||
FORM_POST = "form_post"
|
||||
|
||||
|
||||
class SubModes(models.TextChoices):
|
||||
|
@ -5,7 +5,6 @@ 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.flows.challenge import ChallengeTypes
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, RedirectUriError
|
||||
from authentik.providers.oauth2.models import (
|
||||
@ -79,6 +78,28 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
def test_invalid_redirect_uri_regex(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
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)
|
||||
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_empty_redirect_uri(self):
|
||||
"""test empty redirect URI (configure in provider)"""
|
||||
OAuth2Provider.objects.create(
|
||||
@ -178,7 +199,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
|
||||
def test_full_code(self):
|
||||
"""Test full authorization"""
|
||||
flow = Flow.objects.create(slug="empty")
|
||||
flow = create_test_flow()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id="test",
|
||||
@ -214,7 +235,7 @@ class TestAuthorize(OAuthTestCase):
|
||||
|
||||
def test_full_implicit(self):
|
||||
"""Test full authorization"""
|
||||
flow = Flow.objects.create(slug="empty")
|
||||
flow = create_test_flow()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id="test",
|
||||
@ -255,3 +276,52 @@ class TestAuthorize(OAuthTestCase):
|
||||
},
|
||||
)
|
||||
self.validate_jwt(token, provider)
|
||||
|
||||
def test_full_form_post(self):
|
||||
"""Test full authorization (form_post response)"""
|
||||
flow = create_test_flow()
|
||||
provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id="test",
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=flow,
|
||||
redirect_uris="http://localhost",
|
||||
signing_key=create_test_cert(),
|
||||
)
|
||||
Application.objects.create(name="app", slug="app", 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": "id_token",
|
||||
"response_mode": "form_post",
|
||||
"client_id": "test",
|
||||
"state": state,
|
||||
"scope": "openid",
|
||||
"redirect_uri": "http://localhost",
|
||||
},
|
||||
)
|
||||
response = self.client.get(
|
||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||
)
|
||||
token: RefreshToken = RefreshToken.objects.filter(user=user).first()
|
||||
self.assertJSONEqual(
|
||||
response.content.decode(),
|
||||
{
|
||||
"component": "ak-stage-autosubmit",
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"url": "http://localhost",
|
||||
"title": "Redirecting to app...",
|
||||
"attrs": {
|
||||
"access_token": token.access_token,
|
||||
"id_token": provider.encode(token.id_token.to_dict()),
|
||||
"token_type": "bearer",
|
||||
"expires_in": "60",
|
||||
"state": state,
|
||||
},
|
||||
},
|
||||
)
|
||||
self.validate_jwt(token, provider)
|
||||
|
@ -1,6 +1,8 @@
|
||||
"""authentik OAuth2 Authorization views"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from re import error as RegexError
|
||||
from re import escape, fullmatch
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
@ -15,6 +17,12 @@ from structlog.stdlib import get_logger
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.utils import get_user
|
||||
from authentik.flows.challenge import (
|
||||
PLAN_CONTEXT_TITLE,
|
||||
AutosubmitChallenge,
|
||||
ChallengeTypes,
|
||||
HttpChallengeResponse,
|
||||
)
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.planner import (
|
||||
PLAN_CONTEXT_APPLICATION,
|
||||
@ -30,7 +38,7 @@ from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.types import PolicyRequest
|
||||
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
||||
from authentik.providers.oauth2.constants import (
|
||||
PROMPT_CONSNET,
|
||||
PROMPT_CONSENT,
|
||||
PROMPT_LOGIN,
|
||||
PROMPT_NONE,
|
||||
SCOPE_OPENID,
|
||||
@ -63,7 +71,7 @@ LOGGER = get_logger()
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
SESSION_NEEDS_LOGIN = "authentik_oauth2_needs_login"
|
||||
|
||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET, PROMPT_LOGIN}
|
||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSENT, PROMPT_LOGIN}
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -74,6 +82,7 @@ class OAuthAuthorizationParams:
|
||||
client_id: str
|
||||
redirect_uri: str
|
||||
response_type: str
|
||||
response_mode: Optional[str]
|
||||
scope: list[str]
|
||||
state: str
|
||||
nonce: Optional[str]
|
||||
@ -125,11 +134,22 @@ class OAuthAuthorizationParams:
|
||||
LOGGER.warning("Invalid response type", type=response_type)
|
||||
raise AuthorizeError(redirect_uri, "unsupported_response_type", "", state)
|
||||
|
||||
# Validate and check the response_mode against the predefined dict
|
||||
# Set to Query or Fragment if not defined in request
|
||||
response_mode = query_dict.get("response_mode", False)
|
||||
|
||||
if response_mode not in ResponseMode.values:
|
||||
response_mode = ResponseMode.QUERY
|
||||
|
||||
if grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
response_mode = ResponseMode.FRAGMENT
|
||||
|
||||
max_age = query_dict.get("max_age")
|
||||
return OAuthAuthorizationParams(
|
||||
client_id=query_dict.get("client_id", ""),
|
||||
redirect_uri=redirect_uri,
|
||||
response_type=response_type,
|
||||
response_mode=response_mode,
|
||||
grant_type=grant_type,
|
||||
scope=query_dict.get("scope", "").split(),
|
||||
state=state,
|
||||
@ -155,31 +175,32 @@ class OAuthAuthorizationParams:
|
||||
def check_redirect_uri(self):
|
||||
"""Redirect URI validation."""
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
# We don't want to actually lowercase the final URL we redirect to,
|
||||
# we only lowercase it for comparison
|
||||
redirect_uri = self.redirect_uri.lower()
|
||||
if not redirect_uri:
|
||||
if not self.redirect_uri:
|
||||
LOGGER.warning("Missing redirect uri.")
|
||||
raise RedirectUriError("", allowed_redirect_urls)
|
||||
|
||||
if self.provider.redirect_uris == "":
|
||||
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = self.redirect_uri
|
||||
self.provider.redirect_uris = escape(self.redirect_uri)
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
|
||||
if self.provider.redirect_uris == "*":
|
||||
LOGGER.warning(
|
||||
"Provider has wildcard allowed redirect_uri set, allowing all.",
|
||||
allow=self.redirect_uri,
|
||||
)
|
||||
return
|
||||
if redirect_uri not in [x.lower() for x in allowed_redirect_urls]:
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
excepted=allowed_redirect_urls,
|
||||
)
|
||||
LOGGER.info("Converting redirect_uris to regex", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = ".*"
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
excepted=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)
|
||||
if self.request:
|
||||
raise AuthorizeError(
|
||||
@ -239,167 +260,6 @@ class OAuthAuthorizationParams:
|
||||
return code
|
||||
|
||||
|
||||
class OAuthFulfillmentStage(StageView):
|
||||
"""Final stage, restores params from Flow."""
|
||||
|
||||
params: OAuthAuthorizationParams
|
||||
provider: OAuth2Provider
|
||||
|
||||
def redirect(self, uri: str) -> HttpResponse:
|
||||
"""Redirect using HttpResponseRedirectScheme, compatible with non-http schemes"""
|
||||
parsed = urlparse(uri)
|
||||
return HttpResponseRedirectScheme(uri, allowed_schemes=[parsed.scheme])
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Wrapper when this stage gets hit with a post request"""
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""final Stage of an OAuth2 Flow"""
|
||||
if PLAN_CONTEXT_PARAMS not in self.executor.plan.context:
|
||||
LOGGER.warning("Got to fulfillment stage with no pending context")
|
||||
return HttpResponseBadRequest()
|
||||
self.params: OAuthAuthorizationParams = self.executor.plan.context.pop(PLAN_CONTEXT_PARAMS)
|
||||
application: Application = self.executor.plan.context.pop(PLAN_CONTEXT_APPLICATION)
|
||||
self.provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
|
||||
try:
|
||||
# At this point we don't need to check permissions anymore
|
||||
if {PROMPT_NONE, PROMPT_CONSNET}.issubset(self.params.prompt):
|
||||
raise AuthorizeError(
|
||||
self.params.redirect_uri,
|
||||
"consent_required",
|
||||
self.params.grant_type,
|
||||
self.params.state,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=application,
|
||||
flow=self.executor.plan.flow_pk,
|
||||
scopes=", ".join(self.params.scope),
|
||||
).from_http(self.request)
|
||||
return self.redirect(self.create_response_uri())
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
error.to_event(application=application).from_http(request)
|
||||
self.executor.stage_invalid()
|
||||
# pylint: disable=no-member
|
||||
return bad_request_message(request, error.description, title=error.error)
|
||||
except AuthorizeError as error:
|
||||
error.to_event(application=application).from_http(request)
|
||||
self.executor.stage_invalid()
|
||||
return self.redirect(error.create_uri())
|
||||
|
||||
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
|
||||
|
||||
if self.params.grant_type in [
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
GrantTypes.HYBRID,
|
||||
]:
|
||||
code = self.params.create_code(self.request)
|
||||
code.save(force_insert=True)
|
||||
|
||||
query_dict = self.request.POST if self.request.method == "POST" else self.request.GET
|
||||
response_mode = ResponseMode.QUERY
|
||||
# Get response mode from url param, otherwise decide based on grant type
|
||||
if "response_mode" in query_dict:
|
||||
response_mode = query_dict["response_mode"]
|
||||
elif self.params.grant_type == GrantTypes.AUTHORIZATION_CODE:
|
||||
response_mode = ResponseMode.QUERY
|
||||
elif self.params.grant_type in [GrantTypes.IMPLICIT, GrantTypes.HYBRID]:
|
||||
response_mode = ResponseMode.FRAGMENT
|
||||
|
||||
if response_mode == ResponseMode.QUERY:
|
||||
query_params["code"] = code.code
|
||||
query_params["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
|
||||
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
||||
return urlunsplit(uri)
|
||||
if response_mode == ResponseMode.FRAGMENT:
|
||||
query_fragment = self.create_implicit_response(code)
|
||||
|
||||
uri = uri._replace(
|
||||
fragment=uri.fragment + urlencode(query_fragment, doseq=True),
|
||||
)
|
||||
return urlunsplit(uri)
|
||||
raise OAuth2Error()
|
||||
except OAuth2Error as error:
|
||||
LOGGER.warning("Error when trying to create response uri", error=error)
|
||||
raise AuthorizeError(
|
||||
self.params.redirect_uri,
|
||||
"server_error",
|
||||
self.params.grant_type,
|
||||
self.params.state,
|
||||
)
|
||||
|
||||
def create_implicit_response(self, code: Optional[AuthorizationCode]) -> dict:
|
||||
"""Create implicit response's URL Fragment dictionary"""
|
||||
query_fragment = {}
|
||||
|
||||
token = self.provider.create_refresh_token(
|
||||
user=self.request.user,
|
||||
scope=self.params.scope,
|
||||
request=self.request,
|
||||
)
|
||||
|
||||
# Check if response_type must include access_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
]:
|
||||
query_fragment["access_token"] = token.access_token
|
||||
|
||||
# We don't need id_token if it's an OAuth2 request.
|
||||
if SCOPE_OPENID in self.params.scope:
|
||||
id_token = token.create_id_token(
|
||||
user=self.request.user,
|
||||
request=self.request,
|
||||
)
|
||||
id_token.nonce = self.params.nonce
|
||||
|
||||
# Include at_hash when access_token is being returned.
|
||||
if "access_token" in query_fragment:
|
||||
id_token.at_hash = token.at_hash
|
||||
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
id_token.c_hash = code.c_hash
|
||||
|
||||
# Check if response_type must include id_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
query_fragment["id_token"] = self.provider.encode(id_token.to_dict())
|
||||
token.id_token = id_token
|
||||
|
||||
# Store the token.
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
query_fragment["code"] = code.code
|
||||
|
||||
query_fragment["token_type"] = "bearer" # nosec
|
||||
query_fragment["expires_in"] = int(
|
||||
timedelta_from_string(self.provider.access_code_validity).total_seconds()
|
||||
)
|
||||
query_fragment["state"] = self.params.state if self.params.state else ""
|
||||
|
||||
return query_fragment
|
||||
|
||||
|
||||
class AuthorizationFlowInitView(PolicyAccessView):
|
||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||
|
||||
@ -414,10 +274,10 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
try:
|
||||
self.params = OAuthAuthorizationParams.from_request(self.request)
|
||||
except AuthorizeError as error:
|
||||
error.to_event(redirect_uri=error.redirect_uri).from_http(self.request)
|
||||
LOGGER.warning(error.description, redirect_uri=error.redirect_uri)
|
||||
raise RequestValidationError(HttpResponseRedirect(error.create_uri()))
|
||||
except OAuth2Error as error:
|
||||
error.to_event().from_http(self.request)
|
||||
LOGGER.warning(error.description)
|
||||
raise RequestValidationError(
|
||||
bad_request_message(self.request, error.description, title=error.error)
|
||||
)
|
||||
@ -494,7 +354,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
)
|
||||
# OpenID clients can specify a `prompt` parameter, and if its set to consent we
|
||||
# need to inject a consent stage
|
||||
if PROMPT_CONSNET in self.params.prompt:
|
||||
if PROMPT_CONSENT in self.params.prompt:
|
||||
if not any(isinstance(x.stage, ConsentStageView) for x in plan.bindings):
|
||||
# Plan does not have any consent stage, so we add an in-memory one
|
||||
stage = ConsentStage(
|
||||
@ -502,10 +362,206 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
mode=ConsentMode.ALWAYS_REQUIRE,
|
||||
)
|
||||
plan.append_stage(stage)
|
||||
|
||||
plan.append_stage(in_memory_stage(OAuthFulfillmentStage))
|
||||
|
||||
self.request.session[SESSION_KEY_PLAN] = plan
|
||||
return redirect_with_qs(
|
||||
"authentik_core:if-flow",
|
||||
self.request.GET,
|
||||
flow_slug=self.provider.authorization_flow.slug,
|
||||
)
|
||||
|
||||
|
||||
class OAuthFulfillmentStage(StageView):
|
||||
"""Final stage, restores params from Flow."""
|
||||
|
||||
params: OAuthAuthorizationParams
|
||||
provider: OAuth2Provider
|
||||
application: Application
|
||||
|
||||
def redirect(self, uri: str) -> HttpResponse:
|
||||
"""Redirect using HttpResponseRedirectScheme, compatible with non-http schemes"""
|
||||
parsed = urlparse(uri)
|
||||
|
||||
if self.params.response_mode == ResponseMode.FORM_POST:
|
||||
# parse_qs returns a dictionary with values wrapped in lists, however
|
||||
# we need a flat dictionary for the autosubmit challenge
|
||||
|
||||
# this picks the first item in the list if the value is a list,
|
||||
# otherwise just the value as-is
|
||||
query_params = dict(
|
||||
(k, v[0] if isinstance(v, list) else v) for k, v in parse_qs(parsed.query).items()
|
||||
)
|
||||
|
||||
challenge = AutosubmitChallenge(
|
||||
data={
|
||||
"type": ChallengeTypes.NATIVE.value,
|
||||
"component": "ak-stage-autosubmit",
|
||||
"title": (
|
||||
self.executor.plan.context.get(
|
||||
PLAN_CONTEXT_TITLE,
|
||||
_("Redirecting to %(app)s..." % {"app": self.application.name}),
|
||||
)
|
||||
),
|
||||
"url": self.params.redirect_uri,
|
||||
"attrs": query_params,
|
||||
}
|
||||
)
|
||||
|
||||
challenge.is_valid()
|
||||
|
||||
return HttpChallengeResponse(
|
||||
challenge=challenge,
|
||||
)
|
||||
|
||||
return HttpResponseRedirectScheme(uri, allowed_schemes=[parsed.scheme])
|
||||
|
||||
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""Wrapper when this stage gets hit with a post request"""
|
||||
return self.get(request, *args, **kwargs)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||
"""final Stage of an OAuth2 Flow"""
|
||||
if PLAN_CONTEXT_PARAMS not in self.executor.plan.context:
|
||||
LOGGER.warning("Got to fulfillment stage with no pending context")
|
||||
return HttpResponseBadRequest()
|
||||
self.params: OAuthAuthorizationParams = self.executor.plan.context.pop(PLAN_CONTEXT_PARAMS)
|
||||
self.application: Application = self.executor.plan.context.pop(PLAN_CONTEXT_APPLICATION)
|
||||
self.provider = get_object_or_404(OAuth2Provider, pk=self.application.provider_id)
|
||||
try:
|
||||
# At this point we don't need to check permissions anymore
|
||||
if {PROMPT_NONE, PROMPT_CONSENT}.issubset(self.params.prompt):
|
||||
raise AuthorizeError(
|
||||
self.params.redirect_uri,
|
||||
"consent_required",
|
||||
self.params.grant_type,
|
||||
self.params.state,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.AUTHORIZE_APPLICATION,
|
||||
authorized_application=self.application,
|
||||
flow=self.executor.plan.flow_pk,
|
||||
scopes=", ".join(self.params.scope),
|
||||
).from_http(self.request)
|
||||
return self.redirect(self.create_response_uri())
|
||||
except (ClientIdError, RedirectUriError) as error:
|
||||
error.to_event(application=self.application).from_http(request)
|
||||
self.executor.stage_invalid()
|
||||
# pylint: disable=no-member
|
||||
return bad_request_message(request, error.description, title=error.error)
|
||||
except AuthorizeError as error:
|
||||
error.to_event(application=self.application).from_http(request)
|
||||
self.executor.stage_invalid()
|
||||
return self.redirect(error.create_uri())
|
||||
|
||||
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
|
||||
|
||||
if self.params.grant_type in [
|
||||
GrantTypes.AUTHORIZATION_CODE,
|
||||
GrantTypes.HYBRID,
|
||||
]:
|
||||
code = self.params.create_code(self.request)
|
||||
code.save(force_insert=True)
|
||||
|
||||
if self.params.response_mode == ResponseMode.QUERY:
|
||||
query_params["code"] = code.code
|
||||
query_params["state"] = [str(self.params.state) if self.params.state else ""]
|
||||
|
||||
uri = uri._replace(query=urlencode(query_params, doseq=True))
|
||||
return urlunsplit(uri)
|
||||
|
||||
if self.params.response_mode == ResponseMode.FRAGMENT:
|
||||
query_fragment = self.create_implicit_response(code)
|
||||
|
||||
uri = uri._replace(
|
||||
fragment=uri.fragment + urlencode(query_fragment, doseq=True),
|
||||
)
|
||||
|
||||
return urlunsplit(uri)
|
||||
|
||||
if self.params.response_mode == ResponseMode.FORM_POST:
|
||||
post_params = self.create_implicit_response(code)
|
||||
|
||||
uri = uri._replace(query=urlencode(post_params, doseq=True))
|
||||
|
||||
return urlunsplit(uri)
|
||||
|
||||
raise OAuth2Error()
|
||||
except OAuth2Error as error:
|
||||
LOGGER.warning("Error when trying to create response uri", error=error)
|
||||
raise AuthorizeError(
|
||||
self.params.redirect_uri,
|
||||
"server_error",
|
||||
self.params.grant_type,
|
||||
self.params.state,
|
||||
)
|
||||
|
||||
def create_implicit_response(self, code: Optional[AuthorizationCode]) -> dict:
|
||||
"""Create implicit response's URL Fragment dictionary"""
|
||||
query_fragment = {}
|
||||
|
||||
token = self.provider.create_refresh_token(
|
||||
user=self.request.user,
|
||||
scope=self.params.scope,
|
||||
request=self.request,
|
||||
)
|
||||
|
||||
# Check if response_type must include access_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.CODE_TOKEN,
|
||||
]:
|
||||
query_fragment["access_token"] = token.access_token
|
||||
|
||||
# We don't need id_token if it's an OAuth2 request.
|
||||
if SCOPE_OPENID in self.params.scope:
|
||||
id_token = token.create_id_token(
|
||||
user=self.request.user,
|
||||
request=self.request,
|
||||
)
|
||||
id_token.nonce = self.params.nonce
|
||||
|
||||
# Include at_hash when access_token is being returned.
|
||||
if "access_token" in query_fragment:
|
||||
id_token.at_hash = token.at_hash
|
||||
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
id_token.c_hash = code.c_hash
|
||||
|
||||
# Check if response_type must include id_token in the response.
|
||||
if self.params.response_type in [
|
||||
ResponseTypes.ID_TOKEN,
|
||||
ResponseTypes.ID_TOKEN_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN,
|
||||
ResponseTypes.CODE_ID_TOKEN_TOKEN,
|
||||
]:
|
||||
query_fragment["id_token"] = self.provider.encode(id_token.to_dict())
|
||||
token.id_token = id_token
|
||||
|
||||
# Store the token.
|
||||
token.save()
|
||||
|
||||
# Code parameter must be present if it's Hybrid Flow.
|
||||
if self.params.grant_type == GrantTypes.HYBRID:
|
||||
query_fragment["code"] = code.code
|
||||
|
||||
query_fragment["token_type"] = "bearer" # nosec
|
||||
query_fragment["expires_in"] = int(
|
||||
timedelta_from_string(self.provider.access_code_validity).total_seconds()
|
||||
)
|
||||
query_fragment["state"] = self.params.state if self.params.state else ""
|
||||
|
||||
return query_fragment
|
||||
|
@ -2,12 +2,15 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from dataclasses import InitVar, dataclass
|
||||
from hashlib import sha256
|
||||
from re import error as RegexError
|
||||
from re import fullmatch
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.timezone import datetime, now
|
||||
from django.views import View
|
||||
from jwt import InvalidTokenError, decode
|
||||
from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import (
|
||||
@ -94,16 +97,19 @@ class TokenParams:
|
||||
)
|
||||
|
||||
def __check_policy_access(self, app: Application, request: HttpRequest, **kwargs):
|
||||
engine = PolicyEngine(app, self.user, request)
|
||||
engine.request.context["oauth_scopes"] = self.scope
|
||||
engine.request.context["oauth_grant_type"] = self.grant_type
|
||||
engine.request.context["oauth_code_verifier"] = self.code_verifier
|
||||
engine.request.context.update(kwargs)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if not result.passing:
|
||||
LOGGER.info("User not authenticated for application", user=self.user, app=app)
|
||||
raise TokenError("invalid_grant")
|
||||
with Hub.current.start_span(
|
||||
op="authentik.providers.oauth2.token.policy",
|
||||
):
|
||||
engine = PolicyEngine(app, self.user, request)
|
||||
engine.request.context["oauth_scopes"] = self.scope
|
||||
engine.request.context["oauth_grant_type"] = self.grant_type
|
||||
engine.request.context["oauth_code_verifier"] = self.code_verifier
|
||||
engine.request.context.update(kwargs)
|
||||
engine.build()
|
||||
result = engine.result
|
||||
if not result.passing:
|
||||
LOGGER.info("User not authenticated for application", user=self.user, app=app)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||
if self.grant_type in [GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN]:
|
||||
@ -118,11 +124,20 @@ class TokenParams:
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||
self.__post_init_code(raw_code)
|
||||
with Hub.current.start_span(
|
||||
op="authentik.providers.oauth2.post.parse.code",
|
||||
):
|
||||
self.__post_init_code(raw_code)
|
||||
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||
self.__post_init_refresh(raw_token, request)
|
||||
with Hub.current.start_span(
|
||||
op="authentik.providers.oauth2.post.parse.refresh",
|
||||
):
|
||||
self.__post_init_refresh(raw_token, request)
|
||||
elif self.grant_type in [GRANT_TYPE_CLIENT_CREDENTIALS, GRANT_TYPE_PASSWORD]:
|
||||
self.__post_init_client_credentials(request)
|
||||
with Hub.current.start_span(
|
||||
op="authentik.providers.oauth2.post.parse.client_credentials",
|
||||
):
|
||||
self.__post_init_client_credentials(request)
|
||||
else:
|
||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||
raise TokenError("unsupported_grant_type")
|
||||
@ -133,19 +148,18 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
if self.provider.redirect_uris == "*":
|
||||
LOGGER.warning(
|
||||
"Provider has wildcard allowed redirect_uri set, allowing all.",
|
||||
redirect=self.redirect_uri,
|
||||
)
|
||||
# At this point, no provider should have a blank redirect_uri, in case they do
|
||||
# this will check an empty array and raise an error
|
||||
elif self.redirect_uri not in [x.lower() for x in allowed_redirect_urls]:
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect=self.redirect_uri,
|
||||
expected=self.provider.redirect_uris.split(),
|
||||
)
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
excepted=allowed_redirect_urls,
|
||||
)
|
||||
raise TokenError("invalid_client")
|
||||
except RegexError as exc:
|
||||
LOGGER.warning("Invalid regular expression configured", exc=exc)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
try:
|
||||
@ -228,6 +242,11 @@ class TokenParams:
|
||||
if not token or token.user.uid != user.uid:
|
||||
raise TokenError("invalid_grant")
|
||||
self.user = user
|
||||
# Authorize user access
|
||||
app = Application.objects.filter(provider=self.provider).first()
|
||||
if not app or not app.provider:
|
||||
raise TokenError("invalid_grant")
|
||||
self.__check_policy_access(app, request)
|
||||
|
||||
Event.new(
|
||||
action=EventAction.LOGIN,
|
||||
@ -235,13 +254,8 @@ class TokenParams:
|
||||
PLAN_CONTEXT_METHOD_ARGS={
|
||||
"identifier": token.identifier,
|
||||
},
|
||||
PLAN_CONTEXT_APPLICATION=app,
|
||||
).from_http(request, user=user)
|
||||
|
||||
# Authorize user access
|
||||
app = Application.objects.filter(provider=self.provider).first()
|
||||
if not app or not app.provider:
|
||||
raise TokenError("invalid_grant")
|
||||
self.__check_policy_access(app, request)
|
||||
return None
|
||||
|
||||
def __post_init_client_credentials_jwt(self, request: HttpRequest):
|
||||
@ -288,18 +302,7 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
self.__check_policy_access(app, request, oauth_jwt=token)
|
||||
|
||||
self.user, _ = User.objects.update_or_create(
|
||||
username=f"{self.provider.name}-{token.get('sub')}",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
USER_ATTRIBUTE_EXPIRES: token.get("exp"),
|
||||
},
|
||||
"last_login": now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials JWT)",
|
||||
},
|
||||
)
|
||||
self.__create_user_from_jwt(token, app)
|
||||
|
||||
Event.new(
|
||||
action=EventAction.LOGIN,
|
||||
@ -307,8 +310,26 @@ class TokenParams:
|
||||
PLAN_CONTEXT_METHOD_ARGS={
|
||||
"jwt": token,
|
||||
},
|
||||
PLAN_CONTEXT_APPLICATION=app,
|
||||
).from_http(request, user=self.user)
|
||||
|
||||
def __create_user_from_jwt(self, token: dict[str, Any], app: Application):
|
||||
"""Create user from JWT"""
|
||||
exp = token.get("exp")
|
||||
self.user, created = User.objects.update_or_create(
|
||||
username=f"{self.provider.name}-{token.get('sub')}",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
},
|
||||
"last_login": now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials JWT)",
|
||||
},
|
||||
)
|
||||
if created and exp:
|
||||
self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
|
||||
self.user.save()
|
||||
|
||||
|
||||
class TokenView(View):
|
||||
"""Generate tokens for clients"""
|
||||
@ -330,27 +351,33 @@ class TokenView(View):
|
||||
def post(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Generate tokens for clients"""
|
||||
try:
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
try:
|
||||
self.provider = OAuth2Provider.objects.get(client_id=client_id)
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
||||
raise TokenError("invalid_client")
|
||||
with Hub.current.start_span(
|
||||
op="authentik.providers.oauth2.post.parse",
|
||||
):
|
||||
client_id, client_secret = extract_client_auth(request)
|
||||
try:
|
||||
self.provider = OAuth2Provider.objects.get(client_id=client_id)
|
||||
except OAuth2Provider.DoesNotExist:
|
||||
LOGGER.warning("OAuth2Provider does not exist", client_id=client_id)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
if not self.provider:
|
||||
raise ValueError
|
||||
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
||||
if not self.provider:
|
||||
raise ValueError
|
||||
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
||||
|
||||
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||
LOGGER.debug("Converting authorization code to refresh token")
|
||||
return TokenResponse(self.create_code_response())
|
||||
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||
LOGGER.debug("Refreshing refresh token")
|
||||
return TokenResponse(self.create_refresh_response())
|
||||
if self.params.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS:
|
||||
LOGGER.debug("Client credentials grant")
|
||||
return TokenResponse(self.create_client_credentials_response())
|
||||
raise ValueError(f"Invalid grant_type: {self.params.grant_type}")
|
||||
with Hub.current.start_span(
|
||||
op="authentik.providers.oauth2.post.response",
|
||||
):
|
||||
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||
LOGGER.debug("Converting authorization code to refresh token")
|
||||
return TokenResponse(self.create_code_response())
|
||||
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||
LOGGER.debug("Refreshing refresh token")
|
||||
return TokenResponse(self.create_refresh_response())
|
||||
if self.params.grant_type == GRANT_TYPE_CLIENT_CREDENTIALS:
|
||||
LOGGER.debug("Client credentials grant")
|
||||
return TokenResponse(self.create_client_credentials_response())
|
||||
raise ValueError(f"Invalid grant_type: {self.params.grant_type}")
|
||||
except TokenError as error:
|
||||
return TokenResponse(error.create_dict(), status=400)
|
||||
except UserAuthError as error:
|
||||
|
@ -103,6 +103,7 @@ class ProxyProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"redirect_uris": ["iexact"],
|
||||
"cookie_domain": ["iexact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
@ -166,3 +167,5 @@ class ProxyOutpostConfigViewSet(ReadOnlyModelViewSet):
|
||||
queryset = ProxyProvider.objects.filter(application__isnull=False)
|
||||
serializer_class = ProxyOutpostConfigSerializer
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
|
@ -99,6 +99,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = SAMLProviderSerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
@ -216,4 +217,5 @@ class SAMLPropertyMappingViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = SAMLPropertyMapping.objects.all()
|
||||
serializer_class = SAMLPropertyMappingSerializer
|
||||
filterset_class = SAMLPropertyMappingFilter
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""SAML Identity Provider Metadata Processor"""
|
||||
from hashlib import sha256
|
||||
from typing import Iterator, Optional
|
||||
|
||||
import xmlsec # nosec
|
||||
@ -7,7 +8,6 @@ from django.urls import reverse
|
||||
from lxml.etree import Element, SubElement, tostring # nosec
|
||||
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import strip_pem_header
|
||||
from authentik.sources.saml.processors.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
@ -35,7 +35,7 @@ class MetadataProcessor:
|
||||
self.provider = provider
|
||||
self.http_request = request
|
||||
self.force_binding = None
|
||||
self.xml_id = get_random_id()
|
||||
self.xml_id = sha256(f"{provider.name}-{provider.pk}".encode("ascii")).hexdigest()
|
||||
|
||||
def get_signing_key_descriptor(self) -> Optional[Element]:
|
||||
"""Get Signing KeyDescriptor, if enabled for the provider"""
|
||||
|
@ -3,6 +3,7 @@ from base64 import b64decode
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import quote_plus
|
||||
from xml.etree.ElementTree import ParseError # nosec
|
||||
|
||||
import xmlsec
|
||||
from defusedxml import ElementTree
|
||||
@ -175,7 +176,10 @@ class AuthNRequestParser:
|
||||
)
|
||||
except xmlsec.Error as exc:
|
||||
raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
try:
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
except ParseError as exc:
|
||||
raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
|
||||
|
||||
def idp_initiated(self) -> AuthNRequest:
|
||||
"""Create IdP Initiated AuthNRequest"""
|
||||
|
@ -1,10 +1,12 @@
|
||||
"""Test Service-Provider Metadata Parser"""
|
||||
# flake8: noqa
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.providers.saml.processors.metadata_parser import ServiceProviderMetadataParser
|
||||
|
||||
METADATA_SIMPLE = """<?xml version="1.0"?>
|
||||
@ -66,6 +68,23 @@ class TestServiceProviderMetadataParser(TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.flow = create_test_flow()
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def test_consistent(self):
|
||||
"""Test that metadata generation is consistent"""
|
||||
provider = SAMLProvider.objects.create(
|
||||
name="test",
|
||||
authorization_flow=self.flow,
|
||||
)
|
||||
Application.objects.create(
|
||||
name="test",
|
||||
slug="test",
|
||||
provider=provider,
|
||||
)
|
||||
request = self.factory.get("/")
|
||||
metadata_a = MetadataProcessor(provider, request).build_entity_descriptor()
|
||||
metadata_b = MetadataProcessor(provider, request).build_entity_descriptor()
|
||||
self.assertEqual(metadata_a, metadata_b)
|
||||
|
||||
def test_simple(self):
|
||||
"""Test simple metadata without Signing"""
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user