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]
|
[bumpversion]
|
||||||
current_version = 2022.4.1
|
current_version = 2022.5.3
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
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
|
- security
|
||||||
- pr_wanted
|
- pr_wanted
|
||||||
- enhancement
|
- enhancement
|
||||||
|
- bug/confirmed
|
||||||
|
- enhancement/confirmed
|
||||||
|
- question
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
markComment: >
|
markComment: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
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/**') }}
|
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
||||||
- name: prepare web ui
|
- name: prepare web ui
|
||||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||||
|
working-directory: web
|
||||||
run: |
|
run: |
|
||||||
cd web
|
npm ci
|
||||||
npm i
|
|
||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
@ -169,9 +169,9 @@ jobs:
|
|||||||
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
|
||||||
- name: prepare web ui
|
- name: prepare web ui
|
||||||
if: steps.cache-web.outputs.cache-hit != 'true'
|
if: steps.cache-web.outputs.cache-hit != 'true'
|
||||||
|
working-directory: web/
|
||||||
run: |
|
run: |
|
||||||
cd web
|
npm ci
|
||||||
npm i
|
|
||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
@ -207,23 +207,23 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- 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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
id: ev
|
id: ev
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
uses: ./.github/actions/docker-setup
|
uses: ./.github/actions/docker-setup
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
tags: |
|
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
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- name: Run linter
|
- name: Prepare and generate API
|
||||||
run: |
|
run: |
|
||||||
# Create folder structure for go embeds
|
# Create folder structure for go embeds
|
||||||
mkdir -p web/dist
|
mkdir -p web/dist
|
||||||
mkdir -p website/help
|
mkdir -p website/help
|
||||||
touch web/dist/test website/help/test
|
touch web/dist/test website/help/test
|
||||||
docker run \
|
- name: Generate API
|
||||||
--rm \
|
run: make gen-client-go
|
||||||
-v $(pwd):/app \
|
- name: golangci-lint
|
||||||
-w /app \
|
uses: golangci/golangci-lint-action@v3
|
||||||
golangci/golangci-lint:v1.43 \
|
|
||||||
golangci-lint run -v --timeout 200s
|
|
||||||
test-unittest:
|
test-unittest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@ -37,6 +35,8 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
|
- name: Generate API
|
||||||
|
run: make gen-client-go
|
||||||
- name: Go unittests
|
- name: Go unittests
|
||||||
run: |
|
run: |
|
||||||
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./...
|
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./...
|
||||||
@ -63,23 +63,25 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- 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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: prepare variables
|
- name: prepare variables
|
||||||
id: ev
|
id: ev
|
||||||
uses: ./.github/actions/docker-setup
|
uses: ./.github/actions/docker-setup
|
||||||
env:
|
env:
|
||||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
- name: Login to Container Registry
|
- name: Login to Container Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Generate API
|
||||||
|
run: make gen-client-go
|
||||||
- name: Building Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
tags: |
|
tags: |
|
||||||
@ -108,15 +110,17 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.1.1
|
- uses: actions/setup-node@v3.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
|
- name: Generate API
|
||||||
|
run: make gen-client-go
|
||||||
- name: Build web
|
- name: Build web
|
||||||
|
working-directory: web/
|
||||||
run: |
|
run: |
|
||||||
cd web
|
npm ci
|
||||||
npm install
|
|
||||||
npm run build-proxy
|
npm run build-proxy
|
||||||
- name: Build outpost
|
- name: Build outpost
|
||||||
run: |
|
run: |
|
||||||
|
56
.github/workflows/ci-web.yml
vendored
56
.github/workflows/ci-web.yml
vendored
@ -15,56 +15,50 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.1.1
|
- uses: actions/setup-node@v3.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- run: |
|
- working-directory: web/
|
||||||
cd web
|
run: npm ci
|
||||||
npm install
|
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-web
|
run: make gen-client-web
|
||||||
- name: Eslint
|
- name: Eslint
|
||||||
run: |
|
working-directory: web/
|
||||||
cd web
|
run: npm run lint
|
||||||
npm run lint
|
|
||||||
lint-prettier:
|
lint-prettier:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.1.1
|
- uses: actions/setup-node@v3.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- run: |
|
- working-directory: web/
|
||||||
cd web
|
run: npm ci
|
||||||
npm install
|
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-web
|
run: make gen-client-web
|
||||||
- name: prettier
|
- name: prettier
|
||||||
run: |
|
working-directory: web/
|
||||||
cd web
|
run: npm run prettier-check
|
||||||
npm run prettier-check
|
|
||||||
lint-lit-analyse:
|
lint-lit-analyse:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.1.1
|
- uses: actions/setup-node@v3.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- run: |
|
- working-directory: web/
|
||||||
cd web
|
run: npm ci
|
||||||
npm install
|
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-web
|
run: make gen-client-web
|
||||||
- name: lit-analyse
|
- name: lit-analyse
|
||||||
run: |
|
working-directory: web/
|
||||||
cd web
|
run: npm run lit-analyse
|
||||||
npm run lit-analyse
|
|
||||||
ci-web-mark:
|
ci-web-mark:
|
||||||
needs:
|
needs:
|
||||||
- lint-eslint
|
- lint-eslint
|
||||||
@ -79,17 +73,15 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.1.1
|
- uses: actions/setup-node@v3.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- run: |
|
- working-directory: web/
|
||||||
cd web
|
run: npm ci
|
||||||
npm install
|
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-web
|
run: make gen-client-web
|
||||||
- name: build
|
- name: build
|
||||||
run: |
|
working-directory: web/
|
||||||
cd web
|
run: npm run build
|
||||||
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.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v1
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# 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).
|
# 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)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v1
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 https://git.io/JvXDl
|
||||||
@ -57,4 +57,4 @@ jobs:
|
|||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- 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:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Set up QEMU
|
- 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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: Docker Login Registry
|
- name: Docker Login Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
tags: |
|
tags: |
|
||||||
beryju/authentik:2022.4.1,
|
beryju/authentik:2022.5.3,
|
||||||
beryju/authentik:latest,
|
beryju/authentik:latest,
|
||||||
ghcr.io/goauthentik/server:2022.4.1,
|
ghcr.io/goauthentik/server:2022.5.3,
|
||||||
ghcr.io/goauthentik/server:latest
|
ghcr.io/goauthentik/server:latest
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
context: .
|
context: .
|
||||||
@ -50,28 +50,28 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- name: Set up QEMU
|
- 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
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v2
|
||||||
- name: Docker Login Registry
|
- name: Docker Login Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Building Docker Image
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
tags: |
|
tags: |
|
||||||
beryju/authentik-${{ matrix.type }}:2022.4.1,
|
beryju/authentik-${{ matrix.type }}:2022.5.3,
|
||||||
beryju/authentik-${{ matrix.type }}:latest,
|
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
|
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||||
file: ${{ matrix.type }}.Dockerfile
|
file: ${{ matrix.type }}.Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@ -91,15 +91,15 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.1.1
|
- uses: actions/setup-node@v3.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: web/package-lock.json
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Build web
|
- name: Build web
|
||||||
|
working-directory: web/
|
||||||
run: |
|
run: |
|
||||||
cd web
|
npm ci
|
||||||
npm install
|
|
||||||
npm run build-proxy
|
npm run build-proxy
|
||||||
- name: Build outpost
|
- name: Build outpost
|
||||||
run: |
|
run: |
|
||||||
@ -152,7 +152,7 @@ jobs:
|
|||||||
SENTRY_PROJECT: authentik
|
SENTRY_PROJECT: authentik
|
||||||
SENTRY_URL: https://sentry.beryju.org
|
SENTRY_URL: https://sentry.beryju.org
|
||||||
with:
|
with:
|
||||||
version: authentik@2022.4.1
|
version: authentik@2022.5.3
|
||||||
environment: beryjuorg-prod
|
environment: beryjuorg-prod
|
||||||
sourcemaps: './web/dist'
|
sourcemaps: './web/dist'
|
||||||
url_prefix: '~/static/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 ]
|
branches: [ master ]
|
||||||
paths:
|
paths:
|
||||||
- 'schema.yml'
|
- 'schema.yml'
|
||||||
|
workflow_dispatch:
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
# Setup .npmrc file to publish to npm
|
# Setup .npmrc file to publish to npm
|
||||||
- uses: actions/setup-node@v3.1.1
|
- uses: actions/setup-node@v3.2.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
- name: Generate API Client
|
- name: Generate API Client
|
||||||
run: make gen-web
|
run: make gen-client-web
|
||||||
- name: Publish package
|
- name: Publish package
|
||||||
|
working-directory: gen-ts-api/
|
||||||
run: |
|
run: |
|
||||||
cd web-api/
|
npm ci
|
||||||
npm i
|
|
||||||
npm publish
|
npm publish
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
|
||||||
- name: Upgrade /web
|
- name: Upgrade /web
|
||||||
|
working-directory: web/
|
||||||
run: |
|
run: |
|
||||||
cd web/
|
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||||
export VERSION=`node -e 'console.log(require("../web-api/package.json").version)'`
|
|
||||||
npm i @goauthentik/api@$VERSION
|
npm i @goauthentik/api@$VERSION
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v4
|
uses: peter-evans/create-pull-request@v4
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -202,5 +202,4 @@ media/
|
|||||||
*mmdb
|
*mmdb
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
/api/
|
/gen-*/
|
||||||
/web-api/
|
|
||||||
|
48
Dockerfile
48
Dockerfile
@ -1,22 +1,35 @@
|
|||||||
# Stage 1: Build website
|
# 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/
|
COPY ./website /work/website/
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
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
|
# 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 ./web /work/web/
|
||||||
COPY ./website /work/website/
|
COPY ./website /work/website/
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
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
|
# Stage 3: Poetry to requirements.txt export
|
||||||
FROM docker.io/golang:1.18.0-bullseye AS builder
|
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
|
WORKDIR /work
|
||||||
|
|
||||||
@ -31,7 +44,7 @@ COPY ./go.sum /work/go.sum
|
|||||||
|
|
||||||
RUN go build -o /work/authentik ./cmd/server/main.go
|
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
|
FROM docker.io/python:3.10.4-slim-bullseye
|
||||||
|
|
||||||
LABEL org.opencontainers.image.url https://goauthentik.io
|
LABEL org.opencontainers.image.url https://goauthentik.io
|
||||||
@ -43,19 +56,18 @@ WORKDIR /
|
|||||||
ARG GIT_BUILD_HASH
|
ARG GIT_BUILD_HASH
|
||||||
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
|
||||||
|
|
||||||
COPY ./pyproject.toml /
|
COPY --from=poetry-locker /work/requirements.txt /
|
||||||
COPY ./poetry.lock /
|
COPY --from=poetry-locker /work/requirements-dev.txt /
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
# Required for installing pip packages
|
||||||
curl ca-certificates gnupg git runit libpq-dev \
|
apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev && \
|
||||||
postgresql-client build-essential libxmlsec1-dev \
|
# Required for runtime
|
||||||
pkg-config libmaxminddb0 && \
|
apt-get install -y --no-install-recommends libxmlsec1-openssl libmaxminddb0 && \
|
||||||
pip install poetry && \
|
# Required for bootstrap & healtcheck
|
||||||
poetry config virtualenvs.create false && \
|
apt-get install -y --no-install-recommends curl runit && \
|
||||||
poetry install --no-dev && \
|
pip install --no-cache-dir -r /requirements.txt && \
|
||||||
rm -rf ~/.cache/pypoetry && \
|
apt-get remove --purge -y build-essential pkg-config libxmlsec1-dev && \
|
||||||
apt-get remove --purge -y build-essential git && \
|
|
||||||
apt-get autoremove --purge -y && \
|
apt-get autoremove --purge -y && \
|
||||||
apt-get clean && \
|
apt-get clean && \
|
||||||
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
|
||||||
|
61
Makefile
61
Makefile
@ -18,6 +18,15 @@ test-e2e-rest:
|
|||||||
test-go:
|
test-go:
|
||||||
go test -timeout 0 -v -race -cover ./...
|
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:
|
test:
|
||||||
coverage run manage.py test authentik
|
coverage run manage.py test authentik
|
||||||
coverage html
|
coverage html
|
||||||
@ -52,21 +61,21 @@ gen-clean:
|
|||||||
rm -rf web/api/src/
|
rm -rf web/api/src/
|
||||||
rm -rf api/
|
rm -rf api/
|
||||||
|
|
||||||
gen-web:
|
gen-client-web:
|
||||||
docker run \
|
docker run \
|
||||||
--rm -v ${PWD}:/local \
|
--rm -v ${PWD}:/local \
|
||||||
--user ${UID}:${GID} \
|
--user ${UID}:${GID} \
|
||||||
openapitools/openapi-generator-cli:v6.0.0-beta generate \
|
openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||||
-i /local/schema.yml \
|
-i /local/schema.yml \
|
||||||
-g typescript-fetch \
|
-g typescript-fetch \
|
||||||
-o /local/web-api \
|
-o /local/gen-ts-api \
|
||||||
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=@goauthentik/api,npmVersion=${NPM_VERSION}
|
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=@goauthentik/api,npmVersion=${NPM_VERSION}
|
||||||
mkdir -p web/node_modules/@goauthentik/api
|
mkdir -p web/node_modules/@goauthentik/api
|
||||||
\cp -fv scripts/web_api_readme.md web-api/README.md
|
\cp -fv scripts/web_api_readme.md gen-ts-api/README.md
|
||||||
cd web-api && npm i
|
cd gen-ts-api && npm i
|
||||||
\cp -rfv web-api/* web/node_modules/@goauthentik/api
|
\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
|
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O config.yaml
|
||||||
mkdir -p templates
|
mkdir -p templates
|
||||||
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O templates/README.mustache
|
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O templates/README.mustache
|
||||||
@ -74,15 +83,15 @@ gen-outpost:
|
|||||||
docker run \
|
docker run \
|
||||||
--rm -v ${PWD}:/local \
|
--rm -v ${PWD}:/local \
|
||||||
--user ${UID}:${GID} \
|
--user ${UID}:${GID} \
|
||||||
openapitools/openapi-generator-cli:v6.0.0-beta generate \
|
openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||||
-i /local/schema.yml \
|
-i /local/schema.yml \
|
||||||
-g go \
|
-g go \
|
||||||
-o /local/api \
|
-o /local/gen-go-api \
|
||||||
-c /local/config.yaml
|
-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/
|
rm -rf config.yaml ./templates/
|
||||||
|
|
||||||
gen: gen-build gen-clean gen-web
|
gen: gen-build gen-clean gen-client-web
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
python -m lifecycle.migrate
|
python -m lifecycle.migrate
|
||||||
@ -90,11 +99,18 @@ migrate:
|
|||||||
run:
|
run:
|
||||||
go run -v cmd/server/main.go
|
go run -v cmd/server/main.go
|
||||||
|
|
||||||
web-watch:
|
#########################
|
||||||
cd web && npm run watch
|
## Web
|
||||||
|
#########################
|
||||||
|
|
||||||
web: web-lint-fix web-lint web-extract
|
web: web-lint-fix web-lint web-extract
|
||||||
|
|
||||||
|
web-install:
|
||||||
|
cd web && npm ci
|
||||||
|
|
||||||
|
web-watch:
|
||||||
|
cd web && npm run watch
|
||||||
|
|
||||||
web-lint-fix:
|
web-lint-fix:
|
||||||
cd web && npm run prettier
|
cd web && npm run prettier
|
||||||
|
|
||||||
@ -105,6 +121,21 @@ web-lint:
|
|||||||
web-extract:
|
web-extract:
|
||||||
cd web && npm run 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
|
# These targets are use by GitHub actions to allow usage of matrix
|
||||||
# which makes the YAML File a lot smaller
|
# which makes the YAML File a lot smaller
|
||||||
|
|
||||||
@ -130,10 +161,8 @@ ci-pyright: ci--meta-debug
|
|||||||
ci-pending-migrations: ci--meta-debug
|
ci-pending-migrations: ci--meta-debug
|
||||||
./manage.py makemigrations --check
|
./manage.py makemigrations --check
|
||||||
|
|
||||||
install:
|
install: web-install website-install
|
||||||
poetry install
|
poetry install
|
||||||
cd web && npm i
|
|
||||||
cd website && npm i
|
|
||||||
|
|
||||||
a: install
|
a: install
|
||||||
tmux \
|
tmux \
|
||||||
|
@ -2,13 +2,16 @@
|
|||||||
from os import environ
|
from os import environ
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
__version__ = "2022.4.1"
|
__version__ = "2022.5.3"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
def get_build_hash(fallback: Optional[str] = None) -> str:
|
def get_build_hash(fallback: Optional[str] = None) -> str:
|
||||||
"""Get build hash"""
|
"""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:
|
def get_full_version() -> str:
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
"""authentik admin settings"""
|
"""authentik admin settings"""
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
"admin_latest_version": {
|
"admin_latest_version": {
|
||||||
"task": "authentik.admin.tasks.update_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"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -26,7 +26,7 @@ class TestAdminTasks(TestCase):
|
|||||||
|
|
||||||
def test_version_valid_response(self):
|
def test_version_valid_response(self):
|
||||||
"""Test Update checker with valid response"""
|
"""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)
|
mocker.get("https://version.goauthentik.io/version.json", json=RESPONSE_VALID)
|
||||||
update_latest_version.delay().get()
|
update_latest_version.delay().get()
|
||||||
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
|
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
|
||||||
|
@ -12,6 +12,8 @@ class OwnerFilter(BaseFilterBackend):
|
|||||||
owner_key = "user"
|
owner_key = "user"
|
||||||
|
|
||||||
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
|
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})
|
return queryset.filter(**{self.owner_key: request.user})
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,9 +8,6 @@ API Browser - {{ tenant.branding_title }}
|
|||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
|
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block body %}
|
|
||||||
<script>
|
<script>
|
||||||
function getCookie(name) {
|
function getCookie(name) {
|
||||||
let cookieValue = "";
|
let cookieValue = "";
|
||||||
@ -34,16 +31,58 @@ window.addEventListener('DOMContentLoaded', (event) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
<style>
|
||||||
|
img.logo {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem 0.5rem 1.5rem 0.5rem;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
<rapi-doc
|
<rapi-doc
|
||||||
spec-url="{{ path }}"
|
spec-url="{{ path }}"
|
||||||
heading-text="authentik"
|
heading-text=""
|
||||||
theme="dark"
|
theme="light"
|
||||||
render-style="view"
|
render-style="read"
|
||||||
|
default-schema-tab="schema"
|
||||||
primary-color="#fd4b2d"
|
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-url-load="false"
|
||||||
allow-spec-file-load="false">
|
allow-spec-file-load="false">
|
||||||
<div slot="logo">
|
<div slot="nav-logo">
|
||||||
<img src="{% static 'dist/assets/icons/icon.png' %}" style="width:50px; height:50px" />
|
<img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
||||||
</div>
|
</div>
|
||||||
</rapi-doc>
|
</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 %}
|
{% 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_SAVE_MEDIA = "can_save_media"
|
||||||
CAN_GEO_IP = "can_geo_ip"
|
CAN_GEO_IP = "can_geo_ip"
|
||||||
|
CAN_IMPERSONATE = "can_impersonate"
|
||||||
|
|
||||||
|
|
||||||
class ErrorReportingConfigSerializer(PassiveSerializer):
|
class ErrorReportingConfigSerializer(PassiveSerializer):
|
||||||
@ -63,6 +64,8 @@ class ConfigView(APIView):
|
|||||||
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
caps.append(Capabilities.CAN_SAVE_MEDIA)
|
||||||
if GEOIP_READER.enabled:
|
if GEOIP_READER.enabled:
|
||||||
caps.append(Capabilities.CAN_GEO_IP)
|
caps.append(Capabilities.CAN_GEO_IP)
|
||||||
|
if CONFIG.y_bool("impersonation"):
|
||||||
|
caps.append(Capabilities.CAN_IMPERSONATE)
|
||||||
return caps
|
return caps
|
||||||
|
|
||||||
@extend_schema(responses={200: ConfigSerializer(many=False)})
|
@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.tokens import TokenViewSet
|
||||||
from authentik.core.api.users import UserViewSet
|
from authentik.core.api.users import UserViewSet
|
||||||
from authentik.crypto.api import CertificateKeyPairViewSet
|
from authentik.crypto.api import CertificateKeyPairViewSet
|
||||||
from authentik.events.api.event import EventViewSet
|
from authentik.events.api.events import EventViewSet
|
||||||
from authentik.events.api.notification import NotificationViewSet
|
from authentik.events.api.notification_mappings import NotificationWebhookMappingViewSet
|
||||||
from authentik.events.api.notification_mapping import NotificationWebhookMappingViewSet
|
from authentik.events.api.notification_rules import NotificationRuleViewSet
|
||||||
from authentik.events.api.notification_rule import NotificationRuleViewSet
|
from authentik.events.api.notification_transports import NotificationTransportViewSet
|
||||||
from authentik.events.api.notification_transport import NotificationTransportViewSet
|
from authentik.events.api.notifications import NotificationViewSet
|
||||||
from authentik.flows.api.bindings import FlowStageBindingViewSet
|
from authentik.flows.api.bindings import FlowStageBindingViewSet
|
||||||
from authentik.flows.api.flows import FlowViewSet
|
from authentik.flows.api.flows import FlowViewSet
|
||||||
from authentik.flows.api.stages import StageViewSet
|
from authentik.flows.api.stages import StageViewSet
|
||||||
|
@ -89,6 +89,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"group",
|
"group",
|
||||||
]
|
]
|
||||||
lookup_field = "slug"
|
lookup_field = "slug"
|
||||||
|
filterset_fields = ["name", "slug"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
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 rest_framework.viewsets import GenericViewSet
|
||||||
from structlog.stdlib import get_logger
|
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.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
|
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
|
||||||
from authentik.core.models import Source, UserSourceConnection
|
from authentik.core.models import Source, UserSourceConnection
|
||||||
@ -66,6 +66,7 @@ class SourceViewSet(
|
|||||||
queryset = Source.objects.none()
|
queryset = Source.objects.none()
|
||||||
serializer_class = SourceSerializer
|
serializer_class = SourceSerializer
|
||||||
lookup_field = "slug"
|
lookup_field = "slug"
|
||||||
|
search_fields = ["slug", "name"]
|
||||||
|
|
||||||
def get_queryset(self): # pragma: no cover
|
def get_queryset(self): # pragma: no cover
|
||||||
return Source.objects.select_subclasses()
|
return Source.objects.select_subclasses()
|
||||||
@ -150,6 +151,6 @@ class UserSourceConnectionViewSet(
|
|||||||
|
|
||||||
queryset = UserSourceConnection.objects.all()
|
queryset = UserSourceConnection.objects.all()
|
||||||
serializer_class = UserSourceConnectionSerializer
|
serializer_class = UserSourceConnectionSerializer
|
||||||
permission_classes = [OwnerPermissions]
|
permission_classes = [OwnerSuperuserPermissions]
|
||||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
||||||
ordering = ["pk"]
|
ordering = ["pk"]
|
||||||
|
@ -17,6 +17,7 @@ from django_filters.filterset import FilterSet
|
|||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import (
|
from drf_spectacular.utils import (
|
||||||
OpenApiParameter,
|
OpenApiParameter,
|
||||||
|
OpenApiResponse,
|
||||||
extend_schema,
|
extend_schema,
|
||||||
extend_schema_field,
|
extend_schema_field,
|
||||||
inline_serializer,
|
inline_serializer,
|
||||||
@ -31,7 +32,6 @@ from rest_framework.serializers import (
|
|||||||
ListSerializer,
|
ListSerializer,
|
||||||
ModelSerializer,
|
ModelSerializer,
|
||||||
PrimaryKeyRelatedField,
|
PrimaryKeyRelatedField,
|
||||||
Serializer,
|
|
||||||
ValidationError,
|
ValidationError,
|
||||||
)
|
)
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
@ -72,6 +72,7 @@ class UserSerializer(ModelSerializer):
|
|||||||
)
|
)
|
||||||
groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
|
groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
|
||||||
uid = CharField(read_only=True)
|
uid = CharField(read_only=True)
|
||||||
|
username = CharField(max_length=150)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
@ -351,8 +352,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
responses={
|
responses={
|
||||||
204: "",
|
204: OpenApiResponse(description="Successfully changed password"),
|
||||||
400: "",
|
400: OpenApiResponse(description="Bad request"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"])
|
@action(detail=True, methods=["POST"])
|
||||||
@ -410,8 +411,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
responses={
|
responses={
|
||||||
"204": Serializer(),
|
"204": OpenApiResponse(description="Successfully sent recover email"),
|
||||||
"404": Serializer(),
|
"404": OpenApiResponse(description="Bad request"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@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 = (
|
objects = (
|
||||||
cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now())
|
cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now())
|
||||||
)
|
)
|
||||||
|
amount = objects.count()
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
obj.expire_action()
|
obj.expire_action()
|
||||||
amount = objects.count()
|
|
||||||
LOGGER.debug("Expired models", model=cls, amount=amount)
|
LOGGER.debug("Expired models", model=cls, amount=amount)
|
||||||
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
|
||||||
# Special case
|
# Special case
|
||||||
|
@ -8,6 +8,12 @@
|
|||||||
{% if flow.compatibility_mode and not inspector %}
|
{% if flow.compatibility_mode and not inspector %}
|
||||||
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<script>
|
||||||
|
window.authentik = {};
|
||||||
|
window.authentik.flow = {
|
||||||
|
"layout": "{{ flow.layout }}",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
@ -12,6 +12,25 @@
|
|||||||
.pf-c-background-image::before {
|
.pf-c-background-image::before {
|
||||||
--ak-flow-background: url("/static/dist/assets/images/flow_background.jpg");
|
--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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -59,13 +78,11 @@
|
|||||||
<a href="{{ link.href }}">{{ link.name }}</a>
|
<a href="{{ link.href }}">{{ link.name }}</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if tenant.branding_title != "authentik" %}
|
|
||||||
<li>
|
<li>
|
||||||
<a href="https://goauthentik.io?utm_source=authentik">
|
<a href="https://goauthentik.io?utm_source=authentik">
|
||||||
{% trans 'Powered by authentik' %}
|
{% trans 'Powered by authentik' %}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
|
||||||
</ul>
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
"""authentik URL Configuration"""
|
"""authentik URL Configuration"""
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
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 django.views.generic.base import TemplateView
|
||||||
|
|
||||||
from authentik.core.views import apps, impersonate
|
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.interface import FlowInterfaceView
|
||||||
from authentik.core.views.session import EndSessionView
|
from authentik.core.views.session import EndSessionView
|
||||||
|
|
||||||
@ -60,3 +62,8 @@ urlpatterns = [
|
|||||||
TemplateView.as_view(template_name="if/admin.html"),
|
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.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
@ -17,6 +18,9 @@ class ImpersonateInitView(View):
|
|||||||
|
|
||||||
def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
|
def get(self, request: HttpRequest, user_id: int) -> HttpResponse:
|
||||||
"""Impersonation handler, checks permissions"""
|
"""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"):
|
if not request.user.has_perm("impersonate"):
|
||||||
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
LOGGER.debug("User attempted to impersonate without permissions", user=request.user)
|
||||||
return HttpResponse("Unauthorized", status=401)
|
return HttpResponse("Unauthorized", status=401)
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
from authentik.lib.generators import generate_id
|
||||||
|
|
||||||
|
|
||||||
def create_self_signed(apps, schema_editor):
|
def create_self_signed(apps, schema_editor):
|
||||||
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
|
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
|
||||||
@ -9,7 +11,7 @@ def create_self_signed(apps, schema_editor):
|
|||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
|
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder()
|
||||||
builder.build()
|
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
||||||
CertificateKeyPair.objects.using(db_alias).create(
|
CertificateKeyPair.objects.using(db_alias).create(
|
||||||
name="authentik Self-signed Certificate",
|
name="authentik Self-signed Certificate",
|
||||||
certificate_data=builder.certificate,
|
certificate_data=builder.certificate,
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
"""Crypto task Settings"""
|
"""Crypto task Settings"""
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
"crypto_certificate_discovery": {
|
"crypto_certificate_discovery": {
|
||||||
"task": "authentik.crypto.tasks.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"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -26,3 +26,4 @@ class NotificationWebhookMappingViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = NotificationWebhookMappingSerializer
|
serializer_class = NotificationWebhookMappingSerializer
|
||||||
filterset_fields = ["name"]
|
filterset_fields = ["name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
@ -32,3 +32,4 @@ class NotificationRuleViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = NotificationRuleSerializer
|
serializer_class = NotificationRuleSerializer
|
||||||
filterset_fields = ["name", "severity", "group__name"]
|
filterset_fields = ["name", "severity", "group__name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name", "group__name"]
|
@ -68,6 +68,7 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
|
|||||||
queryset = NotificationTransport.objects.all()
|
queryset = NotificationTransport.objects.all()
|
||||||
serializer_class = NotificationTransportSerializer
|
serializer_class = NotificationTransportSerializer
|
||||||
filterset_fields = ["name", "mode", "webhook_url", "send_once"]
|
filterset_fields = ["name", "mode", "webhook_url", "send_once"]
|
||||||
|
search_fields = ["name", "mode", "webhook_url"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
@permission_required("authentik_events.change_notificationtransport")
|
@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.api.authorization import OwnerFilter, OwnerPermissions
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
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
|
from authentik.events.models import Notification
|
||||||
|
|
||||||
|
|
||||||
@ -55,6 +55,7 @@ class NotificationViewSet(
|
|||||||
"created",
|
"created",
|
||||||
"event",
|
"event",
|
||||||
"seen",
|
"seen",
|
||||||
|
"user",
|
||||||
]
|
]
|
||||||
permission_classes = [OwnerPermissions]
|
permission_classes = [OwnerPermissions]
|
||||||
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
|
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.sentry import before_send
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
|
|
||||||
IGNORED_MODELS = (
|
IGNORED_MODELS = [
|
||||||
Event,
|
Event,
|
||||||
Notification,
|
Notification,
|
||||||
UserObjectPermission,
|
UserObjectPermission,
|
||||||
AuthenticatedSession,
|
AuthenticatedSession,
|
||||||
StaticToken,
|
StaticToken,
|
||||||
)
|
]
|
||||||
|
if settings.DEBUG:
|
||||||
|
from silk.models import Request, Response, SQLQuery
|
||||||
|
|
||||||
|
IGNORED_MODELS += [Request, Response, SQLQuery]
|
||||||
|
IGNORED_MODELS = tuple(IGNORED_MODELS)
|
||||||
|
|
||||||
|
|
||||||
class AuditMiddleware:
|
class AuditMiddleware:
|
||||||
|
@ -383,6 +383,7 @@ class Migration(migrations.Migration):
|
|||||||
models.ManyToManyField(
|
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.",
|
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",
|
to="authentik_events.NotificationTransport",
|
||||||
|
blank=True,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -261,7 +261,7 @@ class Event(ExpiringModel):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self._state.adding:
|
if self._state.adding:
|
||||||
LOGGER.debug(
|
LOGGER.info(
|
||||||
"Created Event",
|
"Created Event",
|
||||||
action=self.action,
|
action=self.action,
|
||||||
context=self.context,
|
context=self.context,
|
||||||
@ -481,6 +481,7 @@ class NotificationRule(PolicyBindingModel):
|
|||||||
"selected, the notification will only be shown in the authentik UI."
|
"selected, the notification will only be shown in the authentik UI."
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
blank=True,
|
||||||
)
|
)
|
||||||
severity = models.TextField(
|
severity = models.TextField(
|
||||||
choices=NotificationSeverity.choices,
|
choices=NotificationSeverity.choices,
|
||||||
@ -518,7 +519,7 @@ class NotificationWebhookMapping(PropertyMapping):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> type["Serializer"]:
|
def serializer(self) -> type["Serializer"]:
|
||||||
from authentik.events.api.notification_mapping import NotificationWebhookMappingSerializer
|
from authentik.events.api.notification_mappings import NotificationWebhookMappingSerializer
|
||||||
|
|
||||||
return 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"""
|
"""Event notification tasks"""
|
||||||
|
from django.db.models.query_utils import Q
|
||||||
from guardian.shortcuts import get_anonymous_user
|
from guardian.shortcuts import get_anonymous_user
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
@ -10,7 +11,12 @@ from authentik.events.models import (
|
|||||||
NotificationTransport,
|
NotificationTransport,
|
||||||
NotificationTransportError,
|
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.engine import PolicyEngine
|
||||||
from authentik.policies.models import PolicyBinding, PolicyEngineMode
|
from authentik.policies.models import PolicyBinding, PolicyEngineMode
|
||||||
from authentik.root.celery import CELERY_APP
|
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)
|
events = Event.objects.filter(user__pk=user_pk)
|
||||||
LOGGER.debug("GDPR cleanup, removing events from user", events=events.count())
|
LOGGER.debug("GDPR cleanup, removing events from user", events=events.count())
|
||||||
events.delete()
|
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()
|
queryset = FlowStageBinding.objects.all()
|
||||||
serializer_class = FlowStageBindingSerializer
|
serializer_class = FlowStageBindingSerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
|
search_fields = ["stage__name"]
|
||||||
|
@ -72,6 +72,7 @@ class FlowSerializer(ModelSerializer):
|
|||||||
"policy_engine_mode",
|
"policy_engine_mode",
|
||||||
"compatibility_mode",
|
"compatibility_mode",
|
||||||
"export_url",
|
"export_url",
|
||||||
|
"layout",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"background": {"read_only": True},
|
"background": {"read_only": True},
|
||||||
@ -211,12 +212,30 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
]
|
]
|
||||||
body: list[DiagramElement] = []
|
body: list[DiagramElement] = []
|
||||||
footer = []
|
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(
|
for s_index, stage_binding in enumerate(
|
||||||
get_objects_for_user(request.user, "authentik_flows.view_flowstagebinding")
|
get_objects_for_user(request.user, "authentik_flows.view_flowstagebinding")
|
||||||
.filter(target=flow)
|
.filter(target=flow)
|
||||||
.order_by("order")
|
.order_by("order")
|
||||||
):
|
):
|
||||||
|
# First all policies bound to stages since they execute before stages
|
||||||
for p_index, policy_binding in enumerate(
|
for p_index, policy_binding in enumerate(
|
||||||
get_objects_for_user(request.user, "authentik_policies.view_policybinding")
|
get_objects_for_user(request.user, "authentik_policies.view_policybinding")
|
||||||
.filter(target=stage_binding)
|
.filter(target=stage_binding)
|
||||||
@ -227,14 +246,18 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
DiagramElement(
|
DiagramElement(
|
||||||
f"stage_{s_index}_policy_{p_index}",
|
f"stage_{s_index}_policy_{p_index}",
|
||||||
"condition",
|
"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(
|
body.append(
|
||||||
DiagramElement(
|
DiagramElement(
|
||||||
f"stage_{s_index}",
|
f"stage_{s_index}",
|
||||||
"operation",
|
"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
|
# 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 enum import Enum
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from rest_framework.fields import ChoiceField, DictField
|
from rest_framework.fields import ChoiceField, DictField
|
||||||
from rest_framework.serializers import CharField
|
from rest_framework.serializers import CharField
|
||||||
@ -12,6 +13,20 @@ from authentik.flows.transfer.common import DataclassEncoder
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from authentik.flows.stage import StageView
|
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):
|
class ChallengeTypes(Enum):
|
||||||
"""Currently defined challenge types"""
|
"""Currently defined challenge types"""
|
||||||
@ -34,6 +49,7 @@ class ContextualFlowInfo(PassiveSerializer):
|
|||||||
title = CharField(required=False, allow_blank=True)
|
title = CharField(required=False, allow_blank=True)
|
||||||
background = CharField(required=False)
|
background = CharField(required=False)
|
||||||
cancel_url = CharField()
|
cancel_url = CharField()
|
||||||
|
layout = ChoiceField(choices=[(x.value, x.name) for x in FlowLayout])
|
||||||
|
|
||||||
|
|
||||||
class Challenge(PassiveSerializer):
|
class Challenge(PassiveSerializer):
|
||||||
@ -97,6 +113,21 @@ class ChallengeResponse(PassiveSerializer):
|
|||||||
super().__init__(instance=instance, data=data, **kwargs)
|
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):
|
class HttpChallengeResponse(JsonResponse):
|
||||||
"""Subclass of JsonResponse that uses the `DataclassEncoder`"""
|
"""Subclass of JsonResponse that uses the `DataclassEncoder`"""
|
||||||
|
|
||||||
|
@ -12,3 +12,7 @@ class FlowNonApplicableException(SentryIgnoredException):
|
|||||||
|
|
||||||
class EmptyFlowException(SentryIgnoredException):
|
class EmptyFlowException(SentryIgnoredException):
|
||||||
"""Flow has no stages."""
|
"""Flow has no stages."""
|
||||||
|
|
||||||
|
|
||||||
|
class FlowSkipStageException(SentryIgnoredException):
|
||||||
|
"""Exception to skip a stage"""
|
||||||
|
@ -130,7 +130,7 @@ class Migration(migrations.Migration):
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_flows", "0017_auto_20210329_1334"),
|
("authentik_flows", "0017_auto_20210329_1334"),
|
||||||
("authentik_stages_user_write", "0002_auto_20200918_1653"),
|
("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_stages_password", "0002_passwordstage_change_flow"),
|
||||||
("authentik_policies", "0001_initial"),
|
("authentik_policies", "0001_initial"),
|
||||||
("authentik_policies_expression", "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.models import Token
|
||||||
from authentik.core.types import UserSettingSerializer
|
from authentik.core.types import UserSettingSerializer
|
||||||
|
from authentik.flows.challenge import FlowLayout
|
||||||
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
from authentik.lib.models import InheritanceForeignKey, SerializerModel
|
||||||
from authentik.policies.models import PolicyBindingModel
|
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."))
|
slug = models.SlugField(unique=True, help_text=_("Visible in the URL."))
|
||||||
|
|
||||||
title = models.TextField(help_text=_("Shown as the Title in Flow pages."))
|
title = models.TextField(help_text=_("Shown as the Title in Flow pages."))
|
||||||
|
layout = models.TextField(default=FlowLayout.STACKED, choices=FlowLayout.choices)
|
||||||
|
|
||||||
designation = models.CharField(
|
designation = models.CharField(
|
||||||
max_length=100,
|
max_length=100,
|
||||||
@ -231,7 +233,7 @@ class FlowStageBinding(SerializerModel, PolicyBindingModel):
|
|||||||
return FlowStageBindingSerializer
|
return FlowStageBindingSerializer
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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:
|
class Meta:
|
||||||
|
|
||||||
|
@ -120,9 +120,12 @@ class ChallengeStageView(StageView):
|
|||||||
return self.executor.flow.title
|
return self.executor.flow.title
|
||||||
try:
|
try:
|
||||||
return self.executor.flow.title % {
|
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
|
return self.executor.flow.title
|
||||||
|
|
||||||
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
||||||
@ -131,12 +134,19 @@ class ChallengeStageView(StageView):
|
|||||||
description=self.__class__.__name__,
|
description=self.__class__.__name__,
|
||||||
):
|
):
|
||||||
challenge = self.get_challenge(*args, **kwargs)
|
challenge = self.get_challenge(*args, **kwargs)
|
||||||
|
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:
|
if "flow_info" not in challenge.initial_data:
|
||||||
flow_info = ContextualFlowInfo(
|
flow_info = ContextualFlowInfo(
|
||||||
data={
|
data={
|
||||||
"title": self.format_title(),
|
"title": self.format_title(),
|
||||||
"background": self.executor.flow.background_url,
|
"background": self.executor.flow.background_url,
|
||||||
"cancel_url": reverse("authentik_flows:cancel"),
|
"cancel_url": reverse("authentik_flows:cancel"),
|
||||||
|
"layout": self.executor.flow.layout,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
flow_info.is_valid()
|
flow_info.is_valid()
|
||||||
|
@ -23,6 +23,7 @@ class FlowTestCase(APITestCase):
|
|||||||
**kwargs,
|
**kwargs,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Assert various attributes of a stage response"""
|
"""Assert various attributes of a stage response"""
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
raw_response = loads(response.content.decode())
|
raw_response = loads(response.content.decode())
|
||||||
self.assertIsNotNone(raw_response["component"])
|
self.assertIsNotNone(raw_response["component"])
|
||||||
self.assertIsNotNone(raw_response["type"])
|
self.assertIsNotNone(raw_response["type"])
|
||||||
|
@ -10,11 +10,11 @@ from authentik.policies.models import PolicyBinding
|
|||||||
from authentik.stages.dummy.models import DummyStage
|
from authentik.stages.dummy.models import DummyStage
|
||||||
|
|
||||||
DIAGRAM_EXPECTED = """st=>start: Start
|
DIAGRAM_EXPECTED = """st=>start: Start
|
||||||
stage_0=>operation: Stage
|
stage_0=>operation: Stage (Dummy Stage)
|
||||||
dummy1
|
dummy1
|
||||||
stage_1_policy_0=>condition: Policy
|
stage_1_policy_0=>condition: Policy (Dummy Policy)
|
||||||
None
|
test
|
||||||
stage_1=>operation: Stage
|
stage_1=>operation: Stage (Dummy Stage)
|
||||||
dummy2
|
dummy2
|
||||||
e=>end: End|future
|
e=>end: End|future
|
||||||
st(right)->stage_0
|
st(right)->stage_0
|
||||||
@ -55,7 +55,7 @@ class TestFlowsAPI(APITestCase):
|
|||||||
slug="test-default-context",
|
slug="test-default-context",
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
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(
|
FlowStageBinding.objects.create(
|
||||||
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
target=flow, stage=DummyStage.objects.create(name="dummy1"), order=0
|
||||||
|
@ -87,7 +87,6 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertStageResponse(
|
self.assertStageResponse(
|
||||||
response,
|
response,
|
||||||
flow=flow,
|
flow=flow,
|
||||||
@ -406,7 +405,6 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
# A get request will evaluate the policies and this will return stage 4
|
# 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
|
# but it won't save it, hence we can't check the plan
|
||||||
response = self.client.get(exec_url)
|
response = self.client.get(exec_url)
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertStageResponse(response, flow, component="ak-stage-dummy")
|
self.assertStageResponse(response, flow, component="ak-stage-dummy")
|
||||||
|
|
||||||
# fourth request, this confirms the last stage (dummy4)
|
# 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})
|
exec_url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
|
||||||
# First request, run the planner
|
# First request, run the planner
|
||||||
response = self.client.get(exec_url)
|
response = self.client.get(exec_url)
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertStageResponse(
|
self.assertStageResponse(
|
||||||
response,
|
response,
|
||||||
flow,
|
flow,
|
||||||
@ -491,5 +488,4 @@ class TestFlowExecutor(FlowTestCase):
|
|||||||
user_fields=[UserFields.E_MAIL],
|
user_fields=[UserFields.E_MAIL],
|
||||||
)
|
)
|
||||||
response = self.client.post(exec_url, {"uid_field": "invalid-string"}, follow=True)
|
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")
|
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.core.tests.utils import create_test_admin_user
|
||||||
from authentik.flows.challenge import ChallengeTypes
|
from authentik.flows.challenge import ChallengeTypes
|
||||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction
|
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.dummy.models import DummyStage
|
||||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||||
|
|
||||||
@ -24,8 +25,8 @@ class TestFlowInspector(APITestCase):
|
|||||||
def test(self):
|
def test(self):
|
||||||
"""test inspector"""
|
"""test inspector"""
|
||||||
flow = Flow.objects.create(
|
flow = Flow.objects.create(
|
||||||
name="test-full",
|
name=generate_id(),
|
||||||
slug="test-full",
|
slug=generate_id(),
|
||||||
designation=FlowDesignation.AUTHENTICATION,
|
designation=FlowDesignation.AUTHENTICATION,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -55,6 +56,7 @@ class TestFlowInspector(APITestCase):
|
|||||||
"background": flow.background_url,
|
"background": flow.background_url,
|
||||||
"cancel_url": reverse("authentik_flows:cancel"),
|
"cancel_url": reverse("authentik_flows:cancel"),
|
||||||
"title": "",
|
"title": "",
|
||||||
|
"layout": "stacked",
|
||||||
},
|
},
|
||||||
"type": ChallengeTypes.NATIVE.value,
|
"type": ChallengeTypes.NATIVE.value,
|
||||||
"password_fields": False,
|
"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.prompt.models import FieldTypes, Prompt, PromptStage
|
||||||
from authentik.stages.user_login.models import UserLoginStage
|
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):
|
class TestFlowTransfer(TransactionTestCase):
|
||||||
"""Test flow transfer"""
|
"""Test flow transfer"""
|
||||||
@ -58,6 +78,22 @@ class TestFlowTransfer(TransactionTestCase):
|
|||||||
|
|
||||||
self.assertTrue(Flow.objects.filter(slug=flow_slug).exists())
|
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):
|
def test_export_validate_import_policies(self):
|
||||||
"""Test export and validate it"""
|
"""Test export and validate it"""
|
||||||
flow_slug = generate_id()
|
flow_slug = generate_id()
|
||||||
|
@ -115,6 +115,11 @@ class FlowImporter:
|
|||||||
serializer_kwargs["instance"] = model_instance
|
serializer_kwargs["instance"] = model_instance
|
||||||
else:
|
else:
|
||||||
self.logger.debug("initialise new instance", model=model, **updated_identifiers)
|
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 = self.__update_pks_for_attrs(entry.attrs)
|
||||||
full_data.update(updated_identifiers)
|
full_data.update(updated_identifiers)
|
||||||
serializer_kwargs["data"] = full_data
|
serializer_kwargs["data"] = full_data
|
||||||
@ -167,7 +172,7 @@ class FlowImporter:
|
|||||||
def validate(self) -> bool:
|
def validate(self) -> bool:
|
||||||
"""Validate loaded flow export, ensure all models are allowed
|
"""Validate loaded flow export, ensure all models are allowed
|
||||||
and serializers have no errors"""
|
and serializers have no errors"""
|
||||||
self.logger.debug("Starting flow import validaton")
|
self.logger.debug("Starting flow import validation")
|
||||||
if self.__import.version != 1:
|
if self.__import.version != 1:
|
||||||
self.logger.warning("Invalid bundle version")
|
self.logger.warning("Invalid bundle version")
|
||||||
return False
|
return False
|
||||||
|
@ -169,10 +169,11 @@ class FlowExecutorView(APIView):
|
|||||||
self.request.session[SESSION_KEY_PLAN] = plan
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
# Early check if there's an active Plan for the current session
|
# Early check if there's an active Plan for the current session
|
||||||
if SESSION_KEY_PLAN in self.request.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:
|
if self.plan.flow_pk != self.flow.pk.hex:
|
||||||
self._logger.warning(
|
self._logger.warning(
|
||||||
"f(exec): Found existing plan for other flow, deleting plan",
|
"f(exec): Found existing plan for other flow, deleting plan",
|
||||||
|
other_flow=self.plan.flow_pk,
|
||||||
)
|
)
|
||||||
# Existing plan is deleted from session and instance
|
# Existing plan is deleted from session and instance
|
||||||
self.plan = None
|
self.plan = None
|
||||||
|
@ -72,3 +72,4 @@ default_user_change_username: true
|
|||||||
gdpr_compliance: true
|
gdpr_compliance: true
|
||||||
cert_discovery_dir: /certs
|
cert_discovery_dir: /certs
|
||||||
default_token_length: 128
|
default_token_length: 128
|
||||||
|
impersonation: true
|
||||||
|
@ -18,13 +18,22 @@ from redis.exceptions import ConnectionError as RedisConnectionError
|
|||||||
from redis.exceptions import RedisError, ResponseError
|
from redis.exceptions import RedisError, ResponseError
|
||||||
from rest_framework.exceptions import APIException
|
from rest_framework.exceptions import APIException
|
||||||
from sentry_sdk import Hub
|
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 sentry_sdk.tracing import Transaction
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
from websockets.exceptions import WebSocketException
|
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()
|
LOGGER = get_logger()
|
||||||
|
SENTRY_DSN = "https://a579bb09306d4f8b8d8847c052d3a1d3@sentry.beryju.org/8"
|
||||||
|
|
||||||
|
|
||||||
class SentryWSMiddleware(BaseMiddleware):
|
class SentryWSMiddleware(BaseMiddleware):
|
||||||
@ -43,6 +52,37 @@ class SentryIgnoredException(Exception):
|
|||||||
"""Base Class for all errors that are suppressed, and not sent to sentry."""
|
"""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]:
|
def before_send(event: dict, hint: dict) -> Optional[dict]:
|
||||||
"""Check if error is database error, and ignore if so"""
|
"""Check if error is database error, and ignore if so"""
|
||||||
# pylint: disable=no-name-in-module
|
# pylint: disable=no-name-in-module
|
||||||
@ -108,6 +148,6 @@ def before_send(event: dict, hint: dict) -> Optional[dict]:
|
|||||||
]:
|
]:
|
||||||
return None
|
return None
|
||||||
LOGGER.debug("sending event to sentry", exc=exc_value, source_logger=event.get("logger", 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 None
|
||||||
return event
|
return event
|
||||||
|
@ -13,4 +13,4 @@ class TestSentry(TestCase):
|
|||||||
|
|
||||||
def test_error_sent(self):
|
def test_error_sent(self):
|
||||||
"""Test error sent"""
|
"""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"""
|
"""Time utilities"""
|
||||||
import datetime
|
import datetime
|
||||||
|
from hashlib import sha256
|
||||||
|
from random import randrange, seed
|
||||||
|
from socket import getfqdn
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -38,3 +41,12 @@ def timedelta_from_string(expr: str) -> datetime.timedelta:
|
|||||||
if len(kwargs) < 1:
|
if len(kwargs) < 1:
|
||||||
raise ValueError("No valid keys to pass to timedelta")
|
raise ValueError("No valid keys to pass to timedelta")
|
||||||
return datetime.timedelta(**kwargs)
|
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"""
|
"""URL-related utils"""
|
||||||
|
from typing import Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse, QueryDict
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import NoReverseMatch, reverse
|
from django.urls import NoReverseMatch, reverse
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
@ -15,7 +16,9 @@ def is_url_absolute(url):
|
|||||||
return bool(urlparse(url).netloc)
|
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"""
|
"""Wrapper to redirect whilst keeping GET Parameters"""
|
||||||
try:
|
try:
|
||||||
target = reverse(view, kwargs=kwargs)
|
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:
|
if get_query_set:
|
||||||
target += "?" + urlencode(get_query_set.items())
|
target += "?" + urlencode(get_query_set.items())
|
||||||
return redirect(target)
|
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"""
|
"""managed Settings"""
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
"managed_reconcile": {
|
"managed_reconcile": {
|
||||||
"task": "authentik.managed.tasks.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"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -118,6 +118,7 @@ class DockerServiceConnectionViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = DockerServiceConnectionSerializer
|
serializer_class = DockerServiceConnectionSerializer
|
||||||
filterset_fields = ["name", "local", "url", "tls_verification", "tls_authentication"]
|
filterset_fields = ["name", "local", "url", "tls_verification", "tls_authentication"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
|
||||||
|
|
||||||
class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
|
class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
|
||||||
@ -152,3 +153,4 @@ class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = KubernetesServiceConnectionSerializer
|
serializer_class = KubernetesServiceConnectionSerializer
|
||||||
filterset_fields = ["name", "local"]
|
filterset_fields = ["name", "local"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
@ -15,7 +15,7 @@ from yaml import safe_dump
|
|||||||
|
|
||||||
from authentik import __version__
|
from authentik import __version__
|
||||||
from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
|
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.docker_tls import DockerInlineTLS
|
||||||
from authentik.outposts.managed import MANAGED_OUTPOST
|
from authentik.outposts.managed import MANAGED_OUTPOST
|
||||||
from authentik.outposts.models import (
|
from authentik.outposts.models import (
|
||||||
@ -35,6 +35,7 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
|||||||
def __init__(self, connection: DockerServiceConnection):
|
def __init__(self, connection: DockerServiceConnection):
|
||||||
self.tls = None
|
self.tls = None
|
||||||
self.ssh = None
|
self.ssh = None
|
||||||
|
self.logger = get_logger()
|
||||||
if connection.local:
|
if connection.local:
|
||||||
# Same result as DockerClient.from_env
|
# Same result as DockerClient.from_env
|
||||||
super().__init__(**kwargs_from_env())
|
super().__init__(**kwargs_from_env())
|
||||||
@ -42,8 +43,12 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
|||||||
parsed_url = urlparse(connection.url)
|
parsed_url = urlparse(connection.url)
|
||||||
tls_config = False
|
tls_config = False
|
||||||
if parsed_url.scheme == "ssh":
|
if parsed_url.scheme == "ssh":
|
||||||
|
try:
|
||||||
self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
|
self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
|
||||||
self.ssh.write()
|
self.ssh.write()
|
||||||
|
except SSHManagedExternallyException as exc:
|
||||||
|
# SSH config is managed externally
|
||||||
|
self.logger.info(f"SSH Managed externally: {exc}")
|
||||||
else:
|
else:
|
||||||
self.tls = DockerInlineTLS(
|
self.tls = DockerInlineTLS(
|
||||||
verification_kp=connection.tls_verification,
|
verification_kp=connection.tls_verification,
|
||||||
@ -57,7 +62,6 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
|||||||
)
|
)
|
||||||
except SSHException as exc:
|
except SSHException as exc:
|
||||||
raise ServiceConnectionInvalid from exc
|
raise ServiceConnectionInvalid from exc
|
||||||
self.logger = get_logger()
|
|
||||||
# Ensure the client actually works
|
# Ensure the client actually works
|
||||||
self.containers.list()
|
self.containers.list()
|
||||||
|
|
||||||
|
@ -16,6 +16,10 @@ def opener(path, flags):
|
|||||||
return os.open(path, flags, 0o700)
|
return os.open(path, flags, 0o700)
|
||||||
|
|
||||||
|
|
||||||
|
class SSHManagedExternallyException(DockerException):
|
||||||
|
"""Raised when the ssh config file is managed externally."""
|
||||||
|
|
||||||
|
|
||||||
class DockerInlineSSH:
|
class DockerInlineSSH:
|
||||||
"""Create paramiko ssh config from CertificateKeyPair"""
|
"""Create paramiko ssh config from CertificateKeyPair"""
|
||||||
|
|
||||||
@ -29,9 +33,15 @@ class DockerInlineSSH:
|
|||||||
def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
|
def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
|
||||||
self.host = host
|
self.host = host
|
||||||
self.keypair = keypair
|
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:
|
if not self.keypair:
|
||||||
raise DockerException("keypair must be set for SSH connections")
|
raise DockerException("keypair must be set for SSH connections")
|
||||||
self.config_path = Path("~/.ssh/config").expanduser()
|
|
||||||
self.header = f"{HEADER} - {self.host}\n"
|
self.header = f"{HEADER} - {self.host}\n"
|
||||||
|
|
||||||
def write_config(self, key_path: str) -> bool:
|
def write_config(self, key_path: str) -> bool:
|
||||||
|
@ -1,25 +1,27 @@
|
|||||||
"""Outposts Settings"""
|
"""Outposts Settings"""
|
||||||
from celery.schedules import crontab
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
from authentik.lib.utils.time import fqdn_rand
|
||||||
|
|
||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
"outposts_controller": {
|
"outposts_controller": {
|
||||||
"task": "authentik.outposts.tasks.outpost_controller_all",
|
"task": "authentik.outposts.tasks.outpost_controller_all",
|
||||||
"schedule": crontab(minute="*/5"),
|
"schedule": crontab(minute=fqdn_rand("outposts_controller"), hour="*/4"),
|
||||||
"options": {"queue": "authentik_scheduled"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
"outposts_service_connection_check": {
|
"outposts_service_connection_check": {
|
||||||
"task": "authentik.outposts.tasks.outpost_service_connection_monitor",
|
"task": "authentik.outposts.tasks.outpost_service_connection_monitor",
|
||||||
"schedule": crontab(minute="*/5"),
|
"schedule": crontab(minute="3-59/15"),
|
||||||
"options": {"queue": "authentik_scheduled"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
"outpost_token_ensurer": {
|
"outpost_token_ensurer": {
|
||||||
"task": "authentik.outposts.tasks.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"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
"outpost_local_connection": {
|
"outpost_local_connection": {
|
||||||
"task": "authentik.outposts.tasks.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"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -52,7 +52,7 @@ def m2m_changed_update(sender, instance: Model, action: str, **_):
|
|||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
# 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 Outpost is saved, Ensure that token is created/updated
|
||||||
|
|
||||||
If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved,
|
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
|
return
|
||||||
if not isinstance(instance, UPDATE_TRIGGERING_MODELS):
|
if not isinstance(instance, UPDATE_TRIGGERING_MODELS):
|
||||||
return
|
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)
|
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.http.request import HttpRequest
|
||||||
from django.template.response import TemplateResponse
|
from django.template.response import TemplateResponse
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from authentik.core.models import USER_ATTRIBUTE_DEBUG
|
from authentik.core.models import USER_ATTRIBUTE_DEBUG
|
||||||
@ -37,4 +38,5 @@ class AccessDeniedResponse(TemplateResponse):
|
|||||||
self._request
|
self._request
|
||||||
).get(USER_ATTRIBUTE_DEBUG, False):
|
).get(USER_ATTRIBUTE_DEBUG, False):
|
||||||
context["policy_result"] = self.policy_result
|
context["policy_result"] = self.policy_result
|
||||||
|
context["cancel"] = reverse("authentik_flows:cancel")
|
||||||
return context
|
return context
|
||||||
|
@ -21,3 +21,4 @@ class DummyPolicyViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = DummyPolicySerializer
|
serializer_class = DummyPolicySerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
@ -25,3 +25,4 @@ class EventMatcherPolicyViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = EventMatcherPolicySerializer
|
serializer_class = EventMatcherPolicySerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
@ -21,3 +21,4 @@ class PasswordExpiryPolicyViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = PasswordExpiryPolicySerializer
|
serializer_class = PasswordExpiryPolicySerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
@ -28,3 +28,4 @@ class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = ExpressionPolicySerializer
|
serializer_class = ExpressionPolicySerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
@ -20,4 +20,5 @@ class HaveIBeenPwendPolicyViewSet(UsedByMixin, ModelViewSet):
|
|||||||
queryset = HaveIBeenPwendPolicy.objects.all()
|
queryset = HaveIBeenPwendPolicy.objects.all()
|
||||||
serializer_class = HaveIBeenPwendPolicySerializer
|
serializer_class = HaveIBeenPwendPolicySerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
|
search_fields = ["name", "password_field"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
@ -9,6 +9,7 @@ from structlog.stdlib import get_logger
|
|||||||
from authentik.lib.utils.http import get_http_session
|
from authentik.lib.utils.http import get_http_session
|
||||||
from authentik.policies.models import Policy, PolicyResult
|
from authentik.policies.models import Policy, PolicyResult
|
||||||
from authentik.policies.types import PolicyRequest
|
from authentik.policies.types import PolicyRequest
|
||||||
|
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||||
|
|
||||||
LOGGER = get_logger()
|
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
|
"""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
|
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 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(
|
LOGGER.warning(
|
||||||
"Password field not set in Policy Request",
|
"Password field not set in Policy Request",
|
||||||
field=self.password_field,
|
field=self.password_field,
|
||||||
fields=request.context.keys(),
|
fields=request.context.keys(),
|
||||||
)
|
)
|
||||||
return PolicyResult(False, _("Password not set in context"))
|
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
|
pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec
|
||||||
url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}"
|
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.lib.generators import generate_key
|
||||||
from authentik.policies.hibp.models import HaveIBeenPwendPolicy
|
from authentik.policies.hibp.models import HaveIBeenPwendPolicy
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
from authentik.policies.types import PolicyRequest, PolicyResult
|
||||||
|
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
||||||
|
|
||||||
|
|
||||||
class TestHIBPPolicy(TestCase):
|
class TestHIBPPolicy(TestCase):
|
||||||
@ -26,7 +27,7 @@ class TestHIBPPolicy(TestCase):
|
|||||||
name="test_false",
|
name="test_false",
|
||||||
)
|
)
|
||||||
request = PolicyRequest(get_anonymous_user())
|
request = PolicyRequest(get_anonymous_user())
|
||||||
request.context["password"] = "password" # nosec
|
request.context[PLAN_CONTEXT_PROMPT] = {"password": "password"} # nosec
|
||||||
result: PolicyResult = policy.passes(request)
|
result: PolicyResult = policy.passes(request)
|
||||||
self.assertFalse(result.passing)
|
self.assertFalse(result.passing)
|
||||||
self.assertTrue(result.messages[0].startswith("Password exists on "))
|
self.assertTrue(result.messages[0].startswith("Password exists on "))
|
||||||
@ -37,7 +38,7 @@ class TestHIBPPolicy(TestCase):
|
|||||||
name="test_true",
|
name="test_true",
|
||||||
)
|
)
|
||||||
request = PolicyRequest(get_anonymous_user())
|
request = PolicyRequest(get_anonymous_user())
|
||||||
request.context["password"] = generate_key()
|
request.context[PLAN_CONTEXT_PROMPT] = {"password": generate_key()}
|
||||||
result: PolicyResult = policy.passes(request)
|
result: PolicyResult = policy.passes(request)
|
||||||
self.assertTrue(result.passing)
|
self.assertTrue(result.passing)
|
||||||
self.assertEqual(result.messages, tuple())
|
self.assertEqual(result.messages, tuple())
|
||||||
|
@ -30,3 +30,4 @@ class PasswordPolicyViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = PasswordPolicySerializer
|
serializer_class = PasswordPolicySerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
@ -50,7 +50,6 @@ class TestPasswordPolicyFlow(FlowTestCase):
|
|||||||
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
|
||||||
{"password": "akadmin"},
|
{"password": "akadmin"},
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertStageResponse(
|
self.assertStageResponse(
|
||||||
response,
|
response,
|
||||||
self.flow,
|
self.flow,
|
||||||
|
@ -151,5 +151,5 @@ class PolicyProcess(PROCESS_CLASS):
|
|||||||
try:
|
try:
|
||||||
self.connection.send(self.profiling_wrapper())
|
self.connection.send(self.profiling_wrapper())
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
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)))
|
self.connection.send(PolicyResult(False, str(exc)))
|
||||||
|
@ -26,6 +26,7 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet):
|
|||||||
queryset = ReputationPolicy.objects.all()
|
queryset = ReputationPolicy.objects.all()
|
||||||
serializer_class = ReputationPolicySerializer
|
serializer_class = ReputationPolicySerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
|
search_fields = ["name", "threshold"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ from celery.schedules import crontab
|
|||||||
CELERY_BEAT_SCHEDULE = {
|
CELERY_BEAT_SCHEDULE = {
|
||||||
"policies_reputation_save": {
|
"policies_reputation_save": {
|
||||||
"task": "authentik.policies.reputation.tasks.save_reputation",
|
"task": "authentik.policies.reputation.tasks.save_reputation",
|
||||||
"schedule": crontab(minute="*/5"),
|
"schedule": crontab(minute="1-59/5"),
|
||||||
"options": {"queue": "authentik_scheduled"},
|
"options": {"queue": "authentik_scheduled"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -12,8 +12,21 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block card %}
|
{% block card %}
|
||||||
<form method="POST" class="pf-c-form">
|
<form class="pf-c-form">
|
||||||
{% csrf_token %}
|
{% 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">
|
<div class="pf-c-form__group">
|
||||||
<p>
|
<p>
|
||||||
<i class="pf-icon pf-icon-error-circle-o"></i>
|
<i class="pf-icon pf-icon-error-circle-o"></i>
|
||||||
|
@ -25,6 +25,7 @@ class LDAPProviderSerializer(ProviderSerializer):
|
|||||||
"gid_start_number",
|
"gid_start_number",
|
||||||
"outpost_set",
|
"outpost_set",
|
||||||
"search_mode",
|
"search_mode",
|
||||||
|
"bind_mode",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -46,6 +47,7 @@ class LDAPProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"uid_start_number": ["iexact"],
|
"uid_start_number": ["iexact"],
|
||||||
"gid_start_number": ["iexact"],
|
"gid_start_number": ["iexact"],
|
||||||
}
|
}
|
||||||
|
search_fields = ["name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
@ -70,6 +72,7 @@ class LDAPOutpostConfigSerializer(ModelSerializer):
|
|||||||
"uid_start_number",
|
"uid_start_number",
|
||||||
"gid_start_number",
|
"gid_start_number",
|
||||||
"search_mode",
|
"search_mode",
|
||||||
|
"bind_mode",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -79,3 +82,5 @@ class LDAPOutpostConfigViewSet(ReadOnlyModelViewSet):
|
|||||||
queryset = LDAPProvider.objects.filter(application__isnull=False)
|
queryset = LDAPProvider.objects.filter(application__isnull=False)
|
||||||
serializer_class = LDAPOutpostConfigSerializer
|
serializer_class = LDAPOutpostConfigSerializer
|
||||||
ordering = ["name"]
|
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
|
from authentik.outposts.models import OutpostModel
|
||||||
|
|
||||||
|
|
||||||
class SearchModes(models.TextChoices):
|
class APIAccessMode(models.TextChoices):
|
||||||
"""Search modes"""
|
"""API Access modes"""
|
||||||
|
|
||||||
DIRECT = "direct"
|
DIRECT = "direct"
|
||||||
CACHED = "cached"
|
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
|
@property
|
||||||
def launch_url(self) -> Optional[str]:
|
def launch_url(self) -> Optional[str]:
|
||||||
|
@ -71,6 +71,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"property_mappings",
|
"property_mappings",
|
||||||
"issuer_mode",
|
"issuer_mode",
|
||||||
]
|
]
|
||||||
|
search_fields = ["name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
|
@ -39,3 +39,4 @@ class ScopeMappingViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = ScopeMappingSerializer
|
serializer_class = ScopeMappingSerializer
|
||||||
filterset_class = ScopeMappingFilter
|
filterset_class = ScopeMappingFilter
|
||||||
ordering = ["scope_name", "name"]
|
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"
|
CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||||
|
|
||||||
PROMPT_NONE = "none"
|
PROMPT_NONE = "none"
|
||||||
PROMPT_CONSNET = "consent"
|
PROMPT_CONSENT = "consent"
|
||||||
PROMPT_LOGIN = "login"
|
PROMPT_LOGIN = "login"
|
||||||
|
|
||||||
SCOPE_OPENID = "openid"
|
SCOPE_OPENID = "openid"
|
||||||
|
@ -24,7 +24,7 @@ class OAuth2Error(SentryIgnoredException):
|
|||||||
return self.error
|
return self.error
|
||||||
|
|
||||||
def to_event(self, message: Optional[str] = None, **kwargs) -> Event:
|
def to_event(self, message: Optional[str] = None, **kwargs) -> Event:
|
||||||
"""Create configuration_error Event and save it."""
|
"""Create configuration_error Event."""
|
||||||
return Event.new(
|
return Event.new(
|
||||||
EventAction.CONFIGURATION_ERROR,
|
EventAction.CONFIGURATION_ERROR,
|
||||||
message=message or self.description,
|
message=message or self.description,
|
||||||
|
@ -50,6 +50,7 @@ class ResponseMode(models.TextChoices):
|
|||||||
|
|
||||||
QUERY = "query"
|
QUERY = "query"
|
||||||
FRAGMENT = "fragment"
|
FRAGMENT = "fragment"
|
||||||
|
FORM_POST = "form_post"
|
||||||
|
|
||||||
|
|
||||||
class SubModes(models.TextChoices):
|
class SubModes(models.TextChoices):
|
||||||
|
@ -5,7 +5,6 @@ from django.urls import reverse
|
|||||||
from authentik.core.models import Application
|
from authentik.core.models import Application
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||||
from authentik.flows.challenge import ChallengeTypes
|
from authentik.flows.challenge import ChallengeTypes
|
||||||
from authentik.flows.models import Flow
|
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, RedirectUriError
|
from authentik.providers.oauth2.errors import AuthorizeError, ClientIdError, RedirectUriError
|
||||||
from authentik.providers.oauth2.models import (
|
from authentik.providers.oauth2.models import (
|
||||||
@ -79,6 +78,28 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
)
|
)
|
||||||
OAuthAuthorizationParams.from_request(request)
|
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):
|
def test_empty_redirect_uri(self):
|
||||||
"""test empty redirect URI (configure in provider)"""
|
"""test empty redirect URI (configure in provider)"""
|
||||||
OAuth2Provider.objects.create(
|
OAuth2Provider.objects.create(
|
||||||
@ -178,7 +199,7 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
|
|
||||||
def test_full_code(self):
|
def test_full_code(self):
|
||||||
"""Test full authorization"""
|
"""Test full authorization"""
|
||||||
flow = Flow.objects.create(slug="empty")
|
flow = create_test_flow()
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="test",
|
name="test",
|
||||||
client_id="test",
|
client_id="test",
|
||||||
@ -214,7 +235,7 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
|
|
||||||
def test_full_implicit(self):
|
def test_full_implicit(self):
|
||||||
"""Test full authorization"""
|
"""Test full authorization"""
|
||||||
flow = Flow.objects.create(slug="empty")
|
flow = create_test_flow()
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
name="test",
|
name="test",
|
||||||
client_id="test",
|
client_id="test",
|
||||||
@ -255,3 +276,52 @@ class TestAuthorize(OAuthTestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.validate_jwt(token, provider)
|
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"""
|
"""authentik OAuth2 Authorization views"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from re import error as RegexError
|
||||||
|
from re import escape, fullmatch
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import parse_qs, urlencode, urlparse, urlsplit, urlunsplit
|
from urllib.parse import parse_qs, urlencode, urlparse, urlsplit, urlunsplit
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@ -15,6 +17,12 @@ from structlog.stdlib import get_logger
|
|||||||
from authentik.core.models import Application
|
from authentik.core.models import Application
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
from authentik.events.utils import get_user
|
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.models import in_memory_stage
|
||||||
from authentik.flows.planner import (
|
from authentik.flows.planner import (
|
||||||
PLAN_CONTEXT_APPLICATION,
|
PLAN_CONTEXT_APPLICATION,
|
||||||
@ -30,7 +38,7 @@ from authentik.lib.views import bad_request_message
|
|||||||
from authentik.policies.types import PolicyRequest
|
from authentik.policies.types import PolicyRequest
|
||||||
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
from authentik.policies.views import PolicyAccessView, RequestValidationError
|
||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
PROMPT_CONSNET,
|
PROMPT_CONSENT,
|
||||||
PROMPT_LOGIN,
|
PROMPT_LOGIN,
|
||||||
PROMPT_NONE,
|
PROMPT_NONE,
|
||||||
SCOPE_OPENID,
|
SCOPE_OPENID,
|
||||||
@ -63,7 +71,7 @@ LOGGER = get_logger()
|
|||||||
PLAN_CONTEXT_PARAMS = "params"
|
PLAN_CONTEXT_PARAMS = "params"
|
||||||
SESSION_NEEDS_LOGIN = "authentik_oauth2_needs_login"
|
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
|
@dataclass
|
||||||
@ -74,6 +82,7 @@ class OAuthAuthorizationParams:
|
|||||||
client_id: str
|
client_id: str
|
||||||
redirect_uri: str
|
redirect_uri: str
|
||||||
response_type: str
|
response_type: str
|
||||||
|
response_mode: Optional[str]
|
||||||
scope: list[str]
|
scope: list[str]
|
||||||
state: str
|
state: str
|
||||||
nonce: Optional[str]
|
nonce: Optional[str]
|
||||||
@ -125,11 +134,22 @@ class OAuthAuthorizationParams:
|
|||||||
LOGGER.warning("Invalid response type", type=response_type)
|
LOGGER.warning("Invalid response type", type=response_type)
|
||||||
raise AuthorizeError(redirect_uri, "unsupported_response_type", "", state)
|
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")
|
max_age = query_dict.get("max_age")
|
||||||
return OAuthAuthorizationParams(
|
return OAuthAuthorizationParams(
|
||||||
client_id=query_dict.get("client_id", ""),
|
client_id=query_dict.get("client_id", ""),
|
||||||
redirect_uri=redirect_uri,
|
redirect_uri=redirect_uri,
|
||||||
response_type=response_type,
|
response_type=response_type,
|
||||||
|
response_mode=response_mode,
|
||||||
grant_type=grant_type,
|
grant_type=grant_type,
|
||||||
scope=query_dict.get("scope", "").split(),
|
scope=query_dict.get("scope", "").split(),
|
||||||
state=state,
|
state=state,
|
||||||
@ -155,32 +175,33 @@ class OAuthAuthorizationParams:
|
|||||||
def check_redirect_uri(self):
|
def check_redirect_uri(self):
|
||||||
"""Redirect URI validation."""
|
"""Redirect URI validation."""
|
||||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||||
# We don't want to actually lowercase the final URL we redirect to,
|
if not self.redirect_uri:
|
||||||
# we only lowercase it for comparison
|
|
||||||
redirect_uri = self.redirect_uri.lower()
|
|
||||||
if not redirect_uri:
|
|
||||||
LOGGER.warning("Missing redirect uri.")
|
LOGGER.warning("Missing redirect uri.")
|
||||||
raise RedirectUriError("", allowed_redirect_urls)
|
raise RedirectUriError("", allowed_redirect_urls)
|
||||||
|
|
||||||
if self.provider.redirect_uris == "":
|
if self.provider.redirect_uris == "":
|
||||||
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
|
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()
|
self.provider.save()
|
||||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||||
|
|
||||||
if self.provider.redirect_uris == "*":
|
if self.provider.redirect_uris == "*":
|
||||||
LOGGER.warning(
|
LOGGER.info("Converting redirect_uris to regex", redirect=self.redirect_uri)
|
||||||
"Provider has wildcard allowed redirect_uri set, allowing all.",
|
self.provider.redirect_uris = ".*"
|
||||||
allow=self.redirect_uri,
|
self.provider.save()
|
||||||
)
|
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||||
return
|
|
||||||
if redirect_uri not in [x.lower() for x in allowed_redirect_urls]:
|
try:
|
||||||
|
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Invalid redirect uri",
|
"Invalid redirect uri",
|
||||||
redirect_uri=self.redirect_uri,
|
redirect_uri=self.redirect_uri,
|
||||||
excepted=allowed_redirect_urls,
|
excepted=allowed_redirect_urls,
|
||||||
)
|
)
|
||||||
raise RedirectUriError(self.redirect_uri, 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:
|
if self.request:
|
||||||
raise AuthorizeError(
|
raise AuthorizeError(
|
||||||
self.redirect_uri, "request_not_supported", self.grant_type, self.state
|
self.redirect_uri, "request_not_supported", self.grant_type, self.state
|
||||||
@ -239,167 +260,6 @@ class OAuthAuthorizationParams:
|
|||||||
return code
|
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):
|
class AuthorizationFlowInitView(PolicyAccessView):
|
||||||
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
"""OAuth2 Flow initializer, checks access to application and starts flow"""
|
||||||
|
|
||||||
@ -414,10 +274,10 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
|||||||
try:
|
try:
|
||||||
self.params = OAuthAuthorizationParams.from_request(self.request)
|
self.params = OAuthAuthorizationParams.from_request(self.request)
|
||||||
except AuthorizeError as error:
|
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()))
|
raise RequestValidationError(HttpResponseRedirect(error.create_uri()))
|
||||||
except OAuth2Error as error:
|
except OAuth2Error as error:
|
||||||
error.to_event().from_http(self.request)
|
LOGGER.warning(error.description)
|
||||||
raise RequestValidationError(
|
raise RequestValidationError(
|
||||||
bad_request_message(self.request, error.description, title=error.error)
|
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
|
# OpenID clients can specify a `prompt` parameter, and if its set to consent we
|
||||||
# need to inject a consent stage
|
# 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):
|
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
|
# Plan does not have any consent stage, so we add an in-memory one
|
||||||
stage = ConsentStage(
|
stage = ConsentStage(
|
||||||
@ -502,10 +362,206 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
|||||||
mode=ConsentMode.ALWAYS_REQUIRE,
|
mode=ConsentMode.ALWAYS_REQUIRE,
|
||||||
)
|
)
|
||||||
plan.append_stage(stage)
|
plan.append_stage(stage)
|
||||||
|
|
||||||
plan.append_stage(in_memory_stage(OAuthFulfillmentStage))
|
plan.append_stage(in_memory_stage(OAuthFulfillmentStage))
|
||||||
|
|
||||||
self.request.session[SESSION_KEY_PLAN] = plan
|
self.request.session[SESSION_KEY_PLAN] = plan
|
||||||
return redirect_with_qs(
|
return redirect_with_qs(
|
||||||
"authentik_core:if-flow",
|
"authentik_core:if-flow",
|
||||||
self.request.GET,
|
self.request.GET,
|
||||||
flow_slug=self.provider.authorization_flow.slug,
|
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 base64 import urlsafe_b64encode
|
||||||
from dataclasses import InitVar, dataclass
|
from dataclasses import InitVar, dataclass
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
|
from re import error as RegexError
|
||||||
|
from re import fullmatch
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
from django.utils.timezone import datetime, now
|
from django.utils.timezone import datetime, now
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from jwt import InvalidTokenError, decode
|
from jwt import InvalidTokenError, decode
|
||||||
|
from sentry_sdk.hub import Hub
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
from authentik.core.models import (
|
from authentik.core.models import (
|
||||||
@ -94,6 +97,9 @@ class TokenParams:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def __check_policy_access(self, app: Application, request: HttpRequest, **kwargs):
|
def __check_policy_access(self, app: Application, request: HttpRequest, **kwargs):
|
||||||
|
with Hub.current.start_span(
|
||||||
|
op="authentik.providers.oauth2.token.policy",
|
||||||
|
):
|
||||||
engine = PolicyEngine(app, self.user, request)
|
engine = PolicyEngine(app, self.user, request)
|
||||||
engine.request.context["oauth_scopes"] = self.scope
|
engine.request.context["oauth_scopes"] = self.scope
|
||||||
engine.request.context["oauth_grant_type"] = self.grant_type
|
engine.request.context["oauth_grant_type"] = self.grant_type
|
||||||
@ -118,10 +124,19 @@ class TokenParams:
|
|||||||
raise TokenError("invalid_client")
|
raise TokenError("invalid_client")
|
||||||
|
|
||||||
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
if self.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||||
|
with Hub.current.start_span(
|
||||||
|
op="authentik.providers.oauth2.post.parse.code",
|
||||||
|
):
|
||||||
self.__post_init_code(raw_code)
|
self.__post_init_code(raw_code)
|
||||||
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||||
|
with Hub.current.start_span(
|
||||||
|
op="authentik.providers.oauth2.post.parse.refresh",
|
||||||
|
):
|
||||||
self.__post_init_refresh(raw_token, request)
|
self.__post_init_refresh(raw_token, request)
|
||||||
elif self.grant_type in [GRANT_TYPE_CLIENT_CREDENTIALS, GRANT_TYPE_PASSWORD]:
|
elif self.grant_type in [GRANT_TYPE_CLIENT_CREDENTIALS, GRANT_TYPE_PASSWORD]:
|
||||||
|
with Hub.current.start_span(
|
||||||
|
op="authentik.providers.oauth2.post.parse.client_credentials",
|
||||||
|
):
|
||||||
self.__post_init_client_credentials(request)
|
self.__post_init_client_credentials(request)
|
||||||
else:
|
else:
|
||||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||||
@ -133,20 +148,19 @@ class TokenParams:
|
|||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
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
|
# 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
|
# this will check an empty array and raise an error
|
||||||
elif self.redirect_uri not in [x.lower() for x in allowed_redirect_urls]:
|
try:
|
||||||
|
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||||
LOGGER.warning(
|
LOGGER.warning(
|
||||||
"Invalid redirect uri",
|
"Invalid redirect uri",
|
||||||
redirect=self.redirect_uri,
|
redirect_uri=self.redirect_uri,
|
||||||
expected=self.provider.redirect_uris.split(),
|
excepted=allowed_redirect_urls,
|
||||||
)
|
)
|
||||||
raise TokenError("invalid_client")
|
raise TokenError("invalid_client")
|
||||||
|
except RegexError as exc:
|
||||||
|
LOGGER.warning("Invalid regular expression configured", exc=exc)
|
||||||
|
raise TokenError("invalid_client")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.authorization_code = AuthorizationCode.objects.get(code=raw_code)
|
self.authorization_code = AuthorizationCode.objects.get(code=raw_code)
|
||||||
@ -228,6 +242,11 @@ class TokenParams:
|
|||||||
if not token or token.user.uid != user.uid:
|
if not token or token.user.uid != user.uid:
|
||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
self.user = user
|
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(
|
Event.new(
|
||||||
action=EventAction.LOGIN,
|
action=EventAction.LOGIN,
|
||||||
@ -235,13 +254,8 @@ class TokenParams:
|
|||||||
PLAN_CONTEXT_METHOD_ARGS={
|
PLAN_CONTEXT_METHOD_ARGS={
|
||||||
"identifier": token.identifier,
|
"identifier": token.identifier,
|
||||||
},
|
},
|
||||||
|
PLAN_CONTEXT_APPLICATION=app,
|
||||||
).from_http(request, user=user)
|
).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
|
return None
|
||||||
|
|
||||||
def __post_init_client_credentials_jwt(self, request: HttpRequest):
|
def __post_init_client_credentials_jwt(self, request: HttpRequest):
|
||||||
@ -288,18 +302,7 @@ class TokenParams:
|
|||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
self.__check_policy_access(app, request, oauth_jwt=token)
|
self.__check_policy_access(app, request, oauth_jwt=token)
|
||||||
|
self.__create_user_from_jwt(token, app)
|
||||||
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)",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
Event.new(
|
Event.new(
|
||||||
action=EventAction.LOGIN,
|
action=EventAction.LOGIN,
|
||||||
@ -307,8 +310,26 @@ class TokenParams:
|
|||||||
PLAN_CONTEXT_METHOD_ARGS={
|
PLAN_CONTEXT_METHOD_ARGS={
|
||||||
"jwt": token,
|
"jwt": token,
|
||||||
},
|
},
|
||||||
|
PLAN_CONTEXT_APPLICATION=app,
|
||||||
).from_http(request, user=self.user)
|
).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):
|
class TokenView(View):
|
||||||
"""Generate tokens for clients"""
|
"""Generate tokens for clients"""
|
||||||
@ -330,6 +351,9 @@ class TokenView(View):
|
|||||||
def post(self, request: HttpRequest) -> HttpResponse:
|
def post(self, request: HttpRequest) -> HttpResponse:
|
||||||
"""Generate tokens for clients"""
|
"""Generate tokens for clients"""
|
||||||
try:
|
try:
|
||||||
|
with Hub.current.start_span(
|
||||||
|
op="authentik.providers.oauth2.post.parse",
|
||||||
|
):
|
||||||
client_id, client_secret = extract_client_auth(request)
|
client_id, client_secret = extract_client_auth(request)
|
||||||
try:
|
try:
|
||||||
self.provider = OAuth2Provider.objects.get(client_id=client_id)
|
self.provider = OAuth2Provider.objects.get(client_id=client_id)
|
||||||
@ -341,6 +365,9 @@ class TokenView(View):
|
|||||||
raise ValueError
|
raise ValueError
|
||||||
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
self.params = TokenParams.parse(request, self.provider, client_id, client_secret)
|
||||||
|
|
||||||
|
with Hub.current.start_span(
|
||||||
|
op="authentik.providers.oauth2.post.response",
|
||||||
|
):
|
||||||
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE:
|
||||||
LOGGER.debug("Converting authorization code to refresh token")
|
LOGGER.debug("Converting authorization code to refresh token")
|
||||||
return TokenResponse(self.create_code_response())
|
return TokenResponse(self.create_code_response())
|
||||||
|
@ -103,6 +103,7 @@ class ProxyProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
"redirect_uris": ["iexact"],
|
"redirect_uris": ["iexact"],
|
||||||
"cookie_domain": ["iexact"],
|
"cookie_domain": ["iexact"],
|
||||||
}
|
}
|
||||||
|
search_fields = ["name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
||||||
|
|
||||||
@ -166,3 +167,5 @@ class ProxyOutpostConfigViewSet(ReadOnlyModelViewSet):
|
|||||||
queryset = ProxyProvider.objects.filter(application__isnull=False)
|
queryset = ProxyProvider.objects.filter(application__isnull=False)
|
||||||
serializer_class = ProxyOutpostConfigSerializer
|
serializer_class = ProxyOutpostConfigSerializer
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
filterset_fields = ["name"]
|
||||||
|
@ -99,6 +99,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
serializer_class = SAMLProviderSerializer
|
serializer_class = SAMLProviderSerializer
|
||||||
filterset_fields = "__all__"
|
filterset_fields = "__all__"
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
search_fields = ["name"]
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
responses={
|
responses={
|
||||||
@ -216,4 +217,5 @@ class SAMLPropertyMappingViewSet(UsedByMixin, ModelViewSet):
|
|||||||
queryset = SAMLPropertyMapping.objects.all()
|
queryset = SAMLPropertyMapping.objects.all()
|
||||||
serializer_class = SAMLPropertyMappingSerializer
|
serializer_class = SAMLPropertyMappingSerializer
|
||||||
filterset_class = SAMLPropertyMappingFilter
|
filterset_class = SAMLPropertyMappingFilter
|
||||||
|
search_fields = ["name"]
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
"""SAML Identity Provider Metadata Processor"""
|
"""SAML Identity Provider Metadata Processor"""
|
||||||
|
from hashlib import sha256
|
||||||
from typing import Iterator, Optional
|
from typing import Iterator, Optional
|
||||||
|
|
||||||
import xmlsec # nosec
|
import xmlsec # nosec
|
||||||
@ -7,7 +8,6 @@ from django.urls import reverse
|
|||||||
from lxml.etree import Element, SubElement, tostring # nosec
|
from lxml.etree import Element, SubElement, tostring # nosec
|
||||||
|
|
||||||
from authentik.providers.saml.models import SAMLProvider
|
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.providers.saml.utils.encoding import strip_pem_header
|
||||||
from authentik.sources.saml.processors.constants import (
|
from authentik.sources.saml.processors.constants import (
|
||||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||||
@ -35,7 +35,7 @@ class MetadataProcessor:
|
|||||||
self.provider = provider
|
self.provider = provider
|
||||||
self.http_request = request
|
self.http_request = request
|
||||||
self.force_binding = None
|
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]:
|
def get_signing_key_descriptor(self) -> Optional[Element]:
|
||||||
"""Get Signing KeyDescriptor, if enabled for the provider"""
|
"""Get Signing KeyDescriptor, if enabled for the provider"""
|
||||||
|
@ -3,6 +3,7 @@ from base64 import b64decode
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
from xml.etree.ElementTree import ParseError # nosec
|
||||||
|
|
||||||
import xmlsec
|
import xmlsec
|
||||||
from defusedxml import ElementTree
|
from defusedxml import ElementTree
|
||||||
@ -175,7 +176,10 @@ class AuthNRequestParser:
|
|||||||
)
|
)
|
||||||
except xmlsec.Error as exc:
|
except xmlsec.Error as exc:
|
||||||
raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
|
raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
|
||||||
|
try:
|
||||||
return self._parse_xml(decoded_xml, relay_state)
|
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:
|
def idp_initiated(self) -> AuthNRequest:
|
||||||
"""Create IdP Initiated AuthNRequest"""
|
"""Create IdP Initiated AuthNRequest"""
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
"""Test Service-Provider Metadata Parser"""
|
"""Test Service-Provider Metadata Parser"""
|
||||||
# flake8: noqa
|
# 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.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
|
from authentik.providers.saml.processors.metadata_parser import ServiceProviderMetadataParser
|
||||||
|
|
||||||
METADATA_SIMPLE = """<?xml version="1.0"?>
|
METADATA_SIMPLE = """<?xml version="1.0"?>
|
||||||
@ -66,6 +68,23 @@ class TestServiceProviderMetadataParser(TestCase):
|
|||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.flow = create_test_flow()
|
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):
|
def test_simple(self):
|
||||||
"""Test simple metadata without Signing"""
|
"""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