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