Compare commits
142 Commits
version/20
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
49b6c71079 | |||
97acc77e0a | |||
eb1e0427c1 | |||
6e0c9acb34 | |||
b75d659707 | |||
8894861a59 | |||
7878755acd | |||
2b62d6646e | |||
4f81f750ce | |||
fa216e2e93 | |||
181bd903be | |||
23c69c456a | |||
c73fce4f58 | |||
bd0ef69ece | |||
19ee98b36d | |||
75d4246b79 | |||
d2fd84d98c | |||
678378403b | |||
7f32d0eb9a | |||
f1b3598a0f | |||
07767c9376 | |||
5a3f9d1417 | |||
44a6303c91 | |||
5f7f80fdee | |||
a332a465ef | |||
8b16fed926 | |||
be10dd629b | |||
a6a868cbc1 | |||
a9ed275f4e | |||
fbc5378158 | |||
20210b614d | |||
063877a615 | |||
a73d50d379 | |||
9568f4dbd6 | |||
9b2ceb0d44 | |||
2deb185550 | |||
69d4719687 | |||
d31e566873 | |||
0ddcefce80 | |||
4c45d35507 | |||
829e49275d | |||
143309448e | |||
1f038ecee2 | |||
1b1f2ea72c | |||
6e1a54753e | |||
67d1f06c91 | |||
d37de6bc00 | |||
8deced771d | |||
c380512cc8 | |||
e0b06bc4de | |||
1bd6107ec7 | |||
ce1409fb6c | |||
b6b97f4706 | |||
cd12e177ea | |||
31c6ea9fda | |||
20931ccc1d | |||
9c9f441cff | |||
36822c128c | |||
29d3fdaa1d | |||
ac5167b8a3 | |||
0db434a922 | |||
3c0675486c | |||
f6d56e7e29 | |||
fac56390a0 | |||
c6e3229f0b | |||
ace30933bd | |||
d313f1576b | |||
ac07576676 | |||
df42480284 | |||
d2f722f032 | |||
a8fdcab927 | |||
0cba3c7788 | |||
0d414ec0ea | |||
c42b34a46b | |||
7a1050300d | |||
a64e87a6b1 | |||
81e9f2d608 | |||
ddbd8153e2 | |||
f7037b9f33 | |||
67a6fa6399 | |||
a35b8f5862 | |||
5b7c6f1b0e | |||
662101fd1f | |||
3f633460a8 | |||
be2d1a522a | |||
d6f5b8e421 | |||
b424c5dd27 | |||
2a83d79ace | |||
1ed24a5eef | |||
f2961cb536 | |||
4d66e42708 | |||
bd3a721753 | |||
25c3086d7a | |||
1bdd09342a | |||
ad6d773d26 | |||
b555ccd549 | |||
9445354b31 | |||
a42f2f7217 | |||
d1aa1f46da | |||
a1be924fa4 | |||
db60427e21 | |||
d3e2f41561 | |||
8840f6ef63 | |||
3b103b22e2 | |||
158f4c1c4c | |||
42606a499b | |||
c0841120bf | |||
61442a7e4a | |||
98876df5c5 | |||
a9680d6088 | |||
7eb6320d74 | |||
47aba4a996 | |||
643b36b732 | |||
001869641d | |||
bec538c543 | |||
c63ba3f378 | |||
0fb2b5550a | |||
762294c0f9 | |||
2a2ab94e97 | |||
53cab07a48 | |||
2604dc14fe | |||
06f67c738c | |||
1b001060a3 | |||
a960ce9454 | |||
439bdc54d6 | |||
e6b5810e03 | |||
89b73a4d89 | |||
ed3f36e72a | |||
78b711ec9d | |||
4be0a707b1 | |||
1e73b42c58 | |||
3df3bceccb | |||
a4370458cb | |||
742bad4080 | |||
f15946e216 | |||
b54415dcde | |||
471293ba25 | |||
3e7320734c | |||
3131e557d9 | |||
1efc7eecbf | |||
15ec6a9284 | |||
dc1359a763 |
@ -1,5 +1,5 @@
|
|||||||
[bumpversion]
|
[bumpversion]
|
||||||
current_version = 2022.12.3
|
current_version = 2023.1.0
|
||||||
tag = True
|
tag = True
|
||||||
commit = True
|
commit = True
|
||||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
|
||||||
|
58
.github/workflows/ci-main.yml
vendored
58
.github/workflows/ci-main.yml
vendored
@ -102,14 +102,31 @@ jobs:
|
|||||||
uses: helm/kind-action@v1.5.0
|
uses: helm/kind-action@v1.5.0
|
||||||
- name: run integration
|
- name: run integration
|
||||||
run: |
|
run: |
|
||||||
poetry run make test-integration
|
poetry run coverage run manage.py test tests/integration
|
||||||
poetry run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
with:
|
with:
|
||||||
flags: integration
|
flags: integration
|
||||||
test-e2e-provider:
|
test-e2e:
|
||||||
|
name: test-e2e (${{ matrix.job.name }})
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
job:
|
||||||
|
- name: proxy
|
||||||
|
glob: tests/e2e/test_provider_proxy*
|
||||||
|
- name: oauth
|
||||||
|
glob: tests/e2e/test_provider_oauth2_!(oidc) tests/e2e/test_source_oauth*
|
||||||
|
- name: oauth-oidc
|
||||||
|
glob: tests/e2e/test_provider_oauth2_oidc*
|
||||||
|
- name: saml
|
||||||
|
glob: tests/e2e/test_provider_saml* tests/e2e/test_source_saml*
|
||||||
|
- name: ldap
|
||||||
|
glob: tests/e2e/test_provider_ldap*
|
||||||
|
- name: flows
|
||||||
|
glob: tests/e2e/test_flows*
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Setup authentik env
|
- name: Setup authentik env
|
||||||
@ -131,36 +148,8 @@ jobs:
|
|||||||
npm run build
|
npm run build
|
||||||
- name: run e2e
|
- name: run e2e
|
||||||
run: |
|
run: |
|
||||||
poetry run make test-e2e-provider
|
shopt -s extglob
|
||||||
poetry run coverage xml
|
poetry run coverage run manage.py test ${{ matrix.job.glob }}
|
||||||
- if: ${{ always() }}
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
with:
|
|
||||||
flags: e2e
|
|
||||||
test-e2e-rest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Setup authentik env
|
|
||||||
uses: ./.github/actions/setup
|
|
||||||
- name: Setup e2e env (chrome, etc)
|
|
||||||
run: |
|
|
||||||
docker-compose -f tests/e2e/docker-compose.yml up -d
|
|
||||||
- id: cache-web
|
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: web/dist
|
|
||||||
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-ts
|
|
||||||
npm run build
|
|
||||||
- name: run e2e
|
|
||||||
run: |
|
|
||||||
poetry run make test-e2e-rest
|
|
||||||
poetry run coverage xml
|
poetry run coverage xml
|
||||||
- if: ${{ always() }}
|
- if: ${{ always() }}
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v3
|
||||||
@ -173,8 +162,7 @@ jobs:
|
|||||||
- test-migrations-from-stable
|
- test-migrations-from-stable
|
||||||
- test-unittest
|
- test-unittest
|
||||||
- test-integration
|
- test-integration
|
||||||
- test-e2e-rest
|
- test-e2e
|
||||||
- test-e2e-provider
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: echo mark
|
- run: echo mark
|
||||||
@ -205,7 +193,7 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
secrets: |
|
secrets: |
|
||||||
|
4
.github/workflows/ci-outpost.yml
vendored
4
.github/workflows/ci-outpost.yml
vendored
@ -80,7 +80,7 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Generate API
|
- name: Generate API
|
||||||
run: make gen-client-go
|
run: make gen-client-go
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
|
||||||
@ -111,7 +111,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
10
.github/workflows/ci-web.yml
vendored
10
.github/workflows/ci-web.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -31,7 +31,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -47,7 +47,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -63,7 +63,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
@ -95,7 +95,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
2
.github/workflows/ci-website.yml
vendored
2
.github/workflows/ci-website.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
4
.github/workflows/ghcr-retention.yml
vendored
4
.github/workflows/ghcr-retention.yml
vendored
@ -11,12 +11,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Delete 'dev' containers older than a week
|
- name: Delete 'dev' containers older than a week
|
||||||
uses: sondrelg/container-retention-policy@v1
|
uses: snok/container-retention-policy@v1
|
||||||
with:
|
with:
|
||||||
image-names: dev-server,dev-ldap,dev-proxy
|
image-names: dev-server,dev-ldap,dev-proxy
|
||||||
cut-off: One week ago UTC
|
cut-off: One week ago UTC
|
||||||
account-type: org
|
account-type: org
|
||||||
org-name: goauthentik
|
org-name: goauthentik
|
||||||
untagged-only: false
|
untagged-only: false
|
||||||
token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
skip-tags: gh-next,gh-main
|
skip-tags: gh-next,gh-main
|
||||||
|
6
.github/workflows/release-publish.yml
vendored
6
.github/workflows/release-publish.yml
vendored
@ -27,7 +27,7 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
@ -75,7 +75,7 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Building Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v3
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name == 'release' }}
|
push: ${{ github.event_name == 'release' }}
|
||||||
@ -109,7 +109,7 @@ jobs:
|
|||||||
- uses: actions/setup-go@v3
|
- uses: actions/setup-go@v3
|
||||||
with:
|
with:
|
||||||
go-version: "^1.17"
|
go-version: "^1.17"
|
||||||
- uses: actions/setup-node@v3.5.1
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
16
.github/workflows/web-api-publish.yml
vendored
16
.github/workflows/web-api-publish.yml
vendored
@ -10,7 +10,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-node@v3.5.1
|
with:
|
||||||
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
|
- uses: actions/setup-node@v3.6.0
|
||||||
with:
|
with:
|
||||||
node-version: '16'
|
node-version: '16'
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
@ -28,14 +30,20 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
|
||||||
npm i @goauthentik/api@$VERSION
|
npm i @goauthentik/api@$VERSION
|
||||||
- name: Create Pull Request
|
- uses: peter-evans/create-pull-request@v4
|
||||||
uses: peter-evans/create-pull-request@v4
|
|
||||||
id: cpr
|
id: cpr
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
branch: update-web-api-client
|
branch: update-web-api-client
|
||||||
commit-message: "web: bump API Client version"
|
commit-message: "web: bump API Client version"
|
||||||
title: "web: bump API Client version"
|
title: "web: bump API Client version"
|
||||||
body: "web: bump API Client version"
|
body: "web: bump API Client version"
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
signoff: true
|
signoff: true
|
||||||
|
team-reviewers: "@goauthentik/core"
|
||||||
|
author: authentik bot <github-bot@goauthentik.io>
|
||||||
|
- uses: peter-evans/enable-pull-request-automerge@v2
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.BOT_GITHUB_TOKEN }}
|
||||||
|
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
|
||||||
|
merge-method: squash
|
||||||
|
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
@ -14,7 +14,9 @@
|
|||||||
"webauthn",
|
"webauthn",
|
||||||
"traefik",
|
"traefik",
|
||||||
"passwordless",
|
"passwordless",
|
||||||
"kubernetes"
|
"kubernetes",
|
||||||
|
"sso",
|
||||||
|
"slo"
|
||||||
],
|
],
|
||||||
"python.linting.pylintEnabled": true,
|
"python.linting.pylintEnabled": true,
|
||||||
"todo-tree.tree.showCountsInTree": true,
|
"todo-tree.tree.showCountsInTree": true,
|
||||||
|
@ -59,19 +59,18 @@ These are the current packages:
|
|||||||
authentik
|
authentik
|
||||||
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
|
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
|
||||||
├── api - General API Configuration (Routes, Schema and general API utilities)
|
├── api - General API Configuration (Routes, Schema and general API utilities)
|
||||||
|
├── blueprints - Handle managed models and their state.
|
||||||
├── core - Core authentik functionality, central routes, core Models
|
├── core - Core authentik functionality, central routes, core Models
|
||||||
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
|
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
|
||||||
├── events - Event Log, middleware and signals to generate signals
|
├── events - Event Log, middleware and signals to generate signals
|
||||||
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
|
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
|
||||||
├── lib - Generic library of functions, few dependencies on other packages.
|
├── lib - Generic library of functions, few dependencies on other packages.
|
||||||
├── managed - Handle managed models and their state.
|
|
||||||
├── outposts - Configure and deploy outposts on kubernetes and docker.
|
├── outposts - Configure and deploy outposts on kubernetes and docker.
|
||||||
├── policies - General PolicyEngine
|
├── policies - General PolicyEngine
|
||||||
│ ├── dummy - A Dummy policy used for testing
|
│ ├── dummy - A Dummy policy used for testing
|
||||||
│ ├── event_matcher - Match events based on different criteria
|
│ ├── event_matcher - Match events based on different criteria
|
||||||
│ ├── expiry - Check when a user's password was last set
|
│ ├── expiry - Check when a user's password was last set
|
||||||
│ ├── expression - Execute any arbitrary python code
|
│ ├── expression - Execute any arbitrary python code
|
||||||
│ ├── hibp - Check a password against HaveIBeenPwned
|
|
||||||
│ ├── password - Check a password against several rules
|
│ ├── password - Check a password against several rules
|
||||||
│ └── reputation - Check the user's/client's reputation
|
│ └── reputation - Check the user's/client's reputation
|
||||||
├── providers
|
├── providers
|
||||||
|
@ -31,7 +31,7 @@ RUN pip install --no-cache-dir poetry && \
|
|||||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||||
|
|
||||||
# Stage 4: Build go proxy
|
# Stage 4: Build go proxy
|
||||||
FROM docker.io/golang:1.19.4-bullseye AS go-builder
|
FROM docker.io/golang:1.19.5-bullseye AS go-builder
|
||||||
|
|
||||||
WORKDIR /work
|
WORKDIR /work
|
||||||
|
|
||||||
@ -50,6 +50,7 @@ RUN go build -o /work/authentik ./cmd/server/
|
|||||||
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
|
FROM docker.io/maxmindinc/geoipupdate:v4.10 as geoip
|
||||||
|
|
||||||
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City"
|
||||||
|
ENV GEOIPUPDATE_VERBOSE="true"
|
||||||
|
|
||||||
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
||||||
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
|
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
|
||||||
@ -57,7 +58,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
|
|||||||
/bin/sh -c "\
|
/bin/sh -c "\
|
||||||
export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
|
export GEOIPUPDATE_ACCOUNT_ID=$(cat /run/secrets/GEOIPUPDATE_ACCOUNT_ID); \
|
||||||
export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \
|
export GEOIPUPDATE_LICENSE_KEY=$(cat /run/secrets/GEOIPUPDATE_LICENSE_KEY); \
|
||||||
/usr/bin/entry.sh || exit 0 \
|
/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0 \
|
||||||
"
|
"
|
||||||
|
|
||||||
# Stage 6: Run
|
# Stage 6: Run
|
||||||
|
9
Makefile
9
Makefile
@ -6,15 +6,6 @@ NPM_VERSION = $(shell python -m scripts.npm_version)
|
|||||||
|
|
||||||
all: lint-fix lint test gen web
|
all: lint-fix lint test gen web
|
||||||
|
|
||||||
test-integration:
|
|
||||||
coverage run manage.py test tests/integration
|
|
||||||
|
|
||||||
test-e2e-provider:
|
|
||||||
coverage run manage.py test tests/e2e/test_provider*
|
|
||||||
|
|
||||||
test-e2e-rest:
|
|
||||||
coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source*
|
|
||||||
|
|
||||||
test-go:
|
test-go:
|
||||||
go test -timeout 0 -v -race -cover ./...
|
go test -timeout 0 -v -race -cover ./...
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
from os import environ
|
from os import environ
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
__version__ = "2022.12.3"
|
__version__ = "2023.1.0"
|
||||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
"""authentik administration metrics"""
|
"""authentik administration metrics"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
from drf_spectacular.utils import extend_schema, extend_schema_field
|
from drf_spectacular.utils import extend_schema, extend_schema_field
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
from rest_framework.fields import IntegerField, SerializerMethodField
|
from rest_framework.fields import IntegerField, SerializerMethodField
|
||||||
@ -21,38 +24,44 @@ class CoordinateSerializer(PassiveSerializer):
|
|||||||
class LoginMetricsSerializer(PassiveSerializer):
|
class LoginMetricsSerializer(PassiveSerializer):
|
||||||
"""Login Metrics per 1h"""
|
"""Login Metrics per 1h"""
|
||||||
|
|
||||||
logins_per_1h = SerializerMethodField()
|
logins = SerializerMethodField()
|
||||||
logins_failed_per_1h = SerializerMethodField()
|
logins_failed = SerializerMethodField()
|
||||||
authorizations_per_1h = SerializerMethodField()
|
authorizations = SerializerMethodField()
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_per_1h(self, _):
|
def get_logins(self, _):
|
||||||
"""Get successful logins per hour for the last 24 hours"""
|
"""Get successful logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.LOGIN)
|
action=EventAction.LOGIN
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed_per_1h(self, _):
|
def get_logins_failed(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.LOGIN_FAILED)
|
action=EventAction.LOGIN_FAILED
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_authorizations_per_1h(self, _):
|
def get_authorizations(self, _):
|
||||||
"""Get successful authorizations per hour for the last 24 hours"""
|
"""Get successful authorizations per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.AUTHORIZE_APPLICATION)
|
action=EventAction.AUTHORIZE_APPLICATION
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -7,7 +7,13 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
|
from rest_framework.fields import (
|
||||||
|
CharField,
|
||||||
|
ChoiceField,
|
||||||
|
DateTimeField,
|
||||||
|
ListField,
|
||||||
|
SerializerMethodField,
|
||||||
|
)
|
||||||
from rest_framework.permissions import IsAdminUser
|
from rest_framework.permissions import IsAdminUser
|
||||||
from rest_framework.request import Request
|
from rest_framework.request import Request
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@ -26,6 +32,7 @@ class TaskSerializer(PassiveSerializer):
|
|||||||
task_name = CharField()
|
task_name = CharField()
|
||||||
task_description = CharField()
|
task_description = CharField()
|
||||||
task_finish_timestamp = DateTimeField(source="finish_time")
|
task_finish_timestamp = DateTimeField(source="finish_time")
|
||||||
|
task_duration = SerializerMethodField()
|
||||||
|
|
||||||
status = ChoiceField(
|
status = ChoiceField(
|
||||||
source="result.status.name",
|
source="result.status.name",
|
||||||
@ -33,7 +40,11 @@ class TaskSerializer(PassiveSerializer):
|
|||||||
)
|
)
|
||||||
messages = ListField(source="result.messages")
|
messages = ListField(source="result.messages")
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def get_task_duration(self, instance: TaskInfo) -> int:
|
||||||
|
"""Get the duration a task took to run"""
|
||||||
|
return max(instance.finish_timestamp - instance.start_timestamp, 0)
|
||||||
|
|
||||||
|
def to_representation(self, instance: TaskInfo):
|
||||||
"""When a new version of authentik adds fields to TaskInfo,
|
"""When a new version of authentik adds fields to TaskInfo,
|
||||||
the API will fail with an AttributeError, as the classes
|
the API will fail with an AttributeError, as the classes
|
||||||
are pickled in cache. In that case, just delete the info"""
|
are pickled in cache. In that case, just delete the info"""
|
||||||
@ -68,7 +79,6 @@ class TaskViewSet(ViewSet):
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
# pylint: disable=invalid-name
|
|
||||||
def retrieve(self, request: Request, pk=None) -> Response:
|
def retrieve(self, request: Request, pk=None) -> Response:
|
||||||
"""Get a single system task"""
|
"""Get a single system task"""
|
||||||
task = TaskInfo.by_name(pk)
|
task = TaskInfo.by_name(pk)
|
||||||
@ -99,7 +109,6 @@ class TaskViewSet(ViewSet):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["post"])
|
@action(detail=True, methods=["post"])
|
||||||
# pylint: disable=invalid-name
|
|
||||||
def retry(self, request: Request, pk=None) -> Response:
|
def retry(self, request: Request, pk=None) -> Response:
|
||||||
"""Retry task"""
|
"""Retry task"""
|
||||||
task = TaskInfo.by_name(pk)
|
task = TaskInfo.by_name(pk)
|
||||||
|
@ -8,7 +8,6 @@ from authentik.root.monitoring import monitoring_set
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_workers(sender, **kwargs):
|
def monitoring_set_workers(sender, **kwargs):
|
||||||
"""Set worker gauge"""
|
"""Set worker gauge"""
|
||||||
count = len(CELERY_APP.control.ping(timeout=0.5))
|
count = len(CELERY_APP.control.ping(timeout=0.5))
|
||||||
@ -16,7 +15,6 @@ def monitoring_set_workers(sender, **kwargs):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_tasks(sender, **kwargs):
|
def monitoring_set_tasks(sender, **kwargs):
|
||||||
"""Set task gauges"""
|
"""Set task gauges"""
|
||||||
for task in TaskInfo.all().values():
|
for task in TaskInfo.all().values():
|
||||||
|
@ -62,7 +62,7 @@ window.addEventListener('DOMContentLoaded', (event) => {
|
|||||||
allow-spec-url-load="false"
|
allow-spec-url-load="false"
|
||||||
allow-spec-file-load="false">
|
allow-spec-file-load="false">
|
||||||
<div slot="nav-logo">
|
<div slot="nav-logo">
|
||||||
<img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
<img alt="authentik Logo" class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
||||||
</div>
|
</div>
|
||||||
</rapi-doc>
|
</rapi-doc>
|
||||||
<script>
|
<script>
|
||||||
|
@ -45,7 +45,6 @@ from authentik.policies.dummy.api import DummyPolicyViewSet
|
|||||||
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
|
from authentik.policies.event_matcher.api import EventMatcherPolicyViewSet
|
||||||
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
|
from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
|
||||||
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
from authentik.policies.expression.api import ExpressionPolicyViewSet
|
||||||
from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
|
|
||||||
from authentik.policies.password.api import PasswordPolicyViewSet
|
from authentik.policies.password.api import PasswordPolicyViewSet
|
||||||
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
|
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
|
||||||
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
|
||||||
@ -150,7 +149,6 @@ router.register("policies/all", PolicyViewSet)
|
|||||||
router.register("policies/bindings", PolicyBindingViewSet)
|
router.register("policies/bindings", PolicyBindingViewSet)
|
||||||
router.register("policies/expression", ExpressionPolicyViewSet)
|
router.register("policies/expression", ExpressionPolicyViewSet)
|
||||||
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
|
router.register("policies/event_matcher", EventMatcherPolicyViewSet)
|
||||||
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
|
|
||||||
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
|
||||||
router.register("policies/password", PasswordPolicyViewSet)
|
router.register("policies/password", PasswordPolicyViewSet)
|
||||||
router.register("policies/reputation/scores", ReputationViewSet)
|
router.register("policies/reputation/scores", ReputationViewSet)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
"""Serializer mixin for managed models"""
|
"""Serializer mixin for managed models"""
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from drf_spectacular.utils import extend_schema, inline_serializer
|
from drf_spectacular.utils import extend_schema, inline_serializer
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import ValidationError
|
||||||
@ -11,6 +12,7 @@ from rest_framework.viewsets import ModelViewSet
|
|||||||
|
|
||||||
from authentik.api.decorators import permission_required
|
from authentik.api.decorators import permission_required
|
||||||
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||||
|
from authentik.blueprints.v1.importer import Importer
|
||||||
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
@ -40,6 +42,21 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
|||||||
raise ValidationError(exc) from exc
|
raise ValidationError(exc) from exc
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
def validate_content(self, content: str) -> str:
|
||||||
|
"""Ensure content (if set) is a valid blueprint"""
|
||||||
|
if content == "":
|
||||||
|
return content
|
||||||
|
context = self.instance.context if self.instance else {}
|
||||||
|
valid, logs = Importer(content, context).validate()
|
||||||
|
if not valid:
|
||||||
|
raise ValidationError(_("Failed to validate blueprint"), *[x["msg"] for x in logs])
|
||||||
|
return content
|
||||||
|
|
||||||
|
def validate(self, attrs: dict) -> dict:
|
||||||
|
if attrs.get("path", "") == "" and attrs.get("content", "") == "":
|
||||||
|
raise ValidationError(_("Either path or content must be set."))
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = BlueprintInstance
|
model = BlueprintInstance
|
||||||
@ -54,6 +71,7 @@ class BlueprintInstanceSerializer(ModelSerializer):
|
|||||||
"enabled",
|
"enabled",
|
||||||
"managed_models",
|
"managed_models",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"content",
|
||||||
]
|
]
|
||||||
extra_kwargs = {
|
extra_kwargs = {
|
||||||
"status": {"read_only": True},
|
"status": {"read_only": True},
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-10 19:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_blueprints", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="blueprintinstance",
|
||||||
|
name="content",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="blueprintinstance",
|
||||||
|
name="path",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
]
|
@ -1,30 +1,18 @@
|
|||||||
"""blueprint models"""
|
"""blueprint models"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from opencontainers.distribution.reggie import (
|
|
||||||
NewClient,
|
|
||||||
WithDebug,
|
|
||||||
WithDefaultName,
|
|
||||||
WithDigest,
|
|
||||||
WithReference,
|
|
||||||
WithUserAgent,
|
|
||||||
WithUsernamePassword,
|
|
||||||
)
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
from rest_framework.serializers import Serializer
|
from rest_framework.serializers import Serializer
|
||||||
from structlog import get_logger
|
from structlog import get_logger
|
||||||
|
|
||||||
|
from authentik.blueprints.v1.oci import BlueprintOCIClient, OCIException
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
|
||||||
from authentik.lib.sentry import SentryIgnoredException
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
from authentik.lib.utils.http import authentik_user_agent
|
|
||||||
|
|
||||||
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@ -74,7 +62,8 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
name = models.TextField()
|
name = models.TextField()
|
||||||
metadata = models.JSONField(default=dict)
|
metadata = models.JSONField(default=dict)
|
||||||
path = models.TextField()
|
path = models.TextField(default="", blank=True)
|
||||||
|
content = models.TextField(default="", blank=True)
|
||||||
context = models.JSONField(default=dict)
|
context = models.JSONField(default=dict)
|
||||||
last_applied = models.DateTimeField(auto_now=True)
|
last_applied = models.DateTimeField(auto_now=True)
|
||||||
last_applied_hash = models.TextField()
|
last_applied_hash = models.TextField()
|
||||||
@ -86,60 +75,29 @@ class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
|
|||||||
|
|
||||||
def retrieve_oci(self) -> str:
|
def retrieve_oci(self) -> str:
|
||||||
"""Get blueprint from an OCI registry"""
|
"""Get blueprint from an OCI registry"""
|
||||||
url = urlparse(self.path)
|
client = BlueprintOCIClient(self.path.replace("oci://", "https://"))
|
||||||
ref = "latest"
|
|
||||||
path = url.path[1:]
|
|
||||||
if ":" in url.path:
|
|
||||||
path, _, ref = path.partition(":")
|
|
||||||
client = NewClient(
|
|
||||||
f"https://{url.hostname}",
|
|
||||||
WithUserAgent(authentik_user_agent()),
|
|
||||||
WithUsernamePassword(url.username, url.password),
|
|
||||||
WithDefaultName(path),
|
|
||||||
WithDebug(True),
|
|
||||||
)
|
|
||||||
LOGGER.info("Fetching OCI manifests for blueprint", instance=self)
|
|
||||||
manifest_request = client.NewRequest(
|
|
||||||
"GET",
|
|
||||||
"/v2/<name>/manifests/<reference>",
|
|
||||||
WithReference(ref),
|
|
||||||
).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
|
|
||||||
try:
|
try:
|
||||||
manifest_response = client.Do(manifest_request)
|
manifests = client.fetch_manifests()
|
||||||
manifest_response.raise_for_status()
|
return client.fetch_blobs(manifests)
|
||||||
except RequestException as exc:
|
except OCIException as exc:
|
||||||
raise BlueprintRetrievalFailed(exc) from exc
|
raise BlueprintRetrievalFailed(exc) from exc
|
||||||
manifest = manifest_response.json()
|
|
||||||
if "errors" in manifest:
|
|
||||||
raise BlueprintRetrievalFailed(manifest["errors"])
|
|
||||||
|
|
||||||
blob = None
|
def retrieve_file(self) -> str:
|
||||||
for layer in manifest.get("layers", []):
|
"""Get blueprint from path"""
|
||||||
if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
|
|
||||||
blob = layer.get("digest")
|
|
||||||
LOGGER.debug("Found layer with matching media type", instance=self, blob=blob)
|
|
||||||
if not blob:
|
|
||||||
raise BlueprintRetrievalFailed("Blob not found")
|
|
||||||
|
|
||||||
blob_request = client.NewRequest(
|
|
||||||
"GET",
|
|
||||||
"/v2/<name>/blobs/<digest>",
|
|
||||||
WithDigest(blob),
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
blob_response = client.Do(blob_request)
|
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
||||||
blob_response.raise_for_status()
|
with full_path.open("r", encoding="utf-8") as _file:
|
||||||
return blob_response.text
|
return _file.read()
|
||||||
except RequestException as exc:
|
except (IOError, OSError) as exc:
|
||||||
raise BlueprintRetrievalFailed(exc) from exc
|
raise BlueprintRetrievalFailed(exc) from exc
|
||||||
|
|
||||||
def retrieve(self) -> str:
|
def retrieve(self) -> str:
|
||||||
"""Retrieve blueprint contents"""
|
"""Retrieve blueprint contents"""
|
||||||
if self.path.startswith("oci://"):
|
if self.path.startswith("oci://"):
|
||||||
return self.retrieve_oci()
|
return self.retrieve_oci()
|
||||||
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
|
if self.path != "":
|
||||||
with full_path.open("r", encoding="utf-8") as _file:
|
return self.retrieve_file()
|
||||||
return _file.read()
|
return self.content
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def serializer(self) -> Serializer:
|
def serializer(self) -> Serializer:
|
||||||
|
51
authentik/blueprints/tests/fixtures/tags.yaml
vendored
51
authentik/blueprints/tests/fixtures/tags.yaml
vendored
@ -3,6 +3,12 @@ context:
|
|||||||
foo: bar
|
foo: bar
|
||||||
policy_property: name
|
policy_property: name
|
||||||
policy_property_value: foo-bar-baz-qux
|
policy_property_value: foo-bar-baz-qux
|
||||||
|
sequence:
|
||||||
|
- foo
|
||||||
|
- bar
|
||||||
|
mapping:
|
||||||
|
key1: value
|
||||||
|
key2: 2
|
||||||
entries:
|
entries:
|
||||||
- model: !Format ["%s", authentik_sources_oauth.oauthsource]
|
- model: !Format ["%s", authentik_sources_oauth.oauthsource]
|
||||||
state: !Format ["%s", present]
|
state: !Format ["%s", present]
|
||||||
@ -19,7 +25,7 @@ entries:
|
|||||||
[slug, default-source-authentication],
|
[slug, default-source-authentication],
|
||||||
]
|
]
|
||||||
enrollment_flow:
|
enrollment_flow:
|
||||||
!Find [authentik_flows.Flow, [slug, default-source-enrollment]]
|
!Find [!Format ["%s", authentik_flows.Flow], [slug, default-source-enrollment]]
|
||||||
- attrs:
|
- attrs:
|
||||||
expression: return True
|
expression: return True
|
||||||
identifiers:
|
identifiers:
|
||||||
@ -92,6 +98,49 @@ entries:
|
|||||||
]
|
]
|
||||||
if_true_simple: !If [!Context foo, true, text]
|
if_true_simple: !If [!Context foo, true, text]
|
||||||
if_false_simple: !If [null, false, 2]
|
if_false_simple: !If [null, false, 2]
|
||||||
|
enumerate_mapping_to_mapping: !Enumerate [
|
||||||
|
!Context mapping,
|
||||||
|
MAP,
|
||||||
|
[!Format ["prefix-%s", !Index 0], !Format ["other-prefix-%s", !Value 0]]
|
||||||
|
]
|
||||||
|
enumerate_mapping_to_sequence: !Enumerate [
|
||||||
|
!Context mapping,
|
||||||
|
SEQ,
|
||||||
|
!Format ["prefixed-pair-%s-%s", !Index 0, !Value 0]
|
||||||
|
]
|
||||||
|
enumerate_sequence_to_sequence: !Enumerate [
|
||||||
|
!Context sequence,
|
||||||
|
SEQ,
|
||||||
|
!Format ["prefixed-items-%s-%s", !Index 0, !Value 0]
|
||||||
|
]
|
||||||
|
enumerate_sequence_to_mapping: !Enumerate [
|
||||||
|
!Context sequence,
|
||||||
|
MAP,
|
||||||
|
[!Format ["index: %d", !Index 0], !Value 0]
|
||||||
|
]
|
||||||
|
nested_complex_enumeration: !Enumerate [
|
||||||
|
!Context sequence,
|
||||||
|
MAP,
|
||||||
|
[
|
||||||
|
!Index 0,
|
||||||
|
!Enumerate [
|
||||||
|
!Context mapping,
|
||||||
|
MAP,
|
||||||
|
[
|
||||||
|
!Format ["%s", !Index 0],
|
||||||
|
[
|
||||||
|
!Enumerate [!Value 2, SEQ, !Format ["prefixed-%s", !Value 0]],
|
||||||
|
{
|
||||||
|
outer_value: !Value 1,
|
||||||
|
outer_index: !Index 1,
|
||||||
|
middle_value: !Value 0,
|
||||||
|
middle_index: !Index 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
identifiers:
|
identifiers:
|
||||||
name: test
|
name: test
|
||||||
conditions:
|
conditions:
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
from django.test import TransactionTestCase
|
from django.test import TransactionTestCase
|
||||||
from requests_mock import Mocker
|
from requests_mock import Mocker
|
||||||
|
|
||||||
from authentik.blueprints.models import OCI_MEDIA_TYPE, BlueprintInstance, BlueprintRetrievalFailed
|
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
|
||||||
|
from authentik.blueprints.v1.oci import OCI_MEDIA_TYPE
|
||||||
|
|
||||||
|
|
||||||
class TestBlueprintOCI(TransactionTestCase):
|
class TestBlueprintOCI(TransactionTestCase):
|
||||||
|
@ -162,6 +162,61 @@ class TestBlueprintsV1(TransactionTestCase):
|
|||||||
"if_false_complex": ["list", "with", "items", "foo-bar"],
|
"if_false_complex": ["list", "with", "items", "foo-bar"],
|
||||||
"if_true_simple": True,
|
"if_true_simple": True,
|
||||||
"if_false_simple": 2,
|
"if_false_simple": 2,
|
||||||
|
"enumerate_mapping_to_mapping": {
|
||||||
|
"prefix-key1": "other-prefix-value",
|
||||||
|
"prefix-key2": "other-prefix-2",
|
||||||
|
},
|
||||||
|
"enumerate_mapping_to_sequence": [
|
||||||
|
"prefixed-pair-key1-value",
|
||||||
|
"prefixed-pair-key2-2",
|
||||||
|
],
|
||||||
|
"enumerate_sequence_to_sequence": [
|
||||||
|
"prefixed-items-0-foo",
|
||||||
|
"prefixed-items-1-bar",
|
||||||
|
],
|
||||||
|
"enumerate_sequence_to_mapping": {"index: 0": "foo", "index: 1": "bar"},
|
||||||
|
"nested_complex_enumeration": {
|
||||||
|
"0": {
|
||||||
|
"key1": [
|
||||||
|
["prefixed-f", "prefixed-o", "prefixed-o"],
|
||||||
|
{
|
||||||
|
"outer_value": "foo",
|
||||||
|
"outer_index": 0,
|
||||||
|
"middle_value": "value",
|
||||||
|
"middle_index": "key1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"key2": [
|
||||||
|
["prefixed-f", "prefixed-o", "prefixed-o"],
|
||||||
|
{
|
||||||
|
"outer_value": "foo",
|
||||||
|
"outer_index": 0,
|
||||||
|
"middle_value": 2,
|
||||||
|
"middle_index": "key2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
"key1": [
|
||||||
|
["prefixed-b", "prefixed-a", "prefixed-r"],
|
||||||
|
{
|
||||||
|
"outer_value": "bar",
|
||||||
|
"outer_index": 1,
|
||||||
|
"middle_value": "value",
|
||||||
|
"middle_index": "key1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"key2": [
|
||||||
|
["prefixed-b", "prefixed-a", "prefixed-r"],
|
||||||
|
{
|
||||||
|
"outer_value": "bar",
|
||||||
|
"outer_index": 1,
|
||||||
|
"middle_value": 2,
|
||||||
|
"middle_index": "key2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -43,3 +43,28 @@ class TestBlueprintsV1API(APITestCase):
|
|||||||
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_api_blank(self):
|
||||||
|
"""Test blank"""
|
||||||
|
res = self.client.post(
|
||||||
|
reverse("authentik_api:blueprintinstance-list"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertJSONEqual(
|
||||||
|
res.content.decode(), {"non_field_errors": ["Either path or content must be set."]}
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_content(self):
|
||||||
|
"""Test blank"""
|
||||||
|
res = self.client.post(
|
||||||
|
reverse("authentik_api:blueprintinstance-list"),
|
||||||
|
data={
|
||||||
|
"name": "foo",
|
||||||
|
"content": '{"version": 3}',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.assertEqual(res.status_code, 400)
|
||||||
|
self.assertJSONEqual(res.content.decode(), {"content": ["Failed to validate blueprint"]})
|
||||||
|
@ -1,13 +1,15 @@
|
|||||||
"""transfer common classes"""
|
"""transfer common classes"""
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from copy import copy
|
||||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
from operator import ixor
|
from operator import ixor
|
||||||
from os import getenv
|
from os import getenv
|
||||||
from typing import Any, Literal, Optional, Union
|
from typing import Any, Iterable, Literal, Mapping, Optional, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
from deepmerge import always_merger
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db.models import Model, Q
|
from django.db.models import Model, Q
|
||||||
from rest_framework.fields import Field
|
from rest_framework.fields import Field
|
||||||
@ -64,11 +66,13 @@ class BlueprintEntry:
|
|||||||
identifiers: dict[str, Any] = field(default_factory=dict)
|
identifiers: dict[str, Any] = field(default_factory=dict)
|
||||||
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
|
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
|
||||||
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
|
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
|
||||||
|
|
||||||
|
def __post_init__(self, *args, **kwargs) -> None:
|
||||||
|
self.__tag_contexts: list["YAMLTagContext"] = []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
|
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
|
||||||
"""Convert a SerializerModel instance to a blueprint Entry"""
|
"""Convert a SerializerModel instance to a blueprint Entry"""
|
||||||
@ -85,17 +89,46 @@ class BlueprintEntry:
|
|||||||
attrs=all_attrs,
|
attrs=all_attrs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_tag_context(
|
||||||
|
self,
|
||||||
|
depth: int = 0,
|
||||||
|
context_tag_type: Optional[type["YAMLTagContext"] | tuple["YAMLTagContext", ...]] = None,
|
||||||
|
) -> "YAMLTagContext":
|
||||||
|
"""Get a YAMLTagContext object located at a certain depth in the tag tree"""
|
||||||
|
if depth < 0:
|
||||||
|
raise ValueError("depth must be a positive number or zero")
|
||||||
|
|
||||||
|
if context_tag_type:
|
||||||
|
contexts = [x for x in self.__tag_contexts if isinstance(x, context_tag_type)]
|
||||||
|
else:
|
||||||
|
contexts = self.__tag_contexts
|
||||||
|
|
||||||
|
try:
|
||||||
|
return contexts[-(depth + 1)]
|
||||||
|
except IndexError:
|
||||||
|
raise ValueError(f"invalid depth: {depth}. Max depth: {len(contexts) - 1}")
|
||||||
|
|
||||||
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
|
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
|
||||||
"""Check if we have any special tags that need handling"""
|
"""Check if we have any special tags that need handling"""
|
||||||
|
val = copy(value)
|
||||||
|
|
||||||
|
if isinstance(value, YAMLTagContext):
|
||||||
|
self.__tag_contexts.append(value)
|
||||||
|
|
||||||
if isinstance(value, YAMLTag):
|
if isinstance(value, YAMLTag):
|
||||||
return value.resolve(self, blueprint)
|
val = value.resolve(self, blueprint)
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
for key, inner_value in value.items():
|
for key, inner_value in value.items():
|
||||||
value[key] = self.tag_resolver(inner_value, blueprint)
|
val[key] = self.tag_resolver(inner_value, blueprint)
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
for idx, inner_value in enumerate(value):
|
for idx, inner_value in enumerate(value):
|
||||||
value[idx] = self.tag_resolver(inner_value, blueprint)
|
val[idx] = self.tag_resolver(inner_value, blueprint)
|
||||||
return value
|
|
||||||
|
if isinstance(value, YAMLTagContext):
|
||||||
|
self.__tag_contexts.pop()
|
||||||
|
|
||||||
|
return val
|
||||||
|
|
||||||
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
|
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
|
||||||
"""Get attributes of this entry, with all yaml tags resolved"""
|
"""Get attributes of this entry, with all yaml tags resolved"""
|
||||||
@ -145,12 +178,19 @@ class YAMLTag:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class YAMLTagContext:
|
||||||
|
"""Base class for all YAML Tag Contexts"""
|
||||||
|
|
||||||
|
def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
"""Implement yaml tag context logic"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
class KeyOf(YAMLTag):
|
class KeyOf(YAMLTag):
|
||||||
"""Reference another object by their ID"""
|
"""Reference another object by their ID"""
|
||||||
|
|
||||||
id_from: str
|
id_from: str
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.id_from = node.value
|
self.id_from = node.value
|
||||||
@ -177,7 +217,6 @@ class Env(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Optional[Any]
|
default: Optional[Any]
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
@ -197,7 +236,6 @@ class Context(YAMLTag):
|
|||||||
key: str
|
key: str
|
||||||
default: Optional[Any]
|
default: Optional[Any]
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.default = None
|
self.default = None
|
||||||
@ -220,7 +258,6 @@ class Format(YAMLTag):
|
|||||||
format_string: str
|
format_string: str
|
||||||
args: list[Any]
|
args: list[Any]
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.format_string = node.value[0].value
|
self.format_string = node.value[0].value
|
||||||
@ -245,15 +282,12 @@ class Format(YAMLTag):
|
|||||||
class Find(YAMLTag):
|
class Find(YAMLTag):
|
||||||
"""Find any object"""
|
"""Find any object"""
|
||||||
|
|
||||||
model_name: str
|
model_name: str | YAMLTag
|
||||||
conditions: list[list]
|
conditions: list[list]
|
||||||
|
|
||||||
model_class: type[Model]
|
|
||||||
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.model_name = node.value[0].value
|
self.model_name = loader.construct_object(node.value[0])
|
||||||
self.model_class = apps.get_model(*self.model_name.split("."))
|
|
||||||
self.conditions = []
|
self.conditions = []
|
||||||
for raw_node in node.value[1:]:
|
for raw_node in node.value[1:]:
|
||||||
values = []
|
values = []
|
||||||
@ -262,6 +296,13 @@ class Find(YAMLTag):
|
|||||||
self.conditions.append(values)
|
self.conditions.append(values)
|
||||||
|
|
||||||
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
if isinstance(self.model_name, YAMLTag):
|
||||||
|
model_name = self.model_name.resolve(entry, blueprint)
|
||||||
|
else:
|
||||||
|
model_name = self.model_name
|
||||||
|
|
||||||
|
model_class = apps.get_model(*model_name.split("."))
|
||||||
|
|
||||||
query = Q()
|
query = Q()
|
||||||
for cond in self.conditions:
|
for cond in self.conditions:
|
||||||
if isinstance(cond[0], YAMLTag):
|
if isinstance(cond[0], YAMLTag):
|
||||||
@ -273,7 +314,7 @@ class Find(YAMLTag):
|
|||||||
else:
|
else:
|
||||||
query_value = cond[1]
|
query_value = cond[1]
|
||||||
query &= Q(**{query_key: query_value})
|
query &= Q(**{query_key: query_value})
|
||||||
instance = self.model_class.objects.filter(query).first()
|
instance = model_class.objects.filter(query).first()
|
||||||
if instance:
|
if instance:
|
||||||
return instance.pk
|
return instance.pk
|
||||||
return None
|
return None
|
||||||
@ -296,7 +337,6 @@ class Condition(YAMLTag):
|
|||||||
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
|
"XNOR": lambda args: not (reduce(ixor, args) if len(args) > 1 else args[0]),
|
||||||
}
|
}
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.mode = node.value[0].value
|
self.mode = node.value[0].value
|
||||||
@ -329,7 +369,6 @@ class If(YAMLTag):
|
|||||||
when_true: Any
|
when_true: Any
|
||||||
when_false: Any
|
when_false: Any
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.condition = loader.construct_object(node.value[0])
|
self.condition = loader.construct_object(node.value[0])
|
||||||
@ -351,6 +390,133 @@ class If(YAMLTag):
|
|||||||
raise EntryInvalidError(exc)
|
raise EntryInvalidError(exc)
|
||||||
|
|
||||||
|
|
||||||
|
class Enumerate(YAMLTag, YAMLTagContext):
|
||||||
|
"""Iterate over an iterable."""
|
||||||
|
|
||||||
|
iterable: YAMLTag | Iterable
|
||||||
|
item_body: Any
|
||||||
|
output_body: Literal["SEQ", "MAP"]
|
||||||
|
|
||||||
|
_OUTPUT_BODIES = {
|
||||||
|
"SEQ": (list, lambda a, b: [*a, b]),
|
||||||
|
"MAP": (
|
||||||
|
dict,
|
||||||
|
lambda a, b: always_merger.merge(
|
||||||
|
a, {b[0]: b[1]} if isinstance(b, (tuple, list)) else b
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.iterable = loader.construct_object(node.value[0])
|
||||||
|
self.output_body = node.value[1].value
|
||||||
|
self.item_body = loader.construct_object(node.value[2])
|
||||||
|
self.__current_context: tuple[Any, Any] = tuple()
|
||||||
|
|
||||||
|
def get_context(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
return self.__current_context
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
if isinstance(self.iterable, EnumeratedItem) and self.iterable.depth == 0:
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"{self.__class__.__name__} tag's iterable references this tag's context. "
|
||||||
|
"This is a noop. Check you are setting depth bigger than 0."
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(self.iterable, YAMLTag):
|
||||||
|
iterable = self.iterable.resolve(entry, blueprint)
|
||||||
|
else:
|
||||||
|
iterable = self.iterable
|
||||||
|
|
||||||
|
if not isinstance(iterable, Iterable):
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"{self.__class__.__name__}'s iterable must be an iterable "
|
||||||
|
"such as a sequence or a mapping"
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(iterable, Mapping):
|
||||||
|
iterable = tuple(iterable.items())
|
||||||
|
else:
|
||||||
|
iterable = tuple(enumerate(iterable))
|
||||||
|
|
||||||
|
try:
|
||||||
|
output_class, add_fn = self._OUTPUT_BODIES[self.output_body.upper()]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise EntryInvalidError(exc)
|
||||||
|
|
||||||
|
result = output_class()
|
||||||
|
|
||||||
|
self.__current_context = tuple()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in iterable:
|
||||||
|
self.__current_context = item
|
||||||
|
resolved_body = entry.tag_resolver(self.item_body, blueprint)
|
||||||
|
result = add_fn(result, resolved_body)
|
||||||
|
if not isinstance(result, output_class):
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"Invalid {self.__class__.__name__} item found: {resolved_body}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.__current_context = tuple()
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class EnumeratedItem(YAMLTag):
|
||||||
|
"""Get the current item value and index provided by an Enumerate tag context"""
|
||||||
|
|
||||||
|
depth: int
|
||||||
|
|
||||||
|
_SUPPORTED_CONTEXT_TAGS = (Enumerate,)
|
||||||
|
|
||||||
|
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.depth = int(node.value)
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
try:
|
||||||
|
context_tag: Enumerate = entry._get_tag_context(
|
||||||
|
depth=self.depth,
|
||||||
|
context_tag_type=EnumeratedItem._SUPPORTED_CONTEXT_TAGS,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
if self.depth == 0:
|
||||||
|
raise EntryInvalidError(
|
||||||
|
f"{self.__class__.__name__} tags are only usable "
|
||||||
|
f"inside an {Enumerate.__name__} tag"
|
||||||
|
)
|
||||||
|
|
||||||
|
raise EntryInvalidError(f"{self.__class__.__name__} tag: {exc}")
|
||||||
|
|
||||||
|
return context_tag.get_context(entry, blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
class Index(EnumeratedItem):
|
||||||
|
"""Get the current item index provided by an Enumerate tag context"""
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
context = super().resolve(entry, blueprint)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return context[0]
|
||||||
|
except IndexError: # pragma: no cover
|
||||||
|
raise EntryInvalidError(f"Empty/invalid context: {context}")
|
||||||
|
|
||||||
|
|
||||||
|
class Value(EnumeratedItem):
|
||||||
|
"""Get the current item value provided by an Enumerate tag context"""
|
||||||
|
|
||||||
|
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
|
||||||
|
context = super().resolve(entry, blueprint)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return context[1]
|
||||||
|
except IndexError: # pragma: no cover
|
||||||
|
raise EntryInvalidError(f"Empty/invalid context: {context}")
|
||||||
|
|
||||||
|
|
||||||
class BlueprintDumper(SafeDumper):
|
class BlueprintDumper(SafeDumper):
|
||||||
"""Dump dataclasses to yaml"""
|
"""Dump dataclasses to yaml"""
|
||||||
|
|
||||||
@ -394,6 +560,9 @@ class BlueprintLoader(SafeLoader):
|
|||||||
self.add_constructor("!Condition", Condition)
|
self.add_constructor("!Condition", Condition)
|
||||||
self.add_constructor("!If", If)
|
self.add_constructor("!If", If)
|
||||||
self.add_constructor("!Env", Env)
|
self.add_constructor("!Env", Env)
|
||||||
|
self.add_constructor("!Enumerate", Enumerate)
|
||||||
|
self.add_constructor("!Value", Value)
|
||||||
|
self.add_constructor("!Index", Index)
|
||||||
|
|
||||||
|
|
||||||
class EntryInvalidError(SentryIgnoredException):
|
class EntryInvalidError(SentryIgnoredException):
|
||||||
|
98
authentik/blueprints/v1/oci.py
Normal file
98
authentik/blueprints/v1/oci.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
"""OCI Client"""
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import ParseResult, urlparse
|
||||||
|
|
||||||
|
from opencontainers.distribution.reggie import (
|
||||||
|
NewClient,
|
||||||
|
WithDebug,
|
||||||
|
WithDefaultName,
|
||||||
|
WithDigest,
|
||||||
|
WithReference,
|
||||||
|
WithUserAgent,
|
||||||
|
WithUsernamePassword,
|
||||||
|
)
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from structlog import get_logger
|
||||||
|
from structlog.stdlib import BoundLogger
|
||||||
|
|
||||||
|
from authentik.lib.sentry import SentryIgnoredException
|
||||||
|
from authentik.lib.utils.http import authentik_user_agent
|
||||||
|
|
||||||
|
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
|
||||||
|
|
||||||
|
|
||||||
|
class OCIException(SentryIgnoredException):
|
||||||
|
"""OCI-related errors"""
|
||||||
|
|
||||||
|
|
||||||
|
class BlueprintOCIClient:
|
||||||
|
"""Blueprint OCI Client"""
|
||||||
|
|
||||||
|
url: ParseResult
|
||||||
|
sanitized_url: str
|
||||||
|
logger: BoundLogger
|
||||||
|
ref: str
|
||||||
|
client: NewClient
|
||||||
|
|
||||||
|
def __init__(self, url: str) -> None:
|
||||||
|
self._parse_url(url)
|
||||||
|
self.logger = get_logger().bind(url=self.sanitized_url)
|
||||||
|
|
||||||
|
self.ref = "latest"
|
||||||
|
path = self.url.path[1:]
|
||||||
|
if ":" in self.url.path:
|
||||||
|
path, _, self.ref = path.partition(":")
|
||||||
|
self.client = NewClient(
|
||||||
|
f"https://{self.url.hostname}",
|
||||||
|
WithUserAgent(authentik_user_agent()),
|
||||||
|
WithUsernamePassword(self.url.username, self.url.password),
|
||||||
|
WithDefaultName(path),
|
||||||
|
WithDebug(True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_url(self, url: str):
|
||||||
|
self.url = urlparse(url)
|
||||||
|
netloc = self.url.netloc
|
||||||
|
if "@" in netloc:
|
||||||
|
netloc = netloc[netloc.index("@") + 1 :]
|
||||||
|
self.sanitized_url = self.url._replace(netloc=netloc).geturl()
|
||||||
|
|
||||||
|
def fetch_manifests(self) -> dict[str, Any]:
|
||||||
|
"""Fetch manifests for ref"""
|
||||||
|
self.logger.info("Fetching OCI manifests for blueprint")
|
||||||
|
manifest_request = self.client.NewRequest(
|
||||||
|
"GET",
|
||||||
|
"/v2/<name>/manifests/<reference>",
|
||||||
|
WithReference(self.ref),
|
||||||
|
).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
|
||||||
|
try:
|
||||||
|
manifest_response = self.client.Do(manifest_request)
|
||||||
|
manifest_response.raise_for_status()
|
||||||
|
except RequestException as exc:
|
||||||
|
raise OCIException(exc) from exc
|
||||||
|
manifest = manifest_response.json()
|
||||||
|
if "errors" in manifest:
|
||||||
|
raise OCIException(manifest["errors"])
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
def fetch_blobs(self, manifest: dict[str, Any]):
|
||||||
|
"""Fetch blob based on manifest info"""
|
||||||
|
blob = None
|
||||||
|
for layer in manifest.get("layers", []):
|
||||||
|
if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
|
||||||
|
blob = layer.get("digest")
|
||||||
|
self.logger.debug("Found layer with matching media type", blob=blob)
|
||||||
|
if not blob:
|
||||||
|
raise OCIException("Blob not found")
|
||||||
|
|
||||||
|
blob_request = self.client.NewRequest(
|
||||||
|
"GET",
|
||||||
|
"/v2/<name>/blobs/<digest>",
|
||||||
|
WithDigest(blob),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
blob_response = self.client.Do(blob_request)
|
||||||
|
blob_response.raise_for_status()
|
||||||
|
return blob_response.text
|
||||||
|
except RequestException as exc:
|
||||||
|
raise OCIException(exc) from exc
|
@ -1,8 +1,10 @@
|
|||||||
"""Application API Views"""
|
"""Application API Views"""
|
||||||
|
from datetime import timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db.models import QuerySet
|
from django.db.models import QuerySet
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
from django.http.response import HttpResponseBadRequest
|
from django.http.response import HttpResponseBadRequest
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
@ -225,7 +227,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
parser_classes=(MultiPartParser,),
|
parser_classes=(MultiPartParser,),
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_icon(self, request: Request, slug: str):
|
def set_icon(self, request: Request, slug: str):
|
||||||
"""Set application icon"""
|
"""Set application icon"""
|
||||||
app: Application = self.get_object()
|
app: Application = self.get_object()
|
||||||
@ -245,7 +246,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_icon_url(self, request: Request, slug: str):
|
def set_icon_url(self, request: Request, slug: str):
|
||||||
"""Set application icon (as URL)"""
|
"""Set application icon (as URL)"""
|
||||||
app: Application = self.get_object()
|
app: Application = self.get_object()
|
||||||
@ -254,15 +254,14 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
|||||||
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
|
||||||
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
@extend_schema(responses={200: CoordinateSerializer(many=True)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def metrics(self, request: Request, slug: str):
|
def metrics(self, request: Request, slug: str):
|
||||||
"""Metrics for application logins"""
|
"""Metrics for application logins"""
|
||||||
app = self.get_object()
|
app = self.get_object()
|
||||||
return Response(
|
return Response(
|
||||||
get_objects_for_user(request.user, "authentik_events.view_event")
|
get_objects_for_user(request.user, "authentik_events.view_event").filter(
|
||||||
.filter(
|
|
||||||
action=EventAction.AUTHORIZE_APPLICATION,
|
action=EventAction.AUTHORIZE_APPLICATION,
|
||||||
context__authorized_application__pk=app.pk.hex,
|
context__authorized_application__pk=app.pk.hex,
|
||||||
)
|
)
|
||||||
.get_events_per_hour()
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
@ -96,7 +96,6 @@ class GroupFilter(FilterSet):
|
|||||||
queryset=User.objects.all(),
|
queryset=User.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_attributes(self, queryset, name, value):
|
def filter_attributes(self, queryset, name, value):
|
||||||
"""Filter attributes by query args"""
|
"""Filter attributes by query args"""
|
||||||
try:
|
try:
|
||||||
@ -157,7 +156,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def add_user(self, request: Request, pk: str) -> Response:
|
def add_user(self, request: Request, pk: str) -> Response:
|
||||||
"""Add user to group"""
|
"""Add user to group"""
|
||||||
group: Group = self.get_object()
|
group: Group = self.get_object()
|
||||||
@ -182,7 +180,6 @@ class GroupViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
@action(detail=True, methods=["POST"], pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def remove_user(self, request: Request, pk: str) -> Response:
|
def remove_user(self, request: Request, pk: str) -> Response:
|
||||||
"""Add user to group"""
|
"""Add user to group"""
|
||||||
group: Group = self.get_object()
|
group: Group = self.get_object()
|
||||||
|
@ -117,7 +117,6 @@ class PropertyMappingViewSet(
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def test(self, request: Request, pk: str) -> Response:
|
def test(self, request: Request, pk: str) -> Response:
|
||||||
"""Test Property Mapping"""
|
"""Test Property Mapping"""
|
||||||
mapping: PropertyMapping = self.get_object()
|
mapping: PropertyMapping = self.get_object()
|
||||||
|
@ -102,7 +102,6 @@ class SourceViewSet(
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
parser_classes=(MultiPartParser,),
|
parser_classes=(MultiPartParser,),
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_icon(self, request: Request, slug: str):
|
def set_icon(self, request: Request, slug: str):
|
||||||
"""Set source icon"""
|
"""Set source icon"""
|
||||||
source: Source = self.get_object()
|
source: Source = self.get_object()
|
||||||
@ -122,7 +121,6 @@ class SourceViewSet(
|
|||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_icon_url(self, request: Request, slug: str):
|
def set_icon_url(self, request: Request, slug: str):
|
||||||
"""Set source icon (as URL)"""
|
"""Set source icon (as URL)"""
|
||||||
source: Source = self.get_object()
|
source: Source = self.get_object()
|
||||||
|
@ -112,7 +112,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def view_key(self, request: Request, identifier: str) -> Response:
|
def view_key(self, request: Request, identifier: str) -> Response:
|
||||||
"""Return token key and log access"""
|
"""Return token key and log access"""
|
||||||
token: Token = self.get_object()
|
token: Token = self.get_object()
|
||||||
@ -134,7 +133,6 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_key(self, request: Request, identifier: str) -> Response:
|
def set_key(self, request: Request, identifier: str) -> Response:
|
||||||
"""Return token key and log access"""
|
"""Return token key and log access"""
|
||||||
token: Token = self.get_object()
|
token: Token = self.get_object()
|
||||||
|
@ -53,7 +53,7 @@ class UsedByMixin:
|
|||||||
responses={200: UsedBySerializer(many=True)},
|
responses={200: UsedBySerializer(many=True)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument, too-many-locals
|
# pylint: disable=too-many-locals
|
||||||
def used_by(self, request: Request, *args, **kwargs) -> Response:
|
def used_by(self, request: Request, *args, **kwargs) -> Response:
|
||||||
"""Get a list of all objects that use this object"""
|
"""Get a list of all objects that use this object"""
|
||||||
# pyright: reportGeneralTypeIssues=false
|
# pyright: reportGeneralTypeIssues=false
|
||||||
|
@ -4,6 +4,7 @@ from json import loads
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from django.contrib.auth import update_session_auth_hash
|
from django.contrib.auth import update_session_auth_hash
|
||||||
|
from django.db.models.functions import ExtractHour
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
@ -199,38 +200,44 @@ class SessionUserSerializer(PassiveSerializer):
|
|||||||
class UserMetricsSerializer(PassiveSerializer):
|
class UserMetricsSerializer(PassiveSerializer):
|
||||||
"""User Metrics"""
|
"""User Metrics"""
|
||||||
|
|
||||||
logins_per_1h = SerializerMethodField()
|
logins = SerializerMethodField()
|
||||||
logins_failed_per_1h = SerializerMethodField()
|
logins_failed = SerializerMethodField()
|
||||||
authorizations_per_1h = SerializerMethodField()
|
authorizations = SerializerMethodField()
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_per_1h(self, _):
|
def get_logins(self, _):
|
||||||
"""Get successful logins per hour for the last 24 hours"""
|
"""Get successful logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.LOGIN, user__pk=user.pk)
|
action=EventAction.LOGIN, user__pk=user.pk
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_logins_failed_per_1h(self, _):
|
def get_logins_failed(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.LOGIN_FAILED, context__username=user.username)
|
action=EventAction.LOGIN_FAILED, context__username=user.username
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema_field(CoordinateSerializer(many=True))
|
@extend_schema_field(CoordinateSerializer(many=True))
|
||||||
def get_authorizations_per_1h(self, _):
|
def get_authorizations(self, _):
|
||||||
"""Get failed logins per hour for the last 24 hours"""
|
"""Get failed logins per 8 hours for the last 7 days"""
|
||||||
user = self.context["user"]
|
user = self.context["user"]
|
||||||
return (
|
return (
|
||||||
get_objects_for_user(user, "authentik_events.view_event")
|
get_objects_for_user(user, "authentik_events.view_event").filter(
|
||||||
.filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
|
action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
|
||||||
.get_events_per_hour()
|
)
|
||||||
|
# 3 data points per day, so 8 hour spans
|
||||||
|
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -262,7 +269,6 @@ class UsersFilter(FilterSet):
|
|||||||
queryset=Group.objects.all(),
|
queryset=Group.objects.all(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_attributes(self, queryset, name, value):
|
def filter_attributes(self, queryset, name, value):
|
||||||
"""Filter attributes by query args"""
|
"""Filter attributes by query args"""
|
||||||
try:
|
try:
|
||||||
@ -397,9 +403,8 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
return Response(data={"non_field_errors": [str(exc)]}, status=400)
|
||||||
|
|
||||||
@extend_schema(responses={200: SessionUserSerializer(many=False)})
|
@extend_schema(responses={200: SessionUserSerializer(many=False)})
|
||||||
@action(detail=False, pagination_class=None, filter_backends=[])
|
@action(url_path="me", url_name="me", detail=False, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name
|
def user_me(self, request: Request) -> Response:
|
||||||
def me(self, request: Request) -> Response:
|
|
||||||
"""Get information about current user"""
|
"""Get information about current user"""
|
||||||
context = {"request": request}
|
context = {"request": request}
|
||||||
serializer = SessionUserSerializer(
|
serializer = SessionUserSerializer(
|
||||||
@ -427,7 +432,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["POST"])
|
@action(detail=True, methods=["POST"])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def set_password(self, request: Request, pk: int) -> Response:
|
def set_password(self, request: Request, pk: int) -> Response:
|
||||||
"""Set password for user"""
|
"""Set password for user"""
|
||||||
user: User = self.get_object()
|
user: User = self.get_object()
|
||||||
@ -445,7 +449,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
|
||||||
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def metrics(self, request: Request, pk: int) -> Response:
|
def metrics(self, request: Request, pk: int) -> Response:
|
||||||
"""User metrics per 1h"""
|
"""User metrics per 1h"""
|
||||||
user: User = self.get_object()
|
user: User = self.get_object()
|
||||||
@ -461,7 +464,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def recovery(self, request: Request, pk: int) -> Response:
|
def recovery(self, request: Request, pk: int) -> Response:
|
||||||
"""Create a temporary link that a user can use to recover their accounts"""
|
"""Create a temporary link that a user can use to recover their accounts"""
|
||||||
link, _ = self._create_recovery_link()
|
link, _ = self._create_recovery_link()
|
||||||
@ -486,7 +488,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def recovery_email(self, request: Request, pk: int) -> Response:
|
def recovery_email(self, request: Request, pk: int) -> Response:
|
||||||
"""Create a temporary link that a user can use to recover their accounts"""
|
"""Create a temporary link that a user can use to recover their accounts"""
|
||||||
for_user: User = self.get_object()
|
for_user: User = self.get_object()
|
||||||
|
@ -49,7 +49,6 @@ class Command(BaseCommand):
|
|||||||
return namespace
|
return namespace
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_handler(sender, instance: Model, created: bool, **_):
|
def post_save_handler(sender, instance: Model, created: bool, **_):
|
||||||
"""Signal handler for all object's post_save"""
|
"""Signal handler for all object's post_save"""
|
||||||
if not should_log_model(instance):
|
if not should_log_model(instance):
|
||||||
@ -65,7 +64,6 @@ class Command(BaseCommand):
|
|||||||
).save()
|
).save()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_delete_handler(sender, instance: Model, **_):
|
def pre_delete_handler(sender, instance: Model, **_):
|
||||||
"""Signal handler for all object's pre_delete"""
|
"""Signal handler for all object's pre_delete"""
|
||||||
if not should_log_model(instance): # pragma: no cover
|
if not should_log_model(instance): # pragma: no cover
|
||||||
|
@ -20,7 +20,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||||
"""Clear user's application cache upon application creation"""
|
"""Clear user's application cache upon application creation"""
|
||||||
from authentik.core.api.applications import user_app_cache_key
|
from authentik.core.api.applications import user_app_cache_key
|
||||||
@ -36,7 +35,6 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
||||||
"""Create an AuthenticatedSession from request"""
|
"""Create an AuthenticatedSession from request"""
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
@ -47,7 +45,6 @@ def user_logged_in_session(sender, request: HttpRequest, user: "User", **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(user_logged_out)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
|
||||||
"""Delete AuthenticatedSession if it exists"""
|
"""Delete AuthenticatedSession if it exists"""
|
||||||
from authentik.core.models import AuthenticatedSession
|
from authentik.core.models import AuthenticatedSession
|
||||||
|
@ -48,7 +48,6 @@ class Action(Enum):
|
|||||||
class MessageStage(StageView):
|
class MessageStage(StageView):
|
||||||
"""Show a pre-configured message after the flow is done"""
|
"""Show a pre-configured message after the flow is done"""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Show a pre-configured message after the flow is done"""
|
"""Show a pre-configured message after the flow is done"""
|
||||||
message = getattr(self.executor.current_stage, "message", "")
|
message = getattr(self.executor.current_stage, "message", "")
|
||||||
@ -209,7 +208,6 @@ class SourceFlowManager:
|
|||||||
response.error_message = error.messages
|
response.error_message = error.messages
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||||
"""Hook to override stages which are appended to the flow"""
|
"""Hook to override stages which are appended to the flow"""
|
||||||
if not self.source.enrollment_flow:
|
if not self.source.enrollment_flow:
|
||||||
@ -264,7 +262,6 @@ class SourceFlowManager:
|
|||||||
flow_slug=flow.slug,
|
flow_slug=flow.slug,
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_auth(
|
def handle_auth(
|
||||||
self,
|
self,
|
||||||
connection: UserSourceConnection,
|
connection: UserSourceConnection,
|
||||||
|
@ -13,7 +13,6 @@ class PostUserEnrollmentStage(StageView):
|
|||||||
"""Dynamically injected stage which saves the Connection after
|
"""Dynamically injected stage which saves the Connection after
|
||||||
the user has been enrolled."""
|
the user has been enrolled."""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Stage used after the user has been enrolled"""
|
"""Stage used after the user has been enrolled"""
|
||||||
connection: UserSourceConnection = self.executor.plan.context[
|
connection: UserSourceConnection = self.executor.plan.context[
|
||||||
|
@ -60,7 +60,7 @@
|
|||||||
<div class="ak-login-container">
|
<div class="ak-login-container">
|
||||||
<header class="pf-c-login__header">
|
<header class="pf-c-login__header">
|
||||||
<div class="pf-c-brand ak-brand">
|
<div class="pf-c-brand ak-brand">
|
||||||
<img src="{{ tenant.branding_logo }}" alt="authentik icon" />
|
<img src="{{ tenant.branding_logo }}" alt="authentik Logo" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{% block main_container %}
|
{% block main_container %}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
"""Test Source flow_manager"""
|
"""Test Source flow_manager"""
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.test.client import RequestFactory
|
|
||||||
from guardian.utils import get_anonymous_user
|
from guardian.utils import get_anonymous_user
|
||||||
|
|
||||||
from authentik.core.models import SourceUserMatchingModes, User
|
from authentik.core.models import SourceUserMatchingModes, User
|
||||||
@ -22,7 +21,6 @@ class TestSourceFlowManager(TestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.source: OAuthSource = OAuthSource.objects.create(name="test")
|
self.source: OAuthSource = OAuthSource.objects.create(name="test")
|
||||||
self.factory = RequestFactory()
|
|
||||||
self.identifier = generate_id()
|
self.identifier = generate_id()
|
||||||
|
|
||||||
def test_unauthenticated_enroll(self):
|
def test_unauthenticated_enroll(self):
|
||||||
|
@ -47,11 +47,11 @@ def create_test_tenant() -> Tenant:
|
|||||||
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
def create_test_cert(use_ec_private_key=False) -> CertificateKeyPair:
|
||||||
"""Generate a certificate for testing"""
|
"""Generate a certificate for testing"""
|
||||||
builder = CertificateBuilder(
|
builder = CertificateBuilder(
|
||||||
|
name=f"{generate_id()}.self-signed.goauthentik.io",
|
||||||
use_ec_private_key=use_ec_private_key,
|
use_ec_private_key=use_ec_private_key,
|
||||||
)
|
)
|
||||||
builder.common_name = "goauthentik.io"
|
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=["goauthentik.io"],
|
subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
builder.common_name = generate_id()
|
builder.common_name = generate_id()
|
||||||
|
@ -187,7 +187,6 @@ class CertificateKeyPairFilter(FilterSet):
|
|||||||
label="Only return certificate-key pairs with keys", method="filter_has_key"
|
label="Only return certificate-key pairs with keys", method="filter_has_key"
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_has_key(self, queryset, name, value): # pragma: no cover
|
def filter_has_key(self, queryset, name, value): # pragma: no cover
|
||||||
"""Only return certificate-key pairs with keys"""
|
"""Only return certificate-key pairs with keys"""
|
||||||
return queryset.exclude(key_data__exact="")
|
return queryset.exclude(key_data__exact="")
|
||||||
@ -236,8 +235,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
data = CertificateGenerationSerializer(data=request.data)
|
data = CertificateGenerationSerializer(data=request.data)
|
||||||
if not data.is_valid():
|
if not data.is_valid():
|
||||||
return Response(data.errors, status=400)
|
return Response(data.errors, status=400)
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder(data.validated_data["common_name"])
|
||||||
builder.common_name = data.validated_data["common_name"]
|
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=data.validated_data.get("subject_alt_name", "").split(","),
|
subject_alt_names=data.validated_data.get("subject_alt_name", "").split(","),
|
||||||
validity_days=int(data.validated_data["validity_days"]),
|
validity_days=int(data.validated_data["validity_days"]),
|
||||||
@ -257,7 +255,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
responses={200: CertificateDataSerializer(many=False)},
|
responses={200: CertificateDataSerializer(many=False)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def view_certificate(self, request: Request, pk: str) -> Response:
|
def view_certificate(self, request: Request, pk: str) -> Response:
|
||||||
"""Return certificate-key pairs certificate and log access"""
|
"""Return certificate-key pairs certificate and log access"""
|
||||||
certificate: CertificateKeyPair = self.get_object()
|
certificate: CertificateKeyPair = self.get_object()
|
||||||
@ -288,7 +285,6 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
|||||||
responses={200: CertificateDataSerializer(many=False)},
|
responses={200: CertificateDataSerializer(many=False)},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def view_private_key(self, request: Request, pk: str) -> Response:
|
def view_private_key(self, request: Request, pk: str) -> Response:
|
||||||
"""Return certificate-key pairs private key and log access"""
|
"""Return certificate-key pairs private key and log access"""
|
||||||
certificate: CertificateKeyPair = self.get_object()
|
certificate: CertificateKeyPair = self.get_object()
|
||||||
|
@ -27,20 +27,16 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
|||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder("authentik Internal JWT Certificate")
|
||||||
builder.common_name = "goauthentik.io"
|
|
||||||
builder.build(
|
builder.build(
|
||||||
subject_alt_names=["goauthentik.io"],
|
subject_alt_names=["goauthentik.io"],
|
||||||
validity_days=360,
|
validity_days=360,
|
||||||
)
|
)
|
||||||
if not cert:
|
if not cert:
|
||||||
|
|
||||||
cert = CertificateKeyPair()
|
cert = CertificateKeyPair()
|
||||||
cert.certificate_data = builder.certificate
|
builder.cert = cert
|
||||||
cert.key_data = builder.private_key
|
builder.cert.managed = MANAGED_KEY
|
||||||
cert.name = "authentik Internal JWT Certificate"
|
builder.save()
|
||||||
cert.managed = MANAGED_KEY
|
|
||||||
cert.save()
|
|
||||||
|
|
||||||
def reconcile_managed_jwt_cert(self):
|
def reconcile_managed_jwt_cert(self):
|
||||||
"""Ensure managed JWT certificate"""
|
"""Ensure managed JWT certificate"""
|
||||||
@ -63,10 +59,6 @@ class AuthentikCryptoConfig(ManagedAppConfig):
|
|||||||
name = "authentik Self-signed Certificate"
|
name = "authentik Self-signed Certificate"
|
||||||
if CertificateKeyPair.objects.filter(name=name).exists():
|
if CertificateKeyPair.objects.filter(name=name).exists():
|
||||||
return
|
return
|
||||||
builder = CertificateBuilder()
|
builder = CertificateBuilder(name)
|
||||||
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
||||||
CertificateKeyPair.objects.create(
|
builder.save()
|
||||||
name="authentik Self-signed Certificate",
|
|
||||||
certificate_data=builder.certificate,
|
|
||||||
key_data=builder.private_key,
|
|
||||||
)
|
|
||||||
|
@ -21,13 +21,13 @@ class CertificateBuilder:
|
|||||||
|
|
||||||
_use_ec_private_key: bool
|
_use_ec_private_key: bool
|
||||||
|
|
||||||
def __init__(self, use_ec_private_key=False):
|
def __init__(self, name: str, use_ec_private_key=False):
|
||||||
self._use_ec_private_key = use_ec_private_key
|
self._use_ec_private_key = use_ec_private_key
|
||||||
self.__public_key = None
|
self.__public_key = None
|
||||||
self.__private_key = None
|
self.__private_key = None
|
||||||
self.__builder = None
|
self.__builder = None
|
||||||
self.__certificate = None
|
self.__certificate = None
|
||||||
self.common_name = "authentik Self-signed Certificate"
|
self.common_name = name
|
||||||
self.cert = CertificateKeyPair()
|
self.cert = CertificateKeyPair()
|
||||||
|
|
||||||
def save(self) -> CertificateKeyPair:
|
def save(self) -> CertificateKeyPair:
|
||||||
|
@ -14,7 +14,7 @@ from authentik.crypto.builder import CertificateBuilder
|
|||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
||||||
from authentik.lib.config import CONFIG
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.generators import generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
from authentik.providers.oauth2.models import OAuth2Provider
|
from authentik.providers.oauth2.models import OAuth2Provider
|
||||||
|
|
||||||
|
|
||||||
@ -54,8 +54,8 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_builder(self):
|
def test_builder(self):
|
||||||
"""Test Builder"""
|
"""Test Builder"""
|
||||||
builder = CertificateBuilder()
|
name = generate_id()
|
||||||
builder.common_name = "test-cert"
|
builder = CertificateBuilder(name)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
builder.save()
|
builder.save()
|
||||||
builder.build(
|
builder.build(
|
||||||
@ -64,7 +64,7 @@ class TestCrypto(APITestCase):
|
|||||||
)
|
)
|
||||||
instance = builder.save()
|
instance = builder.save()
|
||||||
now = datetime.datetime.today()
|
now = datetime.datetime.today()
|
||||||
self.assertEqual(instance.name, "test-cert")
|
self.assertEqual(instance.name, name)
|
||||||
self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
|
self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
|
||||||
|
|
||||||
def test_builder_api(self):
|
def test_builder_api(self):
|
||||||
@ -193,8 +193,8 @@ class TestCrypto(APITestCase):
|
|||||||
|
|
||||||
def test_discovery(self):
|
def test_discovery(self):
|
||||||
"""Test certificate discovery"""
|
"""Test certificate discovery"""
|
||||||
builder = CertificateBuilder()
|
name = generate_id()
|
||||||
builder.common_name = "test-cert"
|
builder = CertificateBuilder(name)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
builder.save()
|
builder.save()
|
||||||
builder.build(
|
builder.build(
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
"""Events API Views"""
|
"""Events API Views"""
|
||||||
|
from datetime import timedelta
|
||||||
from json import loads
|
from json import loads
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
from django.db.models.aggregates import Count
|
from django.db.models.aggregates import Count
|
||||||
from django.db.models.fields.json import KeyTextTransform
|
from django.db.models.fields.json import KeyTextTransform
|
||||||
|
from django.db.models.functions import ExtractDay
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
from guardian.shortcuts import get_objects_for_user
|
from guardian.shortcuts import get_objects_for_user
|
||||||
@ -81,7 +83,6 @@ class EventsFilter(django_filters.FilterSet):
|
|||||||
label="Tenant name",
|
label="Tenant name",
|
||||||
)
|
)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def filter_context_model_pk(self, queryset, name, value):
|
def filter_context_model_pk(self, queryset, name, value):
|
||||||
"""Because we store the PK as UUID.hex,
|
"""Because we store the PK as UUID.hex,
|
||||||
we need to remove the dashes that a client may send. We can't use a
|
we need to remove the dashes that a client may send. We can't use a
|
||||||
@ -178,7 +179,7 @@ class EventViewSet(ModelViewSet):
|
|||||||
get_objects_for_user(request.user, "authentik_events.view_event")
|
get_objects_for_user(request.user, "authentik_events.view_event")
|
||||||
.filter(action=filtered_action)
|
.filter(action=filtered_action)
|
||||||
.filter(**query)
|
.filter(**query)
|
||||||
.get_events_per_day()
|
.get_events_per(timedelta(weeks=4), ExtractDay, 30)
|
||||||
)
|
)
|
||||||
|
|
||||||
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
@extend_schema(responses={200: TypeCreateSerializer(many=True)})
|
||||||
|
@ -80,7 +80,6 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
|
|||||||
request=OpenApiTypes.NONE,
|
request=OpenApiTypes.NONE,
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["post"])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def test(self, request: Request, pk=None) -> Response:
|
def test(self, request: Request, pk=None) -> Response:
|
||||||
"""Send example notification using selected transport. Requires
|
"""Send example notification using selected transport. Requires
|
||||||
Modify permissions."""
|
Modify permissions."""
|
||||||
|
@ -12,12 +12,21 @@ from django.http import HttpRequest, HttpResponse
|
|||||||
from django_otp.plugins.otp_static.models import StaticToken
|
from django_otp.plugins.otp_static.models import StaticToken
|
||||||
from guardian.models import UserObjectPermission
|
from guardian.models import UserObjectPermission
|
||||||
|
|
||||||
from authentik.core.models import AuthenticatedSession, User
|
from authentik.core.models import (
|
||||||
|
AuthenticatedSession,
|
||||||
|
PropertyMapping,
|
||||||
|
Provider,
|
||||||
|
Source,
|
||||||
|
User,
|
||||||
|
UserSourceConnection,
|
||||||
|
)
|
||||||
from authentik.events.models import Event, EventAction, Notification
|
from authentik.events.models import Event, EventAction, Notification
|
||||||
from authentik.events.utils import model_to_dict
|
from authentik.events.utils import model_to_dict
|
||||||
from authentik.flows.models import FlowToken
|
from authentik.flows.models import FlowToken, Stage
|
||||||
from authentik.lib.sentry import before_send
|
from authentik.lib.sentry import before_send
|
||||||
from authentik.lib.utils.errors import exception_to_string
|
from authentik.lib.utils.errors import exception_to_string
|
||||||
|
from authentik.outposts.models import OutpostServiceConnection
|
||||||
|
from authentik.policies.models import Policy, PolicyBindingModel
|
||||||
|
|
||||||
IGNORED_MODELS = (
|
IGNORED_MODELS = (
|
||||||
Event,
|
Event,
|
||||||
@ -27,6 +36,14 @@ IGNORED_MODELS = (
|
|||||||
StaticToken,
|
StaticToken,
|
||||||
Session,
|
Session,
|
||||||
FlowToken,
|
FlowToken,
|
||||||
|
Provider,
|
||||||
|
Source,
|
||||||
|
PropertyMapping,
|
||||||
|
UserSourceConnection,
|
||||||
|
Stage,
|
||||||
|
OutpostServiceConnection,
|
||||||
|
Policy,
|
||||||
|
PolicyBindingModel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -34,7 +51,7 @@ def should_log_model(model: Model) -> bool:
|
|||||||
"""Return true if operation on `model` should be logged"""
|
"""Return true if operation on `model` should be logged"""
|
||||||
if model.__module__.startswith("silk"):
|
if model.__module__.startswith("silk"):
|
||||||
return False
|
return False
|
||||||
return not isinstance(model, IGNORED_MODELS)
|
return model.__class__ not in IGNORED_MODELS
|
||||||
|
|
||||||
|
|
||||||
class EventNewThread(Thread):
|
class EventNewThread(Thread):
|
||||||
@ -101,7 +118,6 @@ class AuditMiddleware:
|
|||||||
self.disconnect(request)
|
self.disconnect(request)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def process_exception(self, request: HttpRequest, exception: Exception):
|
def process_exception(self, request: HttpRequest, exception: Exception):
|
||||||
"""Disconnect handlers in case of exception"""
|
"""Disconnect handlers in case of exception"""
|
||||||
self.disconnect(request)
|
self.disconnect(request)
|
||||||
@ -125,7 +141,6 @@ class AuditMiddleware:
|
|||||||
thread.run()
|
thread.run()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_handler(
|
def post_save_handler(
|
||||||
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
|
||||||
):
|
):
|
||||||
@ -137,7 +152,6 @@ class AuditMiddleware:
|
|||||||
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
EventNewThread(action, request, user=user, model=model_to_dict(instance)).run()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
|
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
|
||||||
"""Signal handler for all object's pre_delete"""
|
"""Signal handler for all object's pre_delete"""
|
||||||
if not should_log_model(instance): # pragma: no cover
|
if not should_log_model(instance): # pragma: no cover
|
||||||
|
@ -11,8 +11,7 @@ from django.conf import settings
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Count, ExpressionWrapper, F
|
from django.db.models import Count, ExpressionWrapper, F
|
||||||
from django.db.models.fields import DurationField
|
from django.db.models.fields import DurationField
|
||||||
from django.db.models.functions import ExtractHour
|
from django.db.models.functions import Extract
|
||||||
from django.db.models.functions.datetime import ExtractDay
|
|
||||||
from django.db.models.manager import Manager
|
from django.db.models.manager import Manager
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
@ -111,48 +110,35 @@ class EventAction(models.TextChoices):
|
|||||||
class EventQuerySet(QuerySet):
|
class EventQuerySet(QuerySet):
|
||||||
"""Custom events query set with helper functions"""
|
"""Custom events query set with helper functions"""
|
||||||
|
|
||||||
def get_events_per_hour(self) -> list[dict[str, int]]:
|
def get_events_per(
|
||||||
|
self,
|
||||||
|
time_since: timedelta,
|
||||||
|
extract: Extract,
|
||||||
|
data_points: int,
|
||||||
|
) -> list[dict[str, int]]:
|
||||||
"""Get event count by hour in the last day, fill with zeros"""
|
"""Get event count by hour in the last day, fill with zeros"""
|
||||||
date_from = now() - timedelta(days=1)
|
_now = now()
|
||||||
|
max_since = timedelta(days=60)
|
||||||
|
# Allow maximum of 60 days to limit load
|
||||||
|
if time_since.total_seconds() > max_since.total_seconds():
|
||||||
|
time_since = max_since
|
||||||
|
date_from = _now - time_since
|
||||||
result = (
|
result = (
|
||||||
self.filter(created__gte=date_from)
|
self.filter(created__gte=date_from)
|
||||||
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
.annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
|
||||||
.annotate(age_hours=ExtractHour("age"))
|
.annotate(age_interval=extract("age"))
|
||||||
.values("age_hours")
|
.values("age_interval")
|
||||||
.annotate(count=Count("pk"))
|
.annotate(count=Count("pk"))
|
||||||
.order_by("age_hours")
|
.order_by("age_interval")
|
||||||
)
|
)
|
||||||
data = Counter({int(d["age_hours"]): d["count"] for d in result})
|
data = Counter({int(d["age_interval"]): d["count"] for d in result})
|
||||||
results = []
|
results = []
|
||||||
_now = now()
|
interval_delta = time_since / data_points
|
||||||
for hour in range(0, -24, -1):
|
for interval in range(1, -data_points, -1):
|
||||||
results.append(
|
results.append(
|
||||||
{
|
{
|
||||||
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
|
"x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000,
|
||||||
"y_cord": data[hour * -1],
|
"y_cord": data[interval * -1],
|
||||||
}
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
def get_events_per_day(self) -> list[dict[str, int]]:
|
|
||||||
"""Get event count by hour in the last day, fill with zeros"""
|
|
||||||
date_from = now() - timedelta(weeks=4)
|
|
||||||
result = (
|
|
||||||
self.filter(created__gte=date_from)
|
|
||||||
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
|
|
||||||
.annotate(age_days=ExtractDay("age"))
|
|
||||||
.values("age_days")
|
|
||||||
.annotate(count=Count("pk"))
|
|
||||||
.order_by("age_days")
|
|
||||||
)
|
|
||||||
data = Counter({int(d["age_days"]): d["count"] for d in result})
|
|
||||||
results = []
|
|
||||||
_now = now()
|
|
||||||
for day in range(0, -30, -1):
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000,
|
|
||||||
"y_cord": data[day * -1],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
@ -165,13 +151,14 @@ class EventManager(Manager):
|
|||||||
"""use custom queryset"""
|
"""use custom queryset"""
|
||||||
return EventQuerySet(self.model, using=self._db)
|
return EventQuerySet(self.model, using=self._db)
|
||||||
|
|
||||||
def get_events_per_hour(self) -> list[dict[str, int]]:
|
def get_events_per(
|
||||||
|
self,
|
||||||
|
time_since: timedelta,
|
||||||
|
extract: Extract,
|
||||||
|
data_points: int,
|
||||||
|
) -> list[dict[str, int]]:
|
||||||
"""Wrap method from queryset"""
|
"""Wrap method from queryset"""
|
||||||
return self.get_queryset().get_events_per_hour()
|
return self.get_queryset().get_events_per(time_since, extract, data_points)
|
||||||
|
|
||||||
def get_events_per_day(self) -> list[dict[str, int]]:
|
|
||||||
"""Wrap method from queryset"""
|
|
||||||
return self.get_queryset().get_events_per_day()
|
|
||||||
|
|
||||||
|
|
||||||
class Event(SerializerModel, ExpiringModel):
|
class Event(SerializerModel, ExpiringModel):
|
||||||
|
@ -102,7 +102,7 @@ class TaskInfo:
|
|||||||
key = CACHE_KEY_PREFIX + self.task_name
|
key = CACHE_KEY_PREFIX + self.task_name
|
||||||
if self.result.uid:
|
if self.result.uid:
|
||||||
key += f"/{self.result.uid}"
|
key += f"/{self.result.uid}"
|
||||||
self.task_name += f"_{self.result.uid}"
|
self.task_name += f"/{self.result.uid}"
|
||||||
self.set_prom_metrics()
|
self.set_prom_metrics()
|
||||||
cache.set(key, self, timeout=timeout_hours * 60 * 60)
|
cache.set(key, self, timeout=timeout_hours * 60 * 60)
|
||||||
|
|
||||||
|
@ -22,7 +22,6 @@ SESSION_LOGIN_EVENT = "login_event"
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
def on_user_logged_in(sender, request: HttpRequest, user: User, **_):
|
||||||
"""Log successful login"""
|
"""Log successful login"""
|
||||||
kwargs = {}
|
kwargs = {}
|
||||||
@ -45,14 +44,12 @@ def get_login_event(request: HttpRequest) -> Optional[Event]:
|
|||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_out)
|
@receiver(user_logged_out)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||||
"""Log successfully logout"""
|
"""Log successfully logout"""
|
||||||
Event.new(EventAction.LOGOUT).from_http(request, user=user)
|
Event.new(EventAction.LOGOUT).from_http(request, user=user)
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_write)
|
@receiver(user_write)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs):
|
def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any], **kwargs):
|
||||||
"""Log User write"""
|
"""Log User write"""
|
||||||
data["created"] = kwargs.get("created", False)
|
data["created"] = kwargs.get("created", False)
|
||||||
@ -60,7 +57,6 @@ def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any]
|
|||||||
|
|
||||||
|
|
||||||
@receiver(login_failed)
|
@receiver(login_failed)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_login_failed(
|
def on_login_failed(
|
||||||
signal,
|
signal,
|
||||||
sender,
|
sender,
|
||||||
@ -74,7 +70,6 @@ def on_login_failed(
|
|||||||
|
|
||||||
|
|
||||||
@receiver(invitation_used)
|
@receiver(invitation_used)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
|
def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_):
|
||||||
"""Log Invitation usage"""
|
"""Log Invitation usage"""
|
||||||
Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.invite_uuid.hex).from_http(
|
Event.new(EventAction.INVITE_USED, invitation_uuid=invitation.invite_uuid.hex).from_http(
|
||||||
@ -83,21 +78,18 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_
|
|||||||
|
|
||||||
|
|
||||||
@receiver(password_changed)
|
@receiver(password_changed)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def on_password_changed(sender, user: User, password: str, **_):
|
def on_password_changed(sender, user: User, password: str, **_):
|
||||||
"""Log password change"""
|
"""Log password change"""
|
||||||
Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
|
Event.new(EventAction.PASSWORD_SET).from_http(None, user=user)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=Event)
|
@receiver(post_save, sender=Event)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def event_post_save_notification(sender, instance: Event, **_):
|
def event_post_save_notification(sender, instance: Event, **_):
|
||||||
"""Start task to check if any policies trigger an notification on this event"""
|
"""Start task to check if any policies trigger an notification on this event"""
|
||||||
event_notification_handler.delay(instance.event_uuid.hex)
|
event_notification_handler.delay(instance.event_uuid.hex)
|
||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=User)
|
@receiver(pre_delete, sender=User)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def event_user_pre_delete_cleanup(sender, instance: User, **_):
|
def event_user_pre_delete_cleanup(sender, instance: User, **_):
|
||||||
"""If gdpr_compliance is enabled, remove all the user's events"""
|
"""If gdpr_compliance is enabled, remove all the user's events"""
|
||||||
gdpr_cleanup.delay(instance.pk)
|
gdpr_cleanup.delay(instance.pk)
|
||||||
|
@ -210,7 +210,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def export(self, request: Request, slug: str) -> Response:
|
def export(self, request: Request, slug: str) -> Response:
|
||||||
"""Export flow to .yaml file"""
|
"""Export flow to .yaml file"""
|
||||||
flow = self.get_object()
|
flow = self.get_object()
|
||||||
@ -221,7 +220,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
|
|
||||||
@extend_schema(responses={200: FlowDiagramSerializer()})
|
@extend_schema(responses={200: FlowDiagramSerializer()})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["get"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["get"])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def diagram(self, request: Request, slug: str) -> Response:
|
def diagram(self, request: Request, slug: str) -> Response:
|
||||||
"""Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
|
"""Return diagram for flow with slug `slug`, in the format used by flowchart.js"""
|
||||||
diagram = FlowDiagram(self.get_object(), request.user)
|
diagram = FlowDiagram(self.get_object(), request.user)
|
||||||
@ -245,7 +243,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
parser_classes=(MultiPartParser,),
|
parser_classes=(MultiPartParser,),
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_background(self, request: Request, slug: str):
|
def set_background(self, request: Request, slug: str):
|
||||||
"""Set Flow background"""
|
"""Set Flow background"""
|
||||||
flow: Flow = self.get_object()
|
flow: Flow = self.get_object()
|
||||||
@ -265,7 +262,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
filter_backends=[],
|
filter_backends=[],
|
||||||
methods=["POST"],
|
methods=["POST"],
|
||||||
)
|
)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def set_background_url(self, request: Request, slug: str):
|
def set_background_url(self, request: Request, slug: str):
|
||||||
"""Set Flow background (as URL)"""
|
"""Set Flow background (as URL)"""
|
||||||
flow: Flow = self.get_object()
|
flow: Flow = self.get_object()
|
||||||
@ -278,7 +274,6 @@ class FlowViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def execute(self, request: Request, slug: str):
|
def execute(self, request: Request, slug: str):
|
||||||
"""Execute flow for current user"""
|
"""Execute flow for current user"""
|
||||||
# Because we pre-plan the flow here, and not in the planner, we need to manually clear
|
# Because we pre-plan the flow here, and not in the planner, we need to manually clear
|
||||||
|
@ -19,7 +19,6 @@ LOGGER = get_logger()
|
|||||||
class StageMarker:
|
class StageMarker:
|
||||||
"""Base stage marker class, no extra attributes, and has no special handler."""
|
"""Base stage marker class, no extra attributes, and has no special handler."""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def process(
|
def process(
|
||||||
self,
|
self,
|
||||||
plan: "FlowPlan",
|
plan: "FlowPlan",
|
||||||
|
@ -19,7 +19,6 @@ def delete_cache_prefix(prefix: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_flows(sender, **kwargs):
|
def monitoring_set_flows(sender, **kwargs):
|
||||||
"""set flow gauges"""
|
"""set flow gauges"""
|
||||||
GAUGE_FLOWS_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}*") or []))
|
GAUGE_FLOWS_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}*") or []))
|
||||||
@ -27,7 +26,6 @@ def monitoring_set_flows(sender, **kwargs):
|
|||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
@receiver(pre_delete)
|
@receiver(pre_delete)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def invalidate_flow_cache(sender, instance, **_):
|
def invalidate_flow_cache(sender, instance, **_):
|
||||||
"""Invalidate flow cache when flow is updated"""
|
"""Invalidate flow cache when flow is updated"""
|
||||||
from authentik.flows.models import Flow, FlowStageBinding, Stage
|
from authentik.flows.models import Flow, FlowStageBinding, Stage
|
||||||
|
@ -91,7 +91,6 @@ class ChallengeStageView(StageView):
|
|||||||
)
|
)
|
||||||
return HttpChallengeResponse(challenge)
|
return HttpChallengeResponse(challenge)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post(self, request: Request, *args, **kwargs) -> HttpResponse:
|
def post(self, request: Request, *args, **kwargs) -> HttpResponse:
|
||||||
"""Handle challenge response"""
|
"""Handle challenge response"""
|
||||||
challenge: ChallengeResponse = self.get_response_instance(data=request.data)
|
challenge: ChallengeResponse = self.get_response_instance(data=request.data)
|
||||||
|
@ -113,7 +113,7 @@ class InvalidStageError(SentryIgnoredException):
|
|||||||
|
|
||||||
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
@method_decorator(xframe_options_sameorigin, name="dispatch")
|
||||||
class FlowExecutorView(APIView):
|
class FlowExecutorView(APIView):
|
||||||
"""Stage 1 Flow executor, passing requests to Stage Views"""
|
"""Flow executor, passing requests to Stage Views"""
|
||||||
|
|
||||||
permission_classes = [AllowAny]
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
@ -162,11 +162,11 @@ class FlowExecutorView(APIView):
|
|||||||
token.delete()
|
token.delete()
|
||||||
if not isinstance(plan, FlowPlan):
|
if not isinstance(plan, FlowPlan):
|
||||||
return None
|
return None
|
||||||
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
|
plan.context[PLAN_CONTEXT_IS_RESTORED] = True
|
||||||
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
|
self._logger.debug("f(exec): restored flow plan from token", plan=plan)
|
||||||
return plan
|
return plan
|
||||||
|
|
||||||
# pylint: disable=unused-argument, too-many-return-statements
|
# pylint: disable=too-many-return-statements
|
||||||
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
|
def dispatch(self, request: HttpRequest, flow_slug: str) -> HttpResponse:
|
||||||
with Hub.current.start_span(
|
with Hub.current.start_span(
|
||||||
op="authentik.flow.executor.dispatch", description=self.flow.slug
|
op="authentik.flow.executor.dispatch", description=self.flow.slug
|
||||||
|
@ -47,7 +47,6 @@ class FlowInspectorPlanSerializer(PassiveSerializer):
|
|||||||
"""Get the plan's context, sanitized"""
|
"""Get the plan's context, sanitized"""
|
||||||
return sanitize_dict(plan.context)
|
return sanitize_dict(plan.context)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get_session_id(self, plan: FlowPlan) -> str:
|
def get_session_id(self, plan: FlowPlan) -> str:
|
||||||
"""Get a unique session ID"""
|
"""Get a unique session ID"""
|
||||||
request: Request = self.context["request"]
|
request: Request = self.context["request"]
|
||||||
|
@ -93,7 +93,7 @@ class ConfigLoader:
|
|||||||
if url.scheme == "file":
|
if url.scheme == "file":
|
||||||
try:
|
try:
|
||||||
with open(url.path, "r", encoding="utf8") as _file:
|
with open(url.path, "r", encoding="utf8") as _file:
|
||||||
value = _file.read()
|
value = _file.read().strip()
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
self.log("error", f"Failed to read config value from {url.path}: {exc}")
|
self.log("error", f"Failed to read config value from {url.path}: {exc}")
|
||||||
value = url.query
|
value = url.query
|
||||||
|
@ -64,6 +64,7 @@ outposts:
|
|||||||
disable_embedded_outpost: false
|
disable_embedded_outpost: false
|
||||||
|
|
||||||
ldap:
|
ldap:
|
||||||
|
task_timeout_hours: 2
|
||||||
tls:
|
tls:
|
||||||
ciphers: null
|
ciphers: null
|
||||||
|
|
||||||
|
@ -159,7 +159,6 @@ class BaseEvaluator:
|
|||||||
raise exc
|
raise exc
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_error(self, exc: Exception, expression_source: str): # pragma: no cover
|
def handle_error(self, exc: Exception, expression_source: str): # pragma: no cover
|
||||||
"""Exception Handler"""
|
"""Exception Handler"""
|
||||||
LOGGER.warning("Expression error", exc=exc)
|
LOGGER.warning("Expression error", exc=exc)
|
||||||
|
@ -3,7 +3,6 @@ from logging import Logger
|
|||||||
from os import getpid
|
from os import getpid
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def add_process_id(logger: Logger, method_name: str, event_dict):
|
def add_process_id(logger: Logger, method_name: str, event_dict):
|
||||||
"""Add the current process ID"""
|
"""Add the current process ID"""
|
||||||
event_dict["pid"] = getpid()
|
event_dict["pid"] = getpid()
|
||||||
|
@ -148,7 +148,6 @@ class OutpostViewSet(UsedByMixin, ModelViewSet):
|
|||||||
|
|
||||||
@extend_schema(responses={200: OutpostHealthSerializer(many=True)})
|
@extend_schema(responses={200: OutpostHealthSerializer(many=True)})
|
||||||
@action(methods=["GET"], detail=True, pagination_class=None)
|
@action(methods=["GET"], detail=True, pagination_class=None)
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def health(self, request: Request, pk: int) -> Response:
|
def health(self, request: Request, pk: int) -> Response:
|
||||||
"""Get outposts current health"""
|
"""Get outposts current health"""
|
||||||
outpost: Outpost = self.get_object()
|
outpost: Outpost = self.get_object()
|
||||||
|
@ -91,7 +91,6 @@ class ServiceConnectionViewSet(
|
|||||||
|
|
||||||
@extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)})
|
@extend_schema(responses={200: ServiceConnectionStateSerializer(many=False)})
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[])
|
@action(detail=True, pagination_class=None, filter_backends=[])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def state(self, request: Request, pk: str) -> Response:
|
def state(self, request: Request, pk: str) -> Response:
|
||||||
"""Get the service connection's state"""
|
"""Get the service connection's state"""
|
||||||
connection = self.get_object()
|
connection = self.get_object()
|
||||||
|
@ -69,7 +69,6 @@ class OutpostConsumer(AuthJsonConsumer):
|
|||||||
self.outpost = outpost
|
self.outpost = outpost
|
||||||
self.last_uid = self.channel_name
|
self.last_uid = self.channel_name
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def disconnect(self, code):
|
def disconnect(self, code):
|
||||||
if self.outpost and self.last_uid:
|
if self.outpost and self.last_uid:
|
||||||
state = OutpostState.for_instance_uid(self.outpost, self.last_uid)
|
state = OutpostState.for_instance_uid(self.outpost, self.last_uid)
|
||||||
@ -127,7 +126,6 @@ class OutpostConsumer(AuthJsonConsumer):
|
|||||||
response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
|
response = WebsocketMessage(instruction=WebsocketMessageInstruction.ACK)
|
||||||
self.send_json(asdict(response))
|
self.send_json(asdict(response))
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def event_update(self, event): # pragma: no cover
|
def event_update(self, event): # pragma: no cover
|
||||||
"""Event handler which is called by post_save signals, Send update instruction"""
|
"""Event handler which is called by post_save signals, Send update instruction"""
|
||||||
self.send_json(
|
self.send_json(
|
||||||
|
@ -23,7 +23,6 @@ UPDATE_TRIGGERING_MODELS = (
|
|||||||
|
|
||||||
|
|
||||||
@receiver(pre_save, sender=Outpost)
|
@receiver(pre_save, sender=Outpost)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_save_outpost(sender, instance: Outpost, **_):
|
def pre_save_outpost(sender, instance: Outpost, **_):
|
||||||
"""Pre-save checks for an outpost, if the name or config.kubernetes_namespace changes,
|
"""Pre-save checks for an outpost, if the name or config.kubernetes_namespace changes,
|
||||||
we call down and then wait for the up after save"""
|
we call down and then wait for the up after save"""
|
||||||
@ -43,7 +42,6 @@ def pre_save_outpost(sender, instance: Outpost, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(m2m_changed, sender=Outpost.providers.through)
|
@receiver(m2m_changed, sender=Outpost.providers.through)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def m2m_changed_update(sender, instance: Model, action: str, **_):
|
def m2m_changed_update(sender, instance: Model, action: str, **_):
|
||||||
"""Update outpost on m2m change, when providers are added or removed"""
|
"""Update outpost on m2m change, when providers are added or removed"""
|
||||||
if action in ["post_add", "post_remove", "post_clear"]:
|
if action in ["post_add", "post_remove", "post_clear"]:
|
||||||
@ -51,7 +49,6 @@ def m2m_changed_update(sender, instance: Model, action: str, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def post_save_update(sender, instance: Model, created: bool, **_):
|
def post_save_update(sender, instance: Model, created: bool, **_):
|
||||||
"""If an Outpost is saved, Ensure that token is created/updated
|
"""If an Outpost is saved, Ensure that token is created/updated
|
||||||
|
|
||||||
@ -70,7 +67,6 @@ def post_save_update(sender, instance: Model, created: bool, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(pre_delete, sender=Outpost)
|
@receiver(pre_delete, sender=Outpost)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def pre_delete_cleanup(sender, instance: Outpost, **_):
|
def pre_delete_cleanup(sender, instance: Outpost, **_):
|
||||||
"""Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
|
"""Ensure that Outpost's user is deleted (which will delete the token through cascade)"""
|
||||||
instance.user.delete()
|
instance.user.delete()
|
||||||
|
@ -144,7 +144,6 @@ class PolicyViewSet(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
|
||||||
# pylint: disable=unused-argument, invalid-name
|
|
||||||
def test(self, request: Request, pk: str) -> Response:
|
def test(self, request: Request, pk: str) -> Response:
|
||||||
"""Test policy"""
|
"""Test policy"""
|
||||||
policy = self.get_object()
|
policy = self.get_object()
|
||||||
|
@ -52,6 +52,8 @@ class PolicyEngine:
|
|||||||
self.empty_result = True
|
self.empty_result = True
|
||||||
if not isinstance(pbm, PolicyBindingModel): # pragma: no cover
|
if not isinstance(pbm, PolicyBindingModel): # pragma: no cover
|
||||||
raise ValueError(f"{pbm} is not instance of PolicyBindingModel")
|
raise ValueError(f"{pbm} is not instance of PolicyBindingModel")
|
||||||
|
if not user:
|
||||||
|
raise ValueError("User must be set")
|
||||||
self.__pbm = pbm
|
self.__pbm = pbm
|
||||||
self.request = PolicyRequest(user)
|
self.request = PolicyRequest(user)
|
||||||
self.request.obj = pbm
|
self.request.obj = pbm
|
||||||
|
@ -1,14 +1,25 @@
|
|||||||
"""Event Matcher Policy API"""
|
"""Event Matcher Policy API"""
|
||||||
|
from django.utils.translation import gettext as _
|
||||||
|
from rest_framework.fields import ChoiceField
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.policies.api.policies import PolicySerializer
|
from authentik.policies.api.policies import PolicySerializer
|
||||||
from authentik.policies.event_matcher.models import EventMatcherPolicy
|
from authentik.policies.event_matcher.models import EventMatcherPolicy, app_choices
|
||||||
|
|
||||||
|
|
||||||
class EventMatcherPolicySerializer(PolicySerializer):
|
class EventMatcherPolicySerializer(PolicySerializer):
|
||||||
"""Event Matcher Policy Serializer"""
|
"""Event Matcher Policy Serializer"""
|
||||||
|
|
||||||
|
app = ChoiceField(
|
||||||
|
choices=app_choices(),
|
||||||
|
required=False,
|
||||||
|
help_text=_(
|
||||||
|
"Match events created by selected application. When left empty, "
|
||||||
|
"all applications are matched."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = EventMatcherPolicy
|
model = EventMatcherPolicy
|
||||||
fields = PolicySerializer.Meta.fields + [
|
fields = PolicySerializer.Meta.fields + [
|
||||||
|
@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-05 09:24
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"authentik_policies_event_matcher",
|
||||||
|
"0020_eventmatcherpolicy_authentik_p_policy__e605cf_idx",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="eventmatcherpolicy",
|
||||||
|
name="app",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
default="",
|
||||||
|
help_text="Match events created by selected application. When left empty, all applications are matched.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -33,7 +33,6 @@ class EventMatcherPolicy(Policy):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
app = models.TextField(
|
app = models.TextField(
|
||||||
choices=app_choices(),
|
|
||||||
blank=True,
|
blank=True,
|
||||||
default="",
|
default="",
|
||||||
help_text=_(
|
help_text=_(
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
"""Source API Views"""
|
|
||||||
from rest_framework.viewsets import ModelViewSet
|
|
||||||
|
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
|
||||||
from authentik.policies.api.policies import PolicySerializer
|
|
||||||
from authentik.policies.hibp.models import HaveIBeenPwendPolicy
|
|
||||||
|
|
||||||
|
|
||||||
class HaveIBeenPwendPolicySerializer(PolicySerializer):
|
|
||||||
"""Have I Been Pwned Policy Serializer"""
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = HaveIBeenPwendPolicy
|
|
||||||
fields = PolicySerializer.Meta.fields + ["password_field", "allowed_count"]
|
|
||||||
|
|
||||||
|
|
||||||
class HaveIBeenPwendPolicyViewSet(UsedByMixin, ModelViewSet):
|
|
||||||
"""Source Viewset"""
|
|
||||||
|
|
||||||
queryset = HaveIBeenPwendPolicy.objects.all()
|
|
||||||
serializer_class = HaveIBeenPwendPolicySerializer
|
|
||||||
filterset_fields = "__all__"
|
|
||||||
search_fields = ["name", "password_field"]
|
|
||||||
ordering = ["name"]
|
|
@ -1,11 +0,0 @@
|
|||||||
"""Authentik hibp app config"""
|
|
||||||
|
|
||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class AuthentikPolicyHIBPConfig(AppConfig):
|
|
||||||
"""Authentik hibp app config"""
|
|
||||||
|
|
||||||
name = "authentik.policies.hibp"
|
|
||||||
label = "authentik_policies_hibp"
|
|
||||||
verbose_name = "authentik Policies.HaveIBeenPwned"
|
|
@ -1,38 +0,0 @@
|
|||||||
# Generated by Django 3.0.6 on 2020-05-19 22:08
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
initial = True
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_policies", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="HaveIBeenPwendPolicy",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"policy_ptr",
|
|
||||||
models.OneToOneField(
|
|
||||||
auto_created=True,
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
parent_link=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
to="authentik_policies.Policy",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("allowed_count", models.IntegerField(default=0)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
"verbose_name": "Have I Been Pwned Policy",
|
|
||||||
"verbose_name_plural": "Have I Been Pwned Policies",
|
|
||||||
},
|
|
||||||
bases=("authentik_policies.policy",),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 3.0.8 on 2020-07-10 18:45
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_policies_hibp", "0001_initial"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name="haveibeenpwendpolicy",
|
|
||||||
name="password_field",
|
|
||||||
field=models.TextField(
|
|
||||||
default="password",
|
|
||||||
help_text="Field key to check, field keys defined in Prompt stages are available.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 4.1.2 on 2022-10-19 19:24
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
("authentik_policies_hibp", "0002_haveibeenpwendpolicy_password_field"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddIndex(
|
|
||||||
model_name="haveibeenpwendpolicy",
|
|
||||||
index=models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__6957d7_idx"),
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,71 +0,0 @@
|
|||||||
"""authentik HIBP Models"""
|
|
||||||
from hashlib import sha1
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
from django.utils.translation import gettext as _
|
|
||||||
from rest_framework.serializers import BaseSerializer
|
|
||||||
from structlog.stdlib import get_logger
|
|
||||||
|
|
||||||
from authentik.lib.utils.http import get_http_session
|
|
||||||
from authentik.policies.models import Policy, PolicyResult
|
|
||||||
from authentik.policies.types import PolicyRequest
|
|
||||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
class HaveIBeenPwendPolicy(Policy):
|
|
||||||
"""DEPRECATED. Check if password is on HaveIBeenPwned's list by uploading the first
|
|
||||||
5 characters of the SHA1 Hash."""
|
|
||||||
|
|
||||||
password_field = models.TextField(
|
|
||||||
default="password",
|
|
||||||
help_text=_("Field key to check, field keys defined in Prompt stages are available."),
|
|
||||||
)
|
|
||||||
|
|
||||||
allowed_count = models.IntegerField(default=0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def serializer(self) -> type[BaseSerializer]:
|
|
||||||
from authentik.policies.hibp.api import HaveIBeenPwendPolicySerializer
|
|
||||||
|
|
||||||
return HaveIBeenPwendPolicySerializer
|
|
||||||
|
|
||||||
@property
|
|
||||||
def component(self) -> str:
|
|
||||||
return "ak-policy-hibp-form"
|
|
||||||
|
|
||||||
def passes(self, request: PolicyRequest) -> PolicyResult:
|
|
||||||
"""Check if password is in HIBP DB. Hashes given Password with SHA1, uses the first 5
|
|
||||||
characters of Password in request and checks if full hash is in response. Returns 0
|
|
||||||
if Password is not in result otherwise the count of how many times it was used."""
|
|
||||||
password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get(
|
|
||||||
self.password_field, request.context.get(self.password_field)
|
|
||||||
)
|
|
||||||
if not password:
|
|
||||||
LOGGER.warning(
|
|
||||||
"Password field not set in Policy Request",
|
|
||||||
field=self.password_field,
|
|
||||||
fields=request.context.keys(),
|
|
||||||
)
|
|
||||||
return PolicyResult(False, _("Password not set in context"))
|
|
||||||
password = str(password)
|
|
||||||
|
|
||||||
pw_hash = sha1(password.encode("utf-8")).hexdigest() # nosec
|
|
||||||
url = f"https://api.pwnedpasswords.com/range/{pw_hash[:5]}"
|
|
||||||
result = get_http_session().get(url).text
|
|
||||||
final_count = 0
|
|
||||||
for line in result.split("\r\n"):
|
|
||||||
full_hash, count = line.split(":")
|
|
||||||
if pw_hash[5:] == full_hash.lower():
|
|
||||||
final_count = int(count)
|
|
||||||
LOGGER.debug("got hibp result", count=final_count, hash=pw_hash[:5])
|
|
||||||
if final_count > self.allowed_count:
|
|
||||||
message = _("Password exists on %(count)d online lists." % {"count": final_count})
|
|
||||||
return PolicyResult(False, message)
|
|
||||||
return PolicyResult(True)
|
|
||||||
|
|
||||||
class Meta(Policy.PolicyMeta):
|
|
||||||
|
|
||||||
verbose_name = _("Have I Been Pwned Policy")
|
|
||||||
verbose_name_plural = _("Have I Been Pwned Policies")
|
|
@ -1,44 +0,0 @@
|
|||||||
"""HIBP Policy tests"""
|
|
||||||
from django.test import TestCase
|
|
||||||
from guardian.shortcuts import get_anonymous_user
|
|
||||||
|
|
||||||
from authentik.lib.generators import generate_key
|
|
||||||
from authentik.policies.hibp.models import HaveIBeenPwendPolicy
|
|
||||||
from authentik.policies.types import PolicyRequest, PolicyResult
|
|
||||||
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
|
|
||||||
|
|
||||||
|
|
||||||
class TestHIBPPolicy(TestCase):
|
|
||||||
"""Test HIBP Policy"""
|
|
||||||
|
|
||||||
def test_invalid(self):
|
|
||||||
"""Test without password"""
|
|
||||||
policy = HaveIBeenPwendPolicy.objects.create(
|
|
||||||
name="test_invalid",
|
|
||||||
)
|
|
||||||
request = PolicyRequest(get_anonymous_user())
|
|
||||||
result: PolicyResult = policy.passes(request)
|
|
||||||
self.assertFalse(result.passing)
|
|
||||||
self.assertEqual(result.messages[0], "Password not set in context")
|
|
||||||
|
|
||||||
def test_false(self):
|
|
||||||
"""Failing password case"""
|
|
||||||
policy = HaveIBeenPwendPolicy.objects.create(
|
|
||||||
name="test_false",
|
|
||||||
)
|
|
||||||
request = PolicyRequest(get_anonymous_user())
|
|
||||||
request.context[PLAN_CONTEXT_PROMPT] = {"password": "password"} # nosec
|
|
||||||
result: PolicyResult = policy.passes(request)
|
|
||||||
self.assertFalse(result.passing)
|
|
||||||
self.assertTrue(result.messages[0].startswith("Password exists on "))
|
|
||||||
|
|
||||||
def test_true(self):
|
|
||||||
"""Positive password case"""
|
|
||||||
policy = HaveIBeenPwendPolicy.objects.create(
|
|
||||||
name="test_true",
|
|
||||||
)
|
|
||||||
request = PolicyRequest(get_anonymous_user())
|
|
||||||
request.context[PLAN_CONTEXT_PROMPT] = {"password": generate_key()}
|
|
||||||
result: PolicyResult = policy.passes(request)
|
|
||||||
self.assertTrue(result.passing)
|
|
||||||
self.assertEqual(result.messages, tuple())
|
|
@ -1,34 +1,10 @@
|
|||||||
# Generated by Django 4.1.3 on 2022-11-14 09:23
|
# Generated by Django 4.1.3 on 2022-11-14 09:23
|
||||||
from django.apps.registry import Apps
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_hibp_policy(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
|
||||||
db_alias = schema_editor.connection.alias
|
|
||||||
|
|
||||||
HaveIBeenPwendPolicy = apps.get_model("authentik_policies_hibp", "HaveIBeenPwendPolicy")
|
|
||||||
PasswordPolicy = apps.get_model("authentik_policies_password", "PasswordPolicy")
|
|
||||||
|
|
||||||
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
|
|
||||||
|
|
||||||
for old_policy in HaveIBeenPwendPolicy.objects.using(db_alias).all():
|
|
||||||
new_policy = PasswordPolicy.objects.using(db_alias).create(
|
|
||||||
name=old_policy.name,
|
|
||||||
hibp_allowed_count=old_policy.allowed_count,
|
|
||||||
password_field=old_policy.password_field,
|
|
||||||
execution_logging=old_policy.execution_logging,
|
|
||||||
check_static_rules=False,
|
|
||||||
check_have_i_been_pwned=True,
|
|
||||||
)
|
|
||||||
PolicyBinding.objects.using(db_alias).filter(policy=old_policy).update(policy=new_policy)
|
|
||||||
old_policy.delete()
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("authentik_policies_hibp", "0003_haveibeenpwendpolicy_authentik_p_policy__6957d7_idx"),
|
|
||||||
("authentik_policies_password", "0004_passwordpolicy_authentik_p_policy__855e80_idx"),
|
("authentik_policies_password", "0004_passwordpolicy_authentik_p_policy__855e80_idx"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -69,5 +45,4 @@ class Migration(migrations.Migration):
|
|||||||
name="error_message",
|
name="error_message",
|
||||||
field=models.TextField(blank=True),
|
field=models.TextField(blank=True),
|
||||||
),
|
),
|
||||||
migrations.RunPython(migrate_hibp_policy),
|
|
||||||
]
|
]
|
||||||
|
@ -138,5 +138,5 @@ class PolicyProcess(PROCESS_CLASS):
|
|||||||
try:
|
try:
|
||||||
self.connection.send(self.profiling_wrapper())
|
self.connection.send(self.profiling_wrapper())
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
LOGGER.warning("Policy failed to run", exc=exc)
|
LOGGER.warning("Policy failed to run", exc=exception_to_string(exc))
|
||||||
self.connection.send(PolicyResult(False, str(exc)))
|
self.connection.send(PolicyResult(False, str(exc)))
|
||||||
|
@ -37,7 +37,6 @@ def update_score(request: HttpRequest, identifier: str, amount: int):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(login_failed)
|
@receiver(login_failed)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_failed_login(sender, request, credentials, **_):
|
def handle_failed_login(sender, request, credentials, **_):
|
||||||
"""Lower Score for failed login attempts"""
|
"""Lower Score for failed login attempts"""
|
||||||
if "username" in credentials:
|
if "username" in credentials:
|
||||||
@ -45,14 +44,12 @@ def handle_failed_login(sender, request, credentials, **_):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(identification_failed)
|
@receiver(identification_failed)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_identification_failed(sender, request, uid_field: str, **_):
|
def handle_identification_failed(sender, request, uid_field: str, **_):
|
||||||
"""Lower Score for failed identification attempts"""
|
"""Lower Score for failed identification attempts"""
|
||||||
update_score(request, uid_field, -1)
|
update_score(request, uid_field, -1)
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
@receiver(user_logged_in)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def handle_successful_login(sender, request, user, **_):
|
def handle_successful_login(sender, request, user, **_):
|
||||||
"""Raise score for successful attempts"""
|
"""Raise score for successful attempts"""
|
||||||
update_score(request, user.username, 1)
|
update_score(request, user.username, 1)
|
||||||
|
@ -13,14 +13,12 @@ LOGGER = get_logger()
|
|||||||
|
|
||||||
|
|
||||||
@receiver(monitoring_set)
|
@receiver(monitoring_set)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def monitoring_set_policies(sender, **kwargs):
|
def monitoring_set_policies(sender, **kwargs):
|
||||||
"""set policy gauges"""
|
"""set policy gauges"""
|
||||||
GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or []))
|
GAUGE_POLICIES_CACHED.set(len(cache.keys(f"{CACHE_PREFIX}_*") or []))
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save)
|
@receiver(post_save)
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def invalidate_policy_cache(sender, instance, **_):
|
def invalidate_policy_cache(sender, instance, **_):
|
||||||
"""Invalidate Policy cache when policy is updated"""
|
"""Invalidate Policy cache when policy is updated"""
|
||||||
from authentik.policies.models import Policy, PolicyBinding
|
from authentik.policies.models import Policy, PolicyBinding
|
||||||
|
@ -83,7 +83,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
@action(methods=["GET"], detail=True)
|
@action(methods=["GET"], detail=True)
|
||||||
# pylint: disable=invalid-name
|
|
||||||
def setup_urls(self, request: Request, pk: int) -> str:
|
def setup_urls(self, request: Request, pk: int) -> str:
|
||||||
"""Get Providers setup URLs"""
|
"""Get Providers setup URLs"""
|
||||||
provider = get_object_or_404(OAuth2Provider, pk=pk)
|
provider = get_object_or_404(OAuth2Provider, pk=pk)
|
||||||
@ -140,7 +139,6 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["GET"])
|
@action(detail=True, methods=["GET"])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def preview_user(self, request: Request, pk: int) -> Response:
|
def preview_user(self, request: Request, pk: int) -> Response:
|
||||||
"""Preview user data for provider"""
|
"""Preview user data for provider"""
|
||||||
provider: OAuth2Provider = self.get_object()
|
provider: OAuth2Provider = self.get_object()
|
||||||
|
@ -3,6 +3,8 @@ from django_filters.filters import AllValuesMultipleFilter
|
|||||||
from django_filters.filterset import FilterSet
|
from django_filters.filterset import FilterSet
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
|
from rest_framework.fields import CharField
|
||||||
|
from rest_framework.serializers import ValidationError
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
|
||||||
from authentik.core.api.propertymappings import PropertyMappingSerializer
|
from authentik.core.api.propertymappings import PropertyMappingSerializer
|
||||||
@ -10,9 +12,18 @@ from authentik.core.api.used_by import UsedByMixin
|
|||||||
from authentik.providers.oauth2.models import ScopeMapping
|
from authentik.providers.oauth2.models import ScopeMapping
|
||||||
|
|
||||||
|
|
||||||
|
def no_space(value: str) -> str:
|
||||||
|
"""Ensure value contains no spaces"""
|
||||||
|
if " " in value:
|
||||||
|
raise ValidationError("Value must not contain spaces.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class ScopeMappingSerializer(PropertyMappingSerializer):
|
class ScopeMappingSerializer(PropertyMappingSerializer):
|
||||||
"""ScopeMapping Serializer"""
|
"""ScopeMapping Serializer"""
|
||||||
|
|
||||||
|
scope_name = CharField(help_text="Scope name requested by the client", validators=[no_space])
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = ScopeMapping
|
model = ScopeMapping
|
||||||
|
@ -16,7 +16,7 @@ class Migration(migrations.Migration):
|
|||||||
model_name="oauth2provider",
|
model_name="oauth2provider",
|
||||||
name="verification_keys",
|
name="verification_keys",
|
||||||
field=models.ManyToManyField(
|
field=models.ManyToManyField(
|
||||||
help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.",
|
help_text="JWTs created with the configured certificates can authenticate with this provider.",
|
||||||
related_name="+",
|
related_name="+",
|
||||||
to="authentik_crypto.certificatekeypair",
|
to="authentik_crypto.certificatekeypair",
|
||||||
verbose_name="Allowed certificates for JWT-based client_credentials",
|
verbose_name="Allowed certificates for JWT-based client_credentials",
|
||||||
|
@ -32,7 +32,7 @@ class Migration(migrations.Migration):
|
|||||||
field=models.ManyToManyField(
|
field=models.ManyToManyField(
|
||||||
blank=True,
|
blank=True,
|
||||||
default=None,
|
default=None,
|
||||||
help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.",
|
help_text="JWTs created with the configured certificates can authenticate with this provider.",
|
||||||
related_name="oauth2_providers",
|
related_name="oauth2_providers",
|
||||||
to="authentik_crypto.certificatekeypair",
|
to="authentik_crypto.certificatekeypair",
|
||||||
verbose_name="Allowed certificates for JWT-based client_credentials",
|
verbose_name="Allowed certificates for JWT-based client_credentials",
|
||||||
|
@ -8,6 +8,7 @@ from django.urls import reverse
|
|||||||
from authentik.core.models import Application
|
from authentik.core.models import Application
|
||||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||||
from authentik.lib.generators import generate_id, generate_key
|
from authentik.lib.generators import generate_id, generate_key
|
||||||
|
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
|
||||||
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
from authentik.providers.oauth2.models import IDToken, OAuth2Provider, RefreshToken
|
||||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||||
|
|
||||||
@ -57,6 +58,8 @@ class TesOAuth2Introspection(OAuthTestCase):
|
|||||||
self.assertJSONEqual(
|
self.assertJSONEqual(
|
||||||
res.content.decode(),
|
res.content.decode(),
|
||||||
{
|
{
|
||||||
|
"acr": ACR_AUTHENTIK_DEFAULT,
|
||||||
|
"auth_time": None,
|
||||||
"aud": None,
|
"aud": None,
|
||||||
"sub": "bar",
|
"sub": "bar",
|
||||||
"exp": None,
|
"exp": None,
|
||||||
@ -64,6 +67,7 @@ class TesOAuth2Introspection(OAuthTestCase):
|
|||||||
"iss": "foo",
|
"iss": "foo",
|
||||||
"active": True,
|
"active": True,
|
||||||
"client_id": self.provider.client_id,
|
"client_id": self.provider.client_id,
|
||||||
|
"scope": " ".join(self.token.scope),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
"""JWKS tests"""
|
"""JWKS tests"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from django.test import RequestFactory
|
|
||||||
from django.urls.base import reverse
|
from django.urls.base import reverse
|
||||||
from jwt import PyJWKSet
|
from jwt import PyJWKSet
|
||||||
|
|
||||||
@ -14,10 +13,6 @@ from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
|||||||
class TestJWKS(OAuthTestCase):
|
class TestJWKS(OAuthTestCase):
|
||||||
"""Test JWKS view"""
|
"""Test JWKS view"""
|
||||||
|
|
||||||
def setUp(self) -> None:
|
|
||||||
super().setUp()
|
|
||||||
self.factory = RequestFactory()
|
|
||||||
|
|
||||||
def test_rs256(self):
|
def test_rs256(self):
|
||||||
"""Test JWKS request with RS256"""
|
"""Test JWKS request with RS256"""
|
||||||
provider = OAuth2Provider.objects.create(
|
provider = OAuth2Provider.objects.create(
|
||||||
|
@ -318,7 +318,6 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
|||||||
request.context["oauth_response_type"] = self.params.response_type
|
request.context["oauth_response_type"] = self.params.response_type
|
||||||
return request
|
return request
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""Start FlowPLanner, return to flow executor shell"""
|
"""Start FlowPLanner, return to flow executor shell"""
|
||||||
# After we've checked permissions, and the user has access, check if we need
|
# After we've checked permissions, and the user has access, check if we need
|
||||||
@ -429,7 +428,6 @@ class OAuthFulfillmentStage(StageView):
|
|||||||
"""Wrapper when this stage gets hit with a post request"""
|
"""Wrapper when this stage gets hit with a post request"""
|
||||||
return self.get(request, *args, **kwargs)
|
return self.get(request, *args, **kwargs)
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""final Stage of an OAuth2 Flow"""
|
"""final Stage of an OAuth2 Flow"""
|
||||||
if PLAN_CONTEXT_PARAMS not in self.executor.plan.context:
|
if PLAN_CONTEXT_PARAMS not in self.executor.plan.context:
|
||||||
|
@ -73,7 +73,6 @@ class GitHubUserView(View):
|
|||||||
class GitHubUserTeamsView(View):
|
class GitHubUserTeamsView(View):
|
||||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
|
||||||
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
def get(self, request: HttpRequest, token: RefreshToken) -> HttpResponse:
|
||||||
"""Emulate GitHub's /user/teams API Endpoint"""
|
"""Emulate GitHub's /user/teams API Endpoint"""
|
||||||
user = token.user
|
user = token.user
|
||||||
|
@ -52,9 +52,8 @@ class TokenIntrospectionParams:
|
|||||||
if not provider:
|
if not provider:
|
||||||
raise TokenIntrospectionError
|
raise TokenIntrospectionError
|
||||||
|
|
||||||
try:
|
token: RefreshToken = RefreshToken.objects.filter(provider=provider, **token_filter).first()
|
||||||
token: RefreshToken = RefreshToken.objects.get(provider=provider, **token_filter)
|
if not token:
|
||||||
except RefreshToken.DoesNotExist:
|
|
||||||
LOGGER.debug("Token does not exist", token=raw_token)
|
LOGGER.debug("Token does not exist", token=raw_token)
|
||||||
raise TokenIntrospectionError()
|
raise TokenIntrospectionError()
|
||||||
|
|
||||||
@ -74,15 +73,12 @@ class TokenIntrospectionView(View):
|
|||||||
"""Introspection handler"""
|
"""Introspection handler"""
|
||||||
try:
|
try:
|
||||||
self.params = TokenIntrospectionParams.from_request(request)
|
self.params = TokenIntrospectionParams.from_request(request)
|
||||||
|
response = {}
|
||||||
response_dic = {}
|
|
||||||
if self.params.id_token:
|
if self.params.id_token:
|
||||||
token_dict = self.params.id_token.to_dict()
|
response.update(self.params.id_token.to_dict())
|
||||||
for k in ("aud", "sub", "exp", "iat", "iss"):
|
response["active"] = True
|
||||||
response_dic[k] = token_dict[k]
|
response["scope"] = " ".join(self.params.token.scope)
|
||||||
response_dic["active"] = True
|
response["client_id"] = self.params.token.provider.client_id
|
||||||
response_dic["client_id"] = self.params.token.provider.client_id
|
return TokenResponse(response)
|
||||||
|
|
||||||
return TokenResponse(response_dic)
|
|
||||||
except TokenIntrospectionError:
|
except TokenIntrospectionError:
|
||||||
return TokenResponse({"active": False})
|
return TokenResponse({"active": False})
|
||||||
|
@ -4,8 +4,10 @@ from typing import Any
|
|||||||
from django.http import HttpRequest, HttpResponse, JsonResponse
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, reverse
|
from django.shortcuts import get_object_or_404, reverse
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
from guardian.shortcuts import get_anonymous_user
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||||
from authentik.core.models import Application
|
from authentik.core.models import Application
|
||||||
from authentik.providers.oauth2.constants import (
|
from authentik.providers.oauth2.constants import (
|
||||||
ACR_AUTHENTIK_DEFAULT,
|
ACR_AUTHENTIK_DEFAULT,
|
||||||
@ -108,21 +110,40 @@ class ProviderInfoView(View):
|
|||||||
"scopes_supported": scopes,
|
"scopes_supported": scopes,
|
||||||
# https://openid.net/specs/openid-connect-core-1_0.html#RequestObject
|
# https://openid.net/specs/openid-connect-core-1_0.html#RequestObject
|
||||||
"request_parameter_supported": False,
|
"request_parameter_supported": False,
|
||||||
# Because claims are dynamic and per-application, the only claims listed here
|
"claims_supported": self.get_claims(provider),
|
||||||
# are ones that are always set by authentik itself on every token
|
|
||||||
"claims_supported": [
|
|
||||||
"sub",
|
|
||||||
"iss",
|
|
||||||
"aud",
|
|
||||||
"exp",
|
|
||||||
"iat",
|
|
||||||
"auth_time",
|
|
||||||
"acr",
|
|
||||||
],
|
|
||||||
"claims_parameter_supported": False,
|
"claims_parameter_supported": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
def get_claims(self, provider: OAuth2Provider) -> list[str]:
|
||||||
|
"""Get a list of supported claims based on configured scope mappings"""
|
||||||
|
default_claims = [
|
||||||
|
"sub",
|
||||||
|
"iss",
|
||||||
|
"aud",
|
||||||
|
"exp",
|
||||||
|
"iat",
|
||||||
|
"auth_time",
|
||||||
|
"acr",
|
||||||
|
"amr",
|
||||||
|
"nonce",
|
||||||
|
]
|
||||||
|
for scope in ScopeMapping.objects.filter(provider=provider).order_by("scope_name"):
|
||||||
|
value = None
|
||||||
|
try:
|
||||||
|
value = scope.evaluate(
|
||||||
|
user=get_anonymous_user(),
|
||||||
|
request=self.request,
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
except PropertyMappingExpressionException:
|
||||||
|
continue
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
continue
|
||||||
|
default_claims.extend(value.keys())
|
||||||
|
return default_claims
|
||||||
|
|
||||||
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
|
||||||
"""OpenID-compliant Provider Info"""
|
"""OpenID-compliant Provider Info"""
|
||||||
return JsonResponse(self.get_info(self.provider), json_dumps_params={"indent": 2})
|
return JsonResponse(self.get_info(self.provider), json_dumps_params={"indent": 2})
|
||||||
|
@ -11,6 +11,7 @@ from django.utils.decorators import method_decorator
|
|||||||
from django.utils.timezone import datetime, now
|
from django.utils.timezone import datetime, now
|
||||||
from django.views import View
|
from django.views import View
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from guardian.shortcuts import get_anonymous_user
|
||||||
from jwt import PyJWK, PyJWTError, decode
|
from jwt import PyJWK, PyJWTError, decode
|
||||||
from sentry_sdk.hub import Hub
|
from sentry_sdk.hub import Hub
|
||||||
from structlog.stdlib import get_logger
|
from structlog.stdlib import get_logger
|
||||||
@ -104,7 +105,8 @@ class TokenParams:
|
|||||||
with Hub.current.start_span(
|
with Hub.current.start_span(
|
||||||
op="authentik.providers.oauth2.token.policy",
|
op="authentik.providers.oauth2.token.policy",
|
||||||
):
|
):
|
||||||
engine = PolicyEngine(app, self.user, request)
|
user = self.user if self.user else get_anonymous_user()
|
||||||
|
engine = PolicyEngine(app, user, request)
|
||||||
engine.request.context["oauth_scopes"] = self.scope
|
engine.request.context["oauth_scopes"] = self.scope
|
||||||
engine.request.context["oauth_grant_type"] = self.grant_type
|
engine.request.context["oauth_grant_type"] = self.grant_type
|
||||||
engine.request.context["oauth_code_verifier"] = self.code_verifier
|
engine.request.context["oauth_code_verifier"] = self.code_verifier
|
||||||
@ -112,7 +114,9 @@ class TokenParams:
|
|||||||
engine.build()
|
engine.build()
|
||||||
result = engine.result
|
result = engine.result
|
||||||
if not result.passing:
|
if not result.passing:
|
||||||
LOGGER.info("User not authenticated for application", user=self.user, app=app)
|
LOGGER.info(
|
||||||
|
"User not authenticated for application", user=self.user, app_slug=app.slug
|
||||||
|
)
|
||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
def __post_init__(self, raw_code: str, raw_token: str, request: HttpRequest):
|
||||||
@ -303,10 +307,10 @@ class TokenParams:
|
|||||||
source: Optional[OAuthSource] = None
|
source: Optional[OAuthSource] = None
|
||||||
parsed_key: Optional[PyJWK] = None
|
parsed_key: Optional[PyJWK] = None
|
||||||
for source in self.provider.jwks_sources.all():
|
for source in self.provider.jwks_sources.all():
|
||||||
LOGGER.debug("verifying jwt with source", source=source.name)
|
LOGGER.debug("verifying jwt with source", source=source.slug)
|
||||||
keys = source.oidc_jwks.get("keys", [])
|
keys = source.oidc_jwks.get("keys", [])
|
||||||
for key in keys:
|
for key in keys:
|
||||||
LOGGER.debug("verifying jwt with key", source=source.name, key=key.get("kid"))
|
LOGGER.debug("verifying jwt with key", source=source.slug, key=key.get("kid"))
|
||||||
try:
|
try:
|
||||||
parsed_key = PyJWK.from_dict(key)
|
parsed_key = PyJWK.from_dict(key)
|
||||||
token = decode(
|
token = decode(
|
||||||
@ -320,12 +324,14 @@ class TokenParams:
|
|||||||
# AttributeError is raised when the configured JWK is a private key
|
# AttributeError is raised when the configured JWK is a private key
|
||||||
# and not a public key
|
# and not a public key
|
||||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||||
LOGGER.warning("failed to validate jwt", exc=exc)
|
LOGGER.warning("failed to verify jwt", exc=exc, source=source.slug)
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
LOGGER.warning("No token could be verified")
|
LOGGER.warning("No token could be verified")
|
||||||
raise TokenError("invalid_grant")
|
raise TokenError("invalid_grant")
|
||||||
|
|
||||||
|
LOGGER.debug("successfully verified jwt with source", source=source.slug)
|
||||||
|
|
||||||
if "exp" in token:
|
if "exp" in token:
|
||||||
exp = datetime.fromtimestamp(token["exp"])
|
exp = datetime.fromtimestamp(token["exp"])
|
||||||
# Non-timezone aware check since we assume `exp` is in UTC
|
# Non-timezone aware check since we assume `exp` is in UTC
|
||||||
|
@ -110,6 +110,8 @@ class UserInfoView(View):
|
|||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
claims = self.get_claims(self.token)
|
claims = self.get_claims(self.token)
|
||||||
claims["sub"] = self.token.id_token.sub
|
claims["sub"] = self.token.id_token.sub
|
||||||
|
if self.token.id_token.nonce:
|
||||||
|
claims["nonce"] = self.token.id_token.nonce
|
||||||
response = TokenResponse(claims)
|
response = TokenResponse(claims)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -37,6 +37,7 @@ class OpenIDConnectConfigurationSerializer(PassiveSerializer):
|
|||||||
class ProxyProviderSerializer(ProviderSerializer):
|
class ProxyProviderSerializer(ProviderSerializer):
|
||||||
"""ProxyProvider Serializer"""
|
"""ProxyProvider Serializer"""
|
||||||
|
|
||||||
|
client_id = CharField(read_only=True)
|
||||||
redirect_uris = CharField(read_only=True)
|
redirect_uris = CharField(read_only=True)
|
||||||
outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
|
outpost_set = ListField(child=CharField(), read_only=True, source="outpost_set.all")
|
||||||
|
|
||||||
@ -77,6 +78,7 @@ class ProxyProviderSerializer(ProviderSerializer):
|
|||||||
|
|
||||||
model = ProxyProvider
|
model = ProxyProvider
|
||||||
fields = ProviderSerializer.Meta.fields + [
|
fields = ProviderSerializer.Meta.fields + [
|
||||||
|
"client_id",
|
||||||
"internal_host",
|
"internal_host",
|
||||||
"external_host",
|
"external_host",
|
||||||
"internal_host_ssl_validation",
|
"internal_host_ssl_validation",
|
||||||
@ -86,8 +88,10 @@ class ProxyProviderSerializer(ProviderSerializer):
|
|||||||
"basic_auth_password_attribute",
|
"basic_auth_password_attribute",
|
||||||
"basic_auth_user_attribute",
|
"basic_auth_user_attribute",
|
||||||
"mode",
|
"mode",
|
||||||
|
"intercept_header_auth",
|
||||||
"redirect_uris",
|
"redirect_uris",
|
||||||
"cookie_domain",
|
"cookie_domain",
|
||||||
|
"jwks_sources",
|
||||||
"token_validity",
|
"token_validity",
|
||||||
"outpost_set",
|
"outpost_set",
|
||||||
]
|
]
|
||||||
@ -168,6 +172,7 @@ class ProxyOutpostConfigSerializer(ModelSerializer):
|
|||||||
"mode",
|
"mode",
|
||||||
"cookie_domain",
|
"cookie_domain",
|
||||||
"token_validity",
|
"token_validity",
|
||||||
|
"intercept_header_auth",
|
||||||
"scopes_to_request",
|
"scopes_to_request",
|
||||||
"assigned_application_slug",
|
"assigned_application_slug",
|
||||||
"assigned_application_name",
|
"assigned_application_name",
|
||||||
|
@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-01-17 10:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("authentik_providers_proxy", "0001_squashed_0014_proxy_v2"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="proxyprovider",
|
||||||
|
name="intercept_header_auth",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="When enabled, this provider will intercept the authorization header and authenticate requests based on its value.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -74,6 +74,13 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
intercept_header_auth = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text=_(
|
||||||
|
"When enabled, this provider will intercept the authorization header and authenticate "
|
||||||
|
"requests based on its value."
|
||||||
|
),
|
||||||
|
)
|
||||||
basic_auth_enabled = models.BooleanField(
|
basic_auth_enabled = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Set HTTP-Basic Authentication"),
|
verbose_name=_("Set HTTP-Basic Authentication"),
|
||||||
@ -126,6 +133,7 @@ class ProxyProvider(OutpostModel, OAuth2Provider):
|
|||||||
"""Ensure all OAuth2-related settings are correct"""
|
"""Ensure all OAuth2-related settings are correct"""
|
||||||
self.client_type = ClientTypes.CONFIDENTIAL
|
self.client_type = ClientTypes.CONFIDENTIAL
|
||||||
self.signing_key = None
|
self.signing_key = None
|
||||||
|
self.include_claims_in_id_token = True
|
||||||
scopes = ScopeMapping.objects.filter(
|
scopes = ScopeMapping.objects.filter(
|
||||||
managed__in=[
|
managed__in=[
|
||||||
"goauthentik.io/providers/oauth2/scope-openid",
|
"goauthentik.io/providers/oauth2/scope-openid",
|
||||||
|
@ -28,9 +28,9 @@ from authentik.core.models import Provider
|
|||||||
from authentik.flows.models import Flow, FlowDesignation
|
from authentik.flows.models import Flow, FlowDesignation
|
||||||
from authentik.providers.saml.models import SAMLProvider
|
from authentik.providers.saml.models import SAMLProvider
|
||||||
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
||||||
|
from authentik.providers.saml.processors.authn_request_parser import AuthNRequest
|
||||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||||
from authentik.providers.saml.processors.metadata_parser import ServiceProviderMetadataParser
|
from authentik.providers.saml.processors.metadata_parser import ServiceProviderMetadataParser
|
||||||
from authentik.providers.saml.processors.request_parser import AuthNRequest
|
|
||||||
from authentik.sources.saml.processors.constants import SAML_BINDING_POST, SAML_BINDING_REDIRECT
|
from authentik.sources.saml.processors.constants import SAML_BINDING_POST, SAML_BINDING_REDIRECT
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
@ -44,6 +44,8 @@ class SAMLProviderSerializer(ProviderSerializer):
|
|||||||
url_sso_post = SerializerMethodField()
|
url_sso_post = SerializerMethodField()
|
||||||
url_sso_redirect = SerializerMethodField()
|
url_sso_redirect = SerializerMethodField()
|
||||||
url_sso_init = SerializerMethodField()
|
url_sso_init = SerializerMethodField()
|
||||||
|
url_slo_post = SerializerMethodField()
|
||||||
|
url_slo_redirect = SerializerMethodField()
|
||||||
|
|
||||||
def get_url_download_metadata(self, instance: SAMLProvider) -> str:
|
def get_url_download_metadata(self, instance: SAMLProvider) -> str:
|
||||||
"""Get metadata download URL"""
|
"""Get metadata download URL"""
|
||||||
@ -99,6 +101,36 @@ class SAMLProviderSerializer(ProviderSerializer):
|
|||||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||||
return "-"
|
return "-"
|
||||||
|
|
||||||
|
def get_url_slo_post(self, instance: SAMLProvider) -> str:
|
||||||
|
"""Get SLO POST URL"""
|
||||||
|
if "request" not in self._context:
|
||||||
|
return ""
|
||||||
|
request: HttpRequest = self._context["request"]._request
|
||||||
|
try:
|
||||||
|
return request.build_absolute_uri(
|
||||||
|
reverse(
|
||||||
|
"authentik_providers_saml:slo-post",
|
||||||
|
kwargs={"application_slug": instance.application.slug},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
def get_url_slo_redirect(self, instance: SAMLProvider) -> str:
|
||||||
|
"""Get SLO redirect URL"""
|
||||||
|
if "request" not in self._context:
|
||||||
|
return ""
|
||||||
|
request: HttpRequest = self._context["request"]._request
|
||||||
|
try:
|
||||||
|
return request.build_absolute_uri(
|
||||||
|
reverse(
|
||||||
|
"authentik_providers_saml:slo-redirect",
|
||||||
|
kwargs={"application_slug": instance.application.slug},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||||
|
return "-"
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = SAMLProvider
|
model = SAMLProvider
|
||||||
@ -120,6 +152,8 @@ class SAMLProviderSerializer(ProviderSerializer):
|
|||||||
"url_sso_post",
|
"url_sso_post",
|
||||||
"url_sso_redirect",
|
"url_sso_redirect",
|
||||||
"url_sso_init",
|
"url_sso_init",
|
||||||
|
"url_slo_post",
|
||||||
|
"url_slo_redirect",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@ -175,7 +209,6 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@action(methods=["GET"], detail=True, permission_classes=[AllowAny])
|
@action(methods=["GET"], detail=True, permission_classes=[AllowAny])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def metadata(self, request: Request, pk: int) -> Response:
|
def metadata(self, request: Request, pk: int) -> Response:
|
||||||
"""Return metadata as XML string"""
|
"""Return metadata as XML string"""
|
||||||
# We don't use self.get_object() on purpose as this view is un-authenticated
|
# We don't use self.get_object() on purpose as this view is un-authenticated
|
||||||
@ -248,7 +281,6 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
@action(detail=True, methods=["GET"])
|
@action(detail=True, methods=["GET"])
|
||||||
# pylint: disable=invalid-name, unused-argument
|
|
||||||
def preview_user(self, request: Request, pk: int) -> Response:
|
def preview_user(self, request: Request, pk: int) -> Response:
|
||||||
"""Preview user data for provider"""
|
"""Preview user data for provider"""
|
||||||
provider: SAMLProvider = self.get_object()
|
provider: SAMLProvider = self.get_object()
|
||||||
|
@ -13,7 +13,7 @@ from authentik.events.models import Event, EventAction
|
|||||||
from authentik.events.signals import get_login_event
|
from authentik.events.signals import get_login_event
|
||||||
from authentik.lib.utils.time import timedelta_from_string
|
from authentik.lib.utils.time import timedelta_from_string
|
||||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||||
from authentik.providers.saml.processors.request_parser import AuthNRequest
|
from authentik.providers.saml.processors.authn_request_parser import AuthNRequest
|
||||||
from authentik.providers.saml.utils import get_random_id
|
from authentik.providers.saml.utils import get_random_id
|
||||||
from authentik.providers.saml.utils.time import get_time_string
|
from authentik.providers.saml.utils.time import get_time_string
|
||||||
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
from authentik.sources.ldap.auth import LDAP_DISTINGUISHED_NAME
|
||||||
|
@ -24,7 +24,6 @@ from authentik.sources.saml.processors.constants import (
|
|||||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGGER = get_logger()
|
|
||||||
ERROR_CANNOT_DECODE_REQUEST = "Cannot decode SAML request."
|
ERROR_CANNOT_DECODE_REQUEST = "Cannot decode SAML request."
|
||||||
ERROR_SIGNATURE_REQUIRED_BUT_ABSENT = (
|
ERROR_SIGNATURE_REQUIRED_BUT_ABSENT = (
|
||||||
"Verification Certificate configured, but request is not signed."
|
"Verification Certificate configured, but request is not signed."
|
||||||
@ -36,7 +35,6 @@ ERROR_FAILED_TO_VERIFY = "Failed to verify signature"
|
|||||||
class AuthNRequest:
|
class AuthNRequest:
|
||||||
"""AuthNRequest Dataclass"""
|
"""AuthNRequest Dataclass"""
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
|
||||||
id: Optional[str] = None
|
id: Optional[str] = None
|
||||||
|
|
||||||
relay_state: Optional[str] = None
|
relay_state: Optional[str] = None
|
||||||
@ -51,6 +49,7 @@ class AuthNRequestParser:
|
|||||||
|
|
||||||
def __init__(self, provider: SAMLProvider):
|
def __init__(self, provider: SAMLProvider):
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
|
self.logger = get_logger().bind(provider=self.provider)
|
||||||
|
|
||||||
def _parse_xml(self, decoded_xml: str | bytes, relay_state: Optional[str]) -> AuthNRequest:
|
def _parse_xml(self, decoded_xml: str | bytes, relay_state: Optional[str]) -> AuthNRequest:
|
||||||
root = ElementTree.fromstring(decoded_xml)
|
root = ElementTree.fromstring(decoded_xml)
|
||||||
@ -68,7 +67,7 @@ class AuthNRequestParser:
|
|||||||
f"ACS URL of {request_acs_url} doesn't match Provider "
|
f"ACS URL of {request_acs_url} doesn't match Provider "
|
||||||
f"ACS URL of {self.provider.acs_url}."
|
f"ACS URL of {self.provider.acs_url}."
|
||||||
)
|
)
|
||||||
LOGGER.warning(msg)
|
self.logger.warning(msg)
|
||||||
raise CannotHandleAssertion(msg)
|
raise CannotHandleAssertion(msg)
|
||||||
|
|
||||||
auth_n_request = AuthNRequest(id=root.attrib["ID"], relay_state=relay_state)
|
auth_n_request = AuthNRequest(id=root.attrib["ID"], relay_state=relay_state)
|
66
authentik/providers/saml/processors/logout_request_parser.py
Normal file
66
authentik/providers/saml/processors/logout_request_parser.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""LogoutRequest parser"""
|
||||||
|
from base64 import b64decode
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from defusedxml import ElementTree
|
||||||
|
|
||||||
|
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||||
|
from authentik.providers.saml.models import SAMLProvider
|
||||||
|
from authentik.providers.saml.processors.authn_request_parser import ERROR_CANNOT_DECODE_REQUEST
|
||||||
|
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||||
|
from authentik.sources.saml.processors.constants import NS_SAML_PROTOCOL
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LogoutRequest:
|
||||||
|
"""Logout Request"""
|
||||||
|
|
||||||
|
id: Optional[str] = None
|
||||||
|
|
||||||
|
issuer: Optional[str] = None
|
||||||
|
|
||||||
|
relay_state: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutRequestParser:
|
||||||
|
"""LogoutRequest Parser"""
|
||||||
|
|
||||||
|
provider: SAMLProvider
|
||||||
|
|
||||||
|
def __init__(self, provider: SAMLProvider):
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def _parse_xml(
|
||||||
|
self, decoded_xml: str | bytes, relay_state: Optional[str] = None
|
||||||
|
) -> LogoutRequest:
|
||||||
|
root = ElementTree.fromstring(decoded_xml)
|
||||||
|
request = LogoutRequest(
|
||||||
|
id=root.attrib["ID"],
|
||||||
|
)
|
||||||
|
issuers = root.findall(f"{{{NS_SAML_PROTOCOL}}}Issuer")
|
||||||
|
if len(issuers) > 0:
|
||||||
|
request.issuer = issuers[0].text
|
||||||
|
request.relay_state = relay_state
|
||||||
|
return request
|
||||||
|
|
||||||
|
def parse(self, saml_request: str, relay_state: Optional[str] = None) -> LogoutRequest:
|
||||||
|
"""Validate and parse raw request with enveloped signautre."""
|
||||||
|
try:
|
||||||
|
decoded_xml = b64decode(saml_request.encode())
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST)
|
||||||
|
return self._parse_xml(decoded_xml, relay_state)
|
||||||
|
|
||||||
|
def parse_detached(
|
||||||
|
self,
|
||||||
|
saml_request: str,
|
||||||
|
relay_state: Optional[str] = None,
|
||||||
|
) -> LogoutRequest:
|
||||||
|
"""Validate and parse raw request with detached signature"""
|
||||||
|
try:
|
||||||
|
decoded_xml = decode_base64_and_inflate(saml_request)
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST)
|
||||||
|
|
||||||
|
return self._parse_xml(decoded_xml, relay_state)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user