Compare commits
217 Commits
version/20
...
version/20
Author | SHA1 | Date | |
---|---|---|---|
ff2baf502b | |||
3b182ca223 | |||
8da8890a8e | |||
23023ec727 | |||
7d84a71a01 | |||
192001f193 | |||
63734682d2 | |||
a0cd2d55f8 | |||
a72c7adfc0 | |||
e88e02ec85 | |||
f7661c8bbd | |||
9add8479ca | |||
4c39e08dd4 | |||
44ce2ebece | |||
f5a8859d00 | |||
9ef0e8bc5f | |||
60eeafd111 | |||
6f3d6efa22 | |||
8d3275817b | |||
ca40d31dac | |||
438aac8879 | |||
2dfa6c2c82 | |||
c11435780d | |||
ee54328589 | |||
817d538b8f | |||
210775776f | |||
2a4ce75bc4 | |||
b26111fb42 | |||
e30103aa9f | |||
dc9203789e | |||
d70ce2776f | |||
ad7d65e903 | |||
67d54c5209 | |||
bb244b8338 | |||
fa04883ac1 | |||
6739ded5a9 | |||
9a7e5d934e | |||
6dc6d19d2d | |||
36cbc44ed6 | |||
0c591a50e3 | |||
7ee655a318 | |||
8447e9b9c2 | |||
09f92e5bad | |||
f9a419107a | |||
8f0572d11e | |||
7ebf793953 | |||
63783ee77b | |||
eba339ba27 | |||
0adb5a79f6 | |||
fa81adf254 | |||
558c7bba2a | |||
8cd1a42fb9 | |||
8cf0e78aa0 | |||
3f69a57013 | |||
f7f12cab10 | |||
cacaa378c8 | |||
33fe85eb96 | |||
a9744cbf48 | |||
b91d8a676c | |||
f19cd1c003 | |||
65341cecd0 | |||
c0cb891078 | |||
fc1c1a849a | |||
5a81ae956f | |||
0cac034512 | |||
5666995a15 | |||
8d3059e4f3 | |||
a90dc34494 | |||
2c6d82593e | |||
34bcc2df1a | |||
c00f2907ea | |||
b4d528a789 | |||
d9172cb296 | |||
bee36cde59 | |||
d4e7d9d64a | |||
7b0265207a | |||
7c076579fd | |||
7171706d7f | |||
9cd46ecbeb | |||
5f09ba675d | |||
630b926e2a | |||
9c6be60ad9 | |||
a0397fdcf4 | |||
59e13e8026 | |||
374b51e956 | |||
8faa1bf865 | |||
fc75867218 | |||
6d94c2c925 | |||
eb51dd1379 | |||
13a4559c37 | |||
4fcf7285d7 | |||
0ba9f25155 | |||
453c751c7f | |||
d1eaaef254 | |||
3eb466ff4b | |||
9f2529c886 | |||
fb25b28976 | |||
612163b82f | |||
3c43690a96 | |||
dd74565c7b | |||
fb69f67f47 | |||
18b48684eb | |||
098b0aef6e | |||
4ed8171130 | |||
335131affc | |||
bba17a8a67 | |||
082df0ec51 | |||
1883402b3d | |||
88a8b7d2fa | |||
987f03c4be | |||
1b3aacfa1d | |||
a03dde8a90 | |||
5f04a187ea | |||
2b68363452 | |||
3a994ab2a4 | |||
d7713357f4 | |||
e7c03fdb14 | |||
6105956847 | |||
89028f175a | |||
f121098957 | |||
4ff32af343 | |||
972868c15c | |||
0bc57f571b | |||
9de5b6f93e | |||
acf1ded1d4 | |||
a286f999e2 | |||
4b6c1da51d | |||
a81d5a3d41 | |||
4d17111233 | |||
64cb9812e0 | |||
ed037b2e3a | |||
d2be6a8e3a | |||
a9667eb0f4 | |||
7f3988f3c9 | |||
4c095a6f2a | |||
c10b5c3c8c | |||
9d920580a1 | |||
34ef4af799 | |||
5da47b69dd | |||
0e0dd2437b | |||
e42386b150 | |||
f21f81022e | |||
e73a468921 | |||
c0ac053380 | |||
4e670295d1 | |||
8d7d8d613c | |||
4d632a8679 | |||
ef219198d4 | |||
cc744dc581 | |||
47006fc9d2 | |||
ada53362d5 | |||
a03e48c5ce | |||
816b0c7d83 | |||
a6398f46da | |||
56babb2649 | |||
0edf4296c4 | |||
b8fdda50ec | |||
d25a051eae | |||
4a9b788703 | |||
d4ef321ac2 | |||
80c1dbdfbb | |||
b0af062d74 | |||
b4e75218f5 | |||
ab1840dd66 | |||
482491e93c | |||
2ca991ba3d | |||
b20c384f5a | |||
9ce8edbcd6 | |||
cb5b2148a3 | |||
d5702c6282 | |||
61a876b582 | |||
8c9748e4a0 | |||
6460245d5e | |||
b7979ad48e | |||
cbd95848e7 | |||
4704de937a | |||
394d8e99a4 | |||
a26f25ccd6 | |||
94257e0f50 | |||
b2a42a68a4 | |||
7895d59da3 | |||
b54c60d7af | |||
6bab3bf68e | |||
fdc09c658a | |||
a690a02f99 | |||
0e912fd647 | |||
27af330932 | |||
7187d28905 | |||
ca832b6090 | |||
53bd6bf06e | |||
813f271bdd | |||
63dc8fe7dc | |||
383f4e4dcf | |||
2896652fef | |||
cfe2648b62 | |||
8d49705c87 | |||
c99e6d8f2c | |||
0996bb500c | |||
3d4a45c93f | |||
0642af0b78 | |||
dce623dd7c | |||
646d174dd2 | |||
b8fdb82adc | |||
75d6cd1674 | |||
5c91658484 | |||
ebb44c992b | |||
233bb35ebe | |||
f60d0b9753 | |||
7e95c756b9 | |||
be26b92927 | |||
dd3ed1bfb9 | |||
6f56a61a64 | |||
2dee8034d3 | |||
d9d42020cc | |||
90298a2b6c | |||
7c17e7d52f | |||
fbb3ca98c1 |
@ -1,5 +1,5 @@
|
||||
[bumpversion]
|
||||
current_version = 2022.5.1
|
||||
current_version = 2022.6.2
|
||||
tag = True
|
||||
commit = True
|
||||
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
|
||||
|
2
.github/pull_request_template.md
vendored
2
.github/pull_request_template.md
vendored
@ -1,7 +1,7 @@
|
||||
<!--
|
||||
👋 Hello there! Welcome.
|
||||
|
||||
Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/master/CONTRIBUTING.md#how-can-i-contribute).
|
||||
Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/main/CONTRIBUTING.md#how-can-i-contribute).
|
||||
-->
|
||||
|
||||
# Details
|
||||
|
1
.github/stale.yml
vendored
1
.github/stale.yml
vendored
@ -10,6 +10,7 @@ exemptLabels:
|
||||
- enhancement
|
||||
- bug/confirmed
|
||||
- enhancement/confirmed
|
||||
- question
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
|
4
.github/workflows/ci-main.yml
vendored
4
.github/workflows/ci-main.yml
vendored
@ -3,14 +3,14 @@ name: authentik-ci-main
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- next
|
||||
- version-*
|
||||
paths-ignore:
|
||||
- website
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
env:
|
||||
POSTGRES_DB: authentik
|
||||
|
6
.github/workflows/ci-outpost.yml
vendored
6
.github/workflows/ci-outpost.yml
vendored
@ -3,12 +3,12 @@ name: authentik-ci-outpost
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- next
|
||||
- version-*
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint-golint:
|
||||
@ -110,7 +110,7 @@ jobs:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
|
12
.github/workflows/ci-web.yml
vendored
12
.github/workflows/ci-web.yml
vendored
@ -3,19 +3,19 @@ name: authentik-ci-web
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- next
|
||||
- version-*
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint-eslint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -47,7 +47,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -73,7 +73,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
|
6
.github/workflows/ci-website.yml
vendored
6
.github/workflows/ci-website.yml
vendored
@ -3,19 +3,19 @@ name: authentik-ci-website
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- next
|
||||
- version-*
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
|
||||
jobs:
|
||||
lint-prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
|
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@ -2,10 +2,10 @@ name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master, '*', next, version* ]
|
||||
branches: [ main, '*', next, version* ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ master ]
|
||||
branches: [ main ]
|
||||
schedule:
|
||||
- cron: '30 6 * * 5'
|
||||
|
||||
|
2
.github/workflows/ghcr-retention.yml
vendored
2
.github/workflows/ghcr-retention.yml
vendored
@ -19,4 +19,4 @@ jobs:
|
||||
org-name: goauthentik
|
||||
untagged-only: false
|
||||
token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
|
||||
skip-tags: gh-next,gh-master
|
||||
skip-tags: gh-next,gh-main
|
||||
|
12
.github/workflows/release-publish.yml
vendored
12
.github/workflows/release-publish.yml
vendored
@ -30,9 +30,9 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik:2022.5.1,
|
||||
beryju/authentik:2022.6.2,
|
||||
beryju/authentik:latest,
|
||||
ghcr.io/goauthentik/server:2022.5.1,
|
||||
ghcr.io/goauthentik/server:2022.6.2,
|
||||
ghcr.io/goauthentik/server:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
@ -69,9 +69,9 @@ jobs:
|
||||
with:
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: |
|
||||
beryju/authentik-${{ matrix.type }}:2022.5.1,
|
||||
beryju/authentik-${{ matrix.type }}:2022.6.2,
|
||||
beryju/authentik-${{ matrix.type }}:latest,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.5.1,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:2022.6.2,
|
||||
ghcr.io/goauthentik/${{ matrix.type }}:latest
|
||||
file: ${{ matrix.type }}.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@ -91,7 +91,7 @@ jobs:
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "^1.17"
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
with:
|
||||
node-version: '16'
|
||||
cache: 'npm'
|
||||
@ -152,7 +152,7 @@ jobs:
|
||||
SENTRY_PROJECT: authentik
|
||||
SENTRY_URL: https://sentry.beryju.org
|
||||
with:
|
||||
version: authentik@2022.5.1
|
||||
version: authentik@2022.6.2
|
||||
environment: beryjuorg-prod
|
||||
sourcemaps: './web/dist'
|
||||
url_prefix: '~/static/dist'
|
||||
|
2
.github/workflows/translation-compile.yml
vendored
2
.github/workflows/translation-compile.yml
vendored
@ -1,7 +1,7 @@
|
||||
name: authentik-backend-translate-compile
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- '/locale/'
|
||||
pull_request:
|
||||
|
4
.github/workflows/web-api-publish.yml
vendored
4
.github/workflows/web-api-publish.yml
vendored
@ -1,7 +1,7 @@
|
||||
name: authentik-web-api-publish
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'schema.yml'
|
||||
workflow_dispatch:
|
||||
@ -11,7 +11,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v3.2.0
|
||||
- uses: actions/setup-node@v3.3.0
|
||||
with:
|
||||
node-version: '16'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -1,5 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"akadmin",
|
||||
"asgi",
|
||||
"authentik",
|
||||
"authn",
|
||||
|
@ -60,7 +60,7 @@ representative at an online or offline event.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
hello@beryju.org.
|
||||
hello@goauthentik.io.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
|
@ -29,7 +29,7 @@ RUN pip install --no-cache-dir poetry && \
|
||||
poetry export -f requirements.txt --dev --output requirements-dev.txt
|
||||
|
||||
# Stage 4: Build go proxy
|
||||
FROM docker.io/golang:1.18.2-bullseye AS builder
|
||||
FROM docker.io/golang:1.18.3-bullseye AS builder
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
@ -64,8 +64,8 @@ RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev && \
|
||||
# Required for runtime
|
||||
apt-get install -y --no-install-recommends libxmlsec1-openssl libmaxminddb0 && \
|
||||
# Required for other things
|
||||
apt-get install -y --no-install-recommends runit && \
|
||||
# Required for bootstrap & healtcheck
|
||||
apt-get install -y --no-install-recommends curl runit && \
|
||||
pip install --no-cache-dir -r /requirements.txt && \
|
||||
apt-get remove --purge -y build-essential pkg-config libxmlsec1-dev && \
|
||||
apt-get autoremove --purge -y && \
|
||||
|
9
Makefile
9
Makefile
@ -55,7 +55,7 @@ i18n-extract-core:
|
||||
./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
|
||||
|
||||
gen-build:
|
||||
./manage.py spectacular --file schema.yml
|
||||
AUTHENTIK_DEBUG=true ./manage.py spectacular --file schema.yml
|
||||
|
||||
gen-clean:
|
||||
rm -rf web/api/src/
|
||||
@ -65,7 +65,7 @@ gen-client-web:
|
||||
docker run \
|
||||
--rm -v ${PWD}:/local \
|
||||
--user ${UID}:${GID} \
|
||||
openapitools/openapi-generator-cli:v6.0.0-beta generate \
|
||||
openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||
-i /local/schema.yml \
|
||||
-g typescript-fetch \
|
||||
-o /local/gen-ts-api \
|
||||
@ -83,7 +83,7 @@ gen-client-go:
|
||||
docker run \
|
||||
--rm -v ${PWD}:/local \
|
||||
--user ${UID}:${GID} \
|
||||
openapitools/openapi-generator-cli:v5.2.1 generate \
|
||||
openapitools/openapi-generator-cli:v6.0.0 generate \
|
||||
-i /local/schema.yml \
|
||||
-g go \
|
||||
-o /local/gen-go-api \
|
||||
@ -103,6 +103,9 @@ run:
|
||||
## Web
|
||||
#########################
|
||||
|
||||
web-build: web-install
|
||||
cd web && npm run build
|
||||
|
||||
web: web-lint-fix web-lint web-extract
|
||||
|
||||
web-install:
|
||||
|
@ -9,7 +9,7 @@
|
||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml)
|
||||
[](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml)
|
||||
[](https://codecov.io/gh/goauthentik/authentik)
|
||||
[](https://goauthentik.testspace.com/)
|
||||
[](https://goauthentik.testspace.com/)
|
||||

|
||||

|
||||
[](https://www.transifex.com/beryjuorg/authentik/)
|
||||
|
@ -6,9 +6,9 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ---------- | ------------------ |
|
||||
| 2022.3.x | :white_check_mark: |
|
||||
| 2022.4.x | :white_check_mark: |
|
||||
| 2022.5.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability, send an email to [security@beryju.org](mailto:security@beryju.org)
|
||||
To report a vulnerability, send an email to [security@goauthentik.io](mailto:security@goauthentik.io)
|
||||
|
@ -2,7 +2,7 @@
|
||||
from os import environ
|
||||
from typing import Optional
|
||||
|
||||
__version__ = "2022.5.1"
|
||||
__version__ = "2022.6.2"
|
||||
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
|
||||
|
||||
|
||||
|
@ -12,7 +12,4 @@ class AuthentikAdminConfig(AppConfig):
|
||||
verbose_name = "authentik Admin"
|
||||
|
||||
def ready(self):
|
||||
from authentik.admin.tasks import clear_update_notifications
|
||||
|
||||
clear_update_notifications.delay()
|
||||
import_module("authentik.admin.signals")
|
||||
|
@ -8,9 +8,6 @@ API Browser - {{ tenant.branding_title }}
|
||||
|
||||
{% block head %}
|
||||
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<script>
|
||||
function getCookie(name) {
|
||||
let cookieValue = "";
|
||||
@ -34,16 +31,58 @@ window.addEventListener('DOMContentLoaded', (event) => {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
img.logo {
|
||||
width: 100%;
|
||||
padding: 1rem 0.5rem 1.5rem 0.5rem;
|
||||
min-height: 48px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<rapi-doc
|
||||
spec-url="{{ path }}"
|
||||
heading-text="authentik"
|
||||
theme="dark"
|
||||
render-style="view"
|
||||
heading-text=""
|
||||
theme="light"
|
||||
render-style="read"
|
||||
default-schema-tab="schema"
|
||||
primary-color="#fd4b2d"
|
||||
nav-bg-color="#212427"
|
||||
bg-color="#000000"
|
||||
text-color="#000000"
|
||||
nav-text-color="#ffffff"
|
||||
nav-hover-bg-color="#3c3f42"
|
||||
nav-accent-color="#4f5255"
|
||||
nav-hover-text-color="#ffffff"
|
||||
use-path-in-nav-bar="true"
|
||||
nav-item-spacing="relaxed"
|
||||
allow-server-selection="false"
|
||||
show-header="false"
|
||||
allow-spec-url-load="false"
|
||||
allow-spec-file-load="false">
|
||||
<div slot="logo">
|
||||
<img src="{% static 'dist/assets/icons/icon.png' %}" style="width:50px; height:50px" />
|
||||
<div slot="nav-logo">
|
||||
<img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
|
||||
</div>
|
||||
</rapi-doc>
|
||||
<script>
|
||||
const rapidoc = document.querySelector("rapi-doc");
|
||||
const matcher = window.matchMedia("(prefers-color-scheme: light)");
|
||||
const changer = (ev) => {
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
let bg, text = "";
|
||||
if (matcher.matches) {
|
||||
bg = style.getPropertyValue('--pf-global--BackgroundColor--light-300');
|
||||
text = style.getPropertyValue('--pf-global--Color--300');
|
||||
} else {
|
||||
bg = style.getPropertyValue('--ak-dark-background');
|
||||
text = style.getPropertyValue('--ak-dark-foreground');
|
||||
}
|
||||
rapidoc.attributes.getNamedItem("bg-color").value = bg.trim();
|
||||
rapidoc.attributes.getNamedItem("text-color").value = text.trim();
|
||||
rapidoc.requestUpdate();
|
||||
};
|
||||
matcher.addEventListener("change", changer);
|
||||
window.addEventListener("load", changer);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
29
authentik/api/tests/test_viewsets.py
Normal file
29
authentik/api/tests/test_viewsets.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""authentik API Modelviewset tests"""
|
||||
from typing import Callable
|
||||
|
||||
from django.test import TestCase
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
|
||||
from authentik.api.v3.urls import router
|
||||
|
||||
|
||||
class TestModelViewSets(TestCase):
|
||||
"""Test Viewset"""
|
||||
|
||||
|
||||
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
|
||||
"""Test Viewset"""
|
||||
|
||||
def tester(self: TestModelViewSets):
|
||||
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
|
||||
filterset_class = getattr(test_viewset, "filterset_class", None)
|
||||
if not filterset_class:
|
||||
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
|
||||
|
||||
return tester
|
||||
|
||||
|
||||
for _, viewset, _ in router.registry:
|
||||
if not issubclass(viewset, (ModelViewSet, ReadOnlyModelViewSet)):
|
||||
continue
|
||||
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))
|
@ -63,6 +63,7 @@ class ApplicationSerializer(ModelSerializer):
|
||||
"provider",
|
||||
"provider_obj",
|
||||
"launch_url",
|
||||
"open_in_new_tab",
|
||||
"meta_launch_url",
|
||||
"meta_icon",
|
||||
"meta_description",
|
||||
@ -89,6 +90,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
|
||||
"group",
|
||||
]
|
||||
lookup_field = "slug"
|
||||
filterset_fields = ["name", "slug"]
|
||||
ordering = ["name"]
|
||||
|
||||
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
|
||||
|
@ -8,7 +8,7 @@ from rest_framework.decorators import action
|
||||
from rest_framework.filters import OrderingFilter, SearchFilter
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ModelSerializer, SerializerMethodField
|
||||
from rest_framework.serializers import ModelSerializer, ReadOnlyField, SerializerMethodField
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@ -26,6 +26,7 @@ LOGGER = get_logger()
|
||||
class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"""Source Serializer"""
|
||||
|
||||
managed = ReadOnlyField()
|
||||
component = SerializerMethodField()
|
||||
|
||||
def get_component(self, obj: Source) -> str:
|
||||
@ -51,6 +52,7 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
|
||||
"meta_model_name",
|
||||
"policy_engine_mode",
|
||||
"user_matching_mode",
|
||||
"managed",
|
||||
]
|
||||
|
||||
|
||||
@ -66,6 +68,8 @@ class SourceViewSet(
|
||||
queryset = Source.objects.none()
|
||||
serializer_class = SourceSerializer
|
||||
lookup_field = "slug"
|
||||
search_fields = ["slug", "name"]
|
||||
filterset_fields = ["slug", "name", "managed"]
|
||||
|
||||
def get_queryset(self): # pragma: no cover
|
||||
return Source.objects.select_subclasses()
|
||||
|
@ -43,7 +43,10 @@ from authentik.api.decorators import permission_required
|
||||
from authentik.core.api.groups import GroupSerializer
|
||||
from authentik.core.api.used_by import UsedByMixin
|
||||
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict
|
||||
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
|
||||
from authentik.core.middleware import (
|
||||
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_KEY_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import (
|
||||
USER_ATTRIBUTE_SA,
|
||||
USER_ATTRIBUTE_TOKEN_EXPIRING,
|
||||
@ -72,6 +75,7 @@ class UserSerializer(ModelSerializer):
|
||||
)
|
||||
groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
|
||||
uid = CharField(read_only=True)
|
||||
username = CharField(max_length=150)
|
||||
|
||||
class Meta:
|
||||
|
||||
@ -335,11 +339,12 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer = SessionUserSerializer(
|
||||
data={"user": UserSelfSerializer(instance=request.user, context=context).data}
|
||||
)
|
||||
if SESSION_IMPERSONATE_USER in request._request.session:
|
||||
if SESSION_KEY_IMPERSONATE_USER in request._request.session:
|
||||
serializer.initial_data["original"] = UserSelfSerializer(
|
||||
instance=request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER],
|
||||
instance=request._request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER],
|
||||
context=context,
|
||||
).data
|
||||
self.request.session.save()
|
||||
return Response(serializer.initial_data)
|
||||
|
||||
@permission_required("authentik_core.reset_user_password")
|
||||
@ -366,7 +371,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
|
||||
except (ValidationError, IntegrityError) as exc:
|
||||
LOGGER.debug("Failed to set password", exc=exc)
|
||||
return Response(status=400)
|
||||
if user.pk == request.user.pk and SESSION_IMPERSONATE_USER not in self.request.session:
|
||||
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session:
|
||||
LOGGER.debug("Updating session hash after password change")
|
||||
update_session_auth_hash(self.request, user)
|
||||
return Response(status=204)
|
||||
|
@ -2,10 +2,6 @@
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db import ProgrammingError
|
||||
|
||||
from authentik.core.signals import GAUGE_MODELS
|
||||
from authentik.lib.utils.reflection import get_apps
|
||||
|
||||
|
||||
class AuthentikCoreConfig(AppConfig):
|
||||
@ -19,12 +15,3 @@ class AuthentikCoreConfig(AppConfig):
|
||||
def ready(self):
|
||||
import_module("authentik.core.signals")
|
||||
import_module("authentik.core.managed")
|
||||
try:
|
||||
for app in get_apps():
|
||||
for model in app.get_models():
|
||||
GAUGE_MODELS.labels(
|
||||
model_name=model._meta.model_name,
|
||||
app=model._meta.app_label,
|
||||
).set(model.objects.count())
|
||||
except ProgrammingError:
|
||||
pass
|
||||
|
@ -12,5 +12,6 @@ class CoreManager(ObjectManager):
|
||||
Source,
|
||||
"goauthentik.io/sources/inbuilt",
|
||||
name="authentik Built-in",
|
||||
slug="authentik-built-in",
|
||||
),
|
||||
]
|
||||
|
@ -7,8 +7,8 @@ from uuid import uuid4
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from sentry_sdk.api import set_tag
|
||||
|
||||
SESSION_IMPERSONATE_USER = "authentik_impersonate_user"
|
||||
SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user"
|
||||
SESSION_KEY_IMPERSONATE_USER = "authentik/impersonate/user"
|
||||
SESSION_KEY_IMPERSONATE_ORIGINAL_USER = "authentik/impersonate/original_user"
|
||||
LOCAL = local()
|
||||
RESPONSE_HEADER_ID = "X-authentik-id"
|
||||
KEY_AUTH_VIA = "auth_via"
|
||||
@ -25,10 +25,10 @@ class ImpersonateMiddleware:
|
||||
|
||||
def __call__(self, request: HttpRequest) -> HttpResponse:
|
||||
# No permission checks are done here, they need to be checked before
|
||||
# SESSION_IMPERSONATE_USER is set.
|
||||
# SESSION_KEY_IMPERSONATE_USER is set.
|
||||
|
||||
if SESSION_IMPERSONATE_USER in request.session:
|
||||
request.user = request.session[SESSION_IMPERSONATE_USER]
|
||||
if SESSION_KEY_IMPERSONATE_USER in request.session:
|
||||
request.user = request.session[SESSION_KEY_IMPERSONATE_USER]
|
||||
# Ensure that the user is active, otherwise nothing will work
|
||||
request.user.is_active = True
|
||||
|
||||
|
@ -20,8 +20,15 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||
username="akadmin", email="root@localhost", name="authentik Default Admin"
|
||||
)
|
||||
if "TF_BUILD" in environ or "AK_ADMIN_PASS" in environ or settings.TEST:
|
||||
akadmin.set_password(environ.get("AK_ADMIN_PASS", "akadmin"), signal=False) # noqa # nosec
|
||||
password = None
|
||||
if "TF_BUILD" in environ or settings.TEST:
|
||||
password = "akadmin" # noqa # nosec
|
||||
if "AK_ADMIN_PASS" in environ:
|
||||
password = environ["AK_ADMIN_PASS"]
|
||||
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
|
||||
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
|
||||
if password:
|
||||
akadmin.set_password(password, signal=False)
|
||||
else:
|
||||
akadmin.set_unusable_password()
|
||||
akadmin.save()
|
||||
|
@ -16,8 +16,15 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
akadmin, _ = User.objects.using(db_alias).get_or_create(
|
||||
username="akadmin", email="root@localhost", name="authentik Default Admin"
|
||||
)
|
||||
if "TF_BUILD" in environ or "AK_ADMIN_PASS" in environ or settings.TEST:
|
||||
akadmin.set_password(environ.get("AK_ADMIN_PASS", "akadmin"), signal=False) # noqa # nosec
|
||||
password = None
|
||||
if "TF_BUILD" in environ or settings.TEST:
|
||||
password = "akadmin" # noqa # nosec
|
||||
if "AK_ADMIN_PASS" in environ:
|
||||
password = environ["AK_ADMIN_PASS"]
|
||||
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
|
||||
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
|
||||
if password:
|
||||
akadmin.set_password(password, signal=False)
|
||||
else:
|
||||
akadmin.set_unusable_password()
|
||||
akadmin.save()
|
||||
|
@ -44,14 +44,19 @@ def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
|
||||
akadmin = User.objects.using(db_alias).filter(username="akadmin")
|
||||
if not akadmin.exists():
|
||||
return
|
||||
if "AK_ADMIN_TOKEN" not in environ:
|
||||
key = None
|
||||
if "AK_ADMIN_TOKEN" in environ:
|
||||
key = environ["AK_ADMIN_TOKEN"]
|
||||
if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ:
|
||||
key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"]
|
||||
if not key:
|
||||
return
|
||||
Token.objects.using(db_alias).create(
|
||||
identifier="authentik-boostrap-token",
|
||||
identifier="authentik-bootstrap-token",
|
||||
user=akadmin.first(),
|
||||
intent=TokenIntents.INTENT_API,
|
||||
expiring=False,
|
||||
key=environ["AK_ADMIN_TOKEN"],
|
||||
key=key,
|
||||
)
|
||||
|
||||
|
||||
|
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.0.5 on 2022-06-04 06:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0019_application_group"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="application",
|
||||
name="open_in_new_tab",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Open launch URL in a new browser tab or window."
|
||||
),
|
||||
),
|
||||
]
|
@ -15,14 +15,19 @@ def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEdito
|
||||
akadmin = User.objects.using(db_alias).filter(username="akadmin")
|
||||
if not akadmin.exists():
|
||||
return
|
||||
if "AK_ADMIN_TOKEN" not in environ:
|
||||
key = None
|
||||
if "AK_ADMIN_TOKEN" in environ:
|
||||
key = environ["AK_ADMIN_TOKEN"]
|
||||
if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ:
|
||||
key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"]
|
||||
if not key:
|
||||
return
|
||||
Token.objects.using(db_alias).create(
|
||||
identifier="authentik-boostrap-token",
|
||||
identifier="authentik-bootstrap-token",
|
||||
user=akadmin.first(),
|
||||
intent=TokenIntents.INTENT_API,
|
||||
expiring=False,
|
||||
key=environ["AK_ADMIN_TOKEN"],
|
||||
key=key,
|
||||
)
|
||||
|
||||
|
||||
|
@ -192,7 +192,7 @@ class User(GuardianUserMixin, AbstractUser):
|
||||
|
||||
@property
|
||||
def uid(self) -> str:
|
||||
"""Generate a globall unique UID, based on the user ID and the hashed secret key"""
|
||||
"""Generate a globally unique UID, based on the user ID and the hashed secret key"""
|
||||
return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
|
||||
|
||||
@property
|
||||
@ -278,6 +278,11 @@ class Application(PolicyBindingModel):
|
||||
meta_launch_url = models.TextField(
|
||||
default="", blank=True, validators=[DomainlessURLValidator()]
|
||||
)
|
||||
|
||||
open_in_new_tab = models.BooleanField(
|
||||
default=False, help_text=_("Open launch URL in a new browser tab or window.")
|
||||
)
|
||||
|
||||
# For template applications, this can be set to /static/authentik/applications/*
|
||||
meta_icon = models.FileField(
|
||||
upload_to="application-icons/",
|
||||
|
@ -1,7 +1,6 @@
|
||||
"""authentik core signals"""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||
from django.contrib.sessions.backends.cache import KEY_PREFIX
|
||||
from django.core.cache import cache
|
||||
@ -10,30 +9,16 @@ from django.db.models import Model
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http.request import HttpRequest
|
||||
from prometheus_client import Gauge
|
||||
|
||||
from authentik.root.monitoring import monitoring_set
|
||||
|
||||
# Arguments: user: User, password: str
|
||||
password_changed = Signal()
|
||||
|
||||
GAUGE_MODELS = Gauge("authentik_models", "Count of various objects", ["model_name", "app"])
|
||||
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
|
||||
login_failed = Signal()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
|
||||
|
||||
@receiver(monitoring_set)
|
||||
# pylint: disable=unused-argument
|
||||
def monitoring_set_models(sender, **kwargs):
|
||||
"""set models gauges"""
|
||||
for model in apps.get_models():
|
||||
GAUGE_MODELS.labels(
|
||||
model_name=model._meta.model_name,
|
||||
app=model._meta.app_label,
|
||||
).set(model.objects.count())
|
||||
|
||||
|
||||
@receiver(post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def post_save_application(sender: type[Model], instance, created: bool, **_):
|
||||
|
@ -5,6 +5,7 @@
|
||||
|
||||
{% block head_before %}
|
||||
{{ block.super }}
|
||||
<link rel="prefetch" href="{{ flow.background_url }}" />
|
||||
{% if flow.compatibility_mode and not inspector %}
|
||||
<script>ShadyDOM = { force: !navigator.webdriver };</script>
|
||||
{% endif %}
|
||||
@ -19,7 +20,7 @@ window.authentik.flow = {
|
||||
{% block head %}
|
||||
<script src="{% static 'dist/flow/FlowInterface.js' %}" type="module"></script>
|
||||
<style>
|
||||
.pf-c-background-image::before {
|
||||
:root {
|
||||
--ak-flow-background: url("{{ flow.background_url }}");
|
||||
}
|
||||
</style>
|
||||
|
@ -4,13 +4,19 @@
|
||||
{% load i18n %}
|
||||
|
||||
{% block head_before %}
|
||||
<link rel="prefetch" href="/static/dist/assets/images/flow_background.jpg" />
|
||||
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.pf-c-background-image::before {
|
||||
:root {
|
||||
--ak-flow-background: url("/static/dist/assets/images/flow_background.jpg");
|
||||
--pf-c-background-image--BackgroundImage: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage--sm-2x: var(--ak-flow-background);
|
||||
--pf-c-background-image--BackgroundImage--lg: var(--ak-flow-background);
|
||||
}
|
||||
/* Form with user */
|
||||
.form-control-static {
|
||||
|
@ -29,6 +29,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
name="allowed",
|
||||
slug="allowed",
|
||||
meta_launch_url="https://goauthentik.io/%(username)s",
|
||||
open_in_new_tab=True,
|
||||
provider=self.provider,
|
||||
)
|
||||
self.denied = Application.objects.create(name="denied", slug="denied")
|
||||
@ -100,6 +101,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
},
|
||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||
"open_in_new_tab": True,
|
||||
"meta_icon": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
@ -148,6 +150,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
},
|
||||
"launch_url": f"https://goauthentik.io/{self.user.username}",
|
||||
"meta_launch_url": "https://goauthentik.io/%(username)s",
|
||||
"open_in_new_tab": True,
|
||||
"meta_icon": None,
|
||||
"meta_description": "",
|
||||
"meta_publisher": "",
|
||||
@ -158,6 +161,7 @@ class TestApplicationsAPI(APITestCase):
|
||||
"meta_description": "",
|
||||
"meta_icon": None,
|
||||
"meta_launch_url": "",
|
||||
"open_in_new_tab": False,
|
||||
"meta_publisher": "",
|
||||
"group": "",
|
||||
"name": "denied",
|
||||
|
@ -5,7 +5,10 @@ from django.shortcuts import get_object_or_404, redirect
|
||||
from django.views import View
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
|
||||
from authentik.core.middleware import (
|
||||
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_KEY_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.lib.config import CONFIG
|
||||
@ -27,8 +30,8 @@ class ImpersonateInitView(View):
|
||||
|
||||
user_to_be = get_object_or_404(User, pk=user_id)
|
||||
|
||||
request.session[SESSION_IMPERSONATE_ORIGINAL_USER] = request.user
|
||||
request.session[SESSION_IMPERSONATE_USER] = user_to_be
|
||||
request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER] = request.user
|
||||
request.session[SESSION_KEY_IMPERSONATE_USER] = user_to_be
|
||||
|
||||
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
|
||||
|
||||
@ -41,16 +44,16 @@ class ImpersonateEndView(View):
|
||||
def get(self, request: HttpRequest) -> HttpResponse:
|
||||
"""End Impersonation handler"""
|
||||
if (
|
||||
SESSION_IMPERSONATE_USER not in request.session
|
||||
or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session
|
||||
SESSION_KEY_IMPERSONATE_USER not in request.session
|
||||
or SESSION_KEY_IMPERSONATE_ORIGINAL_USER not in request.session
|
||||
):
|
||||
LOGGER.debug("Can't end impersonation", user=request.user)
|
||||
return redirect("authentik_core:if-user")
|
||||
|
||||
original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
||||
original_user = request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||
|
||||
del request.session[SESSION_IMPERSONATE_USER]
|
||||
del request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
|
||||
del request.session[SESSION_KEY_IMPERSONATE_USER]
|
||||
del request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER]
|
||||
|
||||
Event.new(EventAction.IMPERSONATION_ENDED).from_http(request, original_user)
|
||||
|
||||
|
@ -2,6 +2,8 @@
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
from authentik.lib.generators import generate_id
|
||||
|
||||
|
||||
def create_self_signed(apps, schema_editor):
|
||||
CertificateKeyPair = apps.get_model("authentik_crypto", "CertificateKeyPair")
|
||||
@ -9,7 +11,7 @@ def create_self_signed(apps, schema_editor):
|
||||
from authentik.crypto.builder import CertificateBuilder
|
||||
|
||||
builder = CertificateBuilder()
|
||||
builder.build()
|
||||
builder.build(subject_alt_names=[f"{generate_id()}.self-signed.goauthentik.io"])
|
||||
CertificateKeyPair.objects.using(db_alias).create(
|
||||
name="authentik Self-signed Certificate",
|
||||
certificate_data=builder.certificate,
|
||||
|
@ -26,3 +26,4 @@ class NotificationWebhookMappingViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = NotificationWebhookMappingSerializer
|
||||
filterset_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -32,3 +32,4 @@ class NotificationRuleViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = NotificationRuleSerializer
|
||||
filterset_fields = ["name", "severity", "group__name"]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name", "group__name"]
|
||||
|
@ -68,6 +68,7 @@ class NotificationTransportViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = NotificationTransport.objects.all()
|
||||
serializer_class = NotificationTransportSerializer
|
||||
filterset_fields = ["name", "mode", "webhook_url", "send_once"]
|
||||
search_fields = ["name", "mode", "webhook_url"]
|
||||
ordering = ["name"]
|
||||
|
||||
@permission_required("authentik_events.change_notificationtransport")
|
||||
|
@ -76,11 +76,8 @@ class GeoIPReader:
|
||||
except (GeoIP2Error, ValueError):
|
||||
return None
|
||||
|
||||
def city_dict(self, ip_address: str) -> Optional[GeoIPDict]:
|
||||
"""Wrapper for self.city that returns a dict"""
|
||||
city = self.city(ip_address)
|
||||
if not city:
|
||||
return None
|
||||
def city_to_dict(self, city: City) -> GeoIPDict:
|
||||
"""Convert City to dict"""
|
||||
city_dict: GeoIPDict = {
|
||||
"continent": city.continent.code,
|
||||
"country": city.country.iso_code,
|
||||
@ -92,5 +89,12 @@ class GeoIPReader:
|
||||
city_dict["city"] = city.city.name
|
||||
return city_dict
|
||||
|
||||
def city_dict(self, ip_address: str) -> Optional[GeoIPDict]:
|
||||
"""Wrapper for self.city that returns a dict"""
|
||||
city = self.city(ip_address)
|
||||
if not city:
|
||||
return None
|
||||
return self.city_to_dict(city)
|
||||
|
||||
|
||||
GEOIP_READER = GeoIPReader()
|
||||
|
@ -3,6 +3,7 @@ from functools import partial
|
||||
from typing import Callable
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.db.models import Model
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
@ -24,11 +25,12 @@ IGNORED_MODELS = [
|
||||
UserObjectPermission,
|
||||
AuthenticatedSession,
|
||||
StaticToken,
|
||||
Session,
|
||||
]
|
||||
if settings.DEBUG:
|
||||
from silk.models import Request, Response
|
||||
from silk.models import Request, Response, SQLQuery
|
||||
|
||||
IGNORED_MODELS += [Request, Response]
|
||||
IGNORED_MODELS += [Request, Response, SQLQuery]
|
||||
IGNORED_MODELS = tuple(IGNORED_MODELS)
|
||||
|
||||
|
||||
|
@ -383,6 +383,7 @@ class Migration(migrations.Migration):
|
||||
models.ManyToManyField(
|
||||
help_text="Select which transports should be used to notify the user. If none are selected, the notification will only be shown in the authentik UI.",
|
||||
to="authentik_events.NotificationTransport",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -0,0 +1,50 @@
|
||||
# Generated by Django 4.0.4 on 2022-05-30 18:08
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
from authentik.events.models import TransportMode
|
||||
|
||||
|
||||
def notify_local_transport(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
db_alias = schema_editor.connection.alias
|
||||
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
|
||||
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
|
||||
|
||||
local_transport, _ = NotificationTransport.objects.using(db_alias).update_or_create(
|
||||
name="default-local-transport",
|
||||
defaults={"mode": TransportMode.LOCAL},
|
||||
)
|
||||
|
||||
for trigger in NotificationRule.objects.using(db_alias).filter(
|
||||
name__in=[
|
||||
"default-notify-configuration-error",
|
||||
"default-notify-exception",
|
||||
"default-notify-update",
|
||||
]
|
||||
):
|
||||
trigger.transports.add(local_transport)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_events", "0001_squashed_0019_alter_notificationtransport_webhook_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notificationtransport",
|
||||
name="mode",
|
||||
field=models.TextField(
|
||||
choices=[
|
||||
("local", "authentik inbuilt notifications"),
|
||||
("webhook", "Generic Webhook"),
|
||||
("webhook_slack", "Slack Webhook (Slack/Discord)"),
|
||||
("email", "Email"),
|
||||
],
|
||||
default="local",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(notify_local_transport),
|
||||
]
|
@ -23,7 +23,10 @@ from requests import RequestException
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
|
||||
from authentik.core.middleware import (
|
||||
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
|
||||
SESSION_KEY_IMPERSONATE_USER,
|
||||
)
|
||||
from authentik.core.models import ExpiringModel, Group, PropertyMapping, User
|
||||
from authentik.events.geo import GEOIP_READER
|
||||
from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict
|
||||
@ -233,15 +236,15 @@ class Event(ExpiringModel):
|
||||
if hasattr(request, "user"):
|
||||
original_user = None
|
||||
if hasattr(request, "session"):
|
||||
original_user = request.session.get(SESSION_IMPERSONATE_ORIGINAL_USER, None)
|
||||
original_user = request.session.get(SESSION_KEY_IMPERSONATE_ORIGINAL_USER, None)
|
||||
self.user = get_user(request.user, original_user)
|
||||
if user:
|
||||
self.user = get_user(user)
|
||||
# Check if we're currently impersonating, and add that user
|
||||
if hasattr(request, "session"):
|
||||
if SESSION_IMPERSONATE_ORIGINAL_USER in request.session:
|
||||
self.user = get_user(request.session[SESSION_IMPERSONATE_ORIGINAL_USER])
|
||||
self.user["on_behalf_of"] = get_user(request.session[SESSION_IMPERSONATE_USER])
|
||||
if SESSION_KEY_IMPERSONATE_ORIGINAL_USER in request.session:
|
||||
self.user = get_user(request.session[SESSION_KEY_IMPERSONATE_ORIGINAL_USER])
|
||||
self.user["on_behalf_of"] = get_user(request.session[SESSION_KEY_IMPERSONATE_USER])
|
||||
# User 255.255.255.255 as fallback if IP cannot be determined
|
||||
self.client_ip = get_client_ip(request)
|
||||
# Apply GeoIP Data, when enabled
|
||||
@ -289,6 +292,7 @@ class Event(ExpiringModel):
|
||||
class TransportMode(models.TextChoices):
|
||||
"""Modes that a notification transport can send a notification"""
|
||||
|
||||
LOCAL = "local", _("authentik inbuilt notifications")
|
||||
WEBHOOK = "webhook", _("Generic Webhook")
|
||||
WEBHOOK_SLACK = "webhook_slack", _("Slack Webhook (Slack/Discord)")
|
||||
EMAIL = "email", _("Email")
|
||||
@ -300,7 +304,7 @@ class NotificationTransport(models.Model):
|
||||
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
|
||||
|
||||
name = models.TextField(unique=True)
|
||||
mode = models.TextField(choices=TransportMode.choices)
|
||||
mode = models.TextField(choices=TransportMode.choices, default=TransportMode.LOCAL)
|
||||
|
||||
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()])
|
||||
webhook_mapping = models.ForeignKey(
|
||||
@ -315,6 +319,8 @@ class NotificationTransport(models.Model):
|
||||
|
||||
def send(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to user, called from async task"""
|
||||
if self.mode == TransportMode.LOCAL:
|
||||
return self.send_local(notification)
|
||||
if self.mode == TransportMode.WEBHOOK:
|
||||
return self.send_webhook(notification)
|
||||
if self.mode == TransportMode.WEBHOOK_SLACK:
|
||||
@ -323,6 +329,17 @@ class NotificationTransport(models.Model):
|
||||
return self.send_email(notification)
|
||||
raise ValueError(f"Invalid mode {self.mode} set")
|
||||
|
||||
def send_local(self, notification: "Notification") -> list[str]:
|
||||
"""Local notification delivery"""
|
||||
if self.webhook_mapping:
|
||||
self.webhook_mapping.evaluate(
|
||||
user=notification.user,
|
||||
request=None,
|
||||
notification=notification,
|
||||
)
|
||||
notification.save()
|
||||
return []
|
||||
|
||||
def send_webhook(self, notification: "Notification") -> list[str]:
|
||||
"""Send notification to generic webhook"""
|
||||
default_body = {
|
||||
@ -481,6 +498,7 @@ class NotificationRule(PolicyBindingModel):
|
||||
"selected, the notification will only be shown in the authentik UI."
|
||||
)
|
||||
),
|
||||
blank=True,
|
||||
)
|
||||
severity = models.TextField(
|
||||
choices=NotificationSeverity.choices,
|
||||
|
@ -2,15 +2,16 @@
|
||||
from threading import Thread
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out, user_login_failed
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.core.signals import password_changed
|
||||
from authentik.core.signals import login_failed, password_changed
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.events.tasks import event_notification_handler, gdpr_cleanup
|
||||
from authentik.flows.models import Stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_SOURCE, FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.stages.invitation.models import Invitation
|
||||
@ -77,11 +78,18 @@ def on_user_write(sender, request: HttpRequest, user: User, data: dict[str, Any]
|
||||
thread.run()
|
||||
|
||||
|
||||
@receiver(user_login_failed)
|
||||
@receiver(login_failed)
|
||||
# pylint: disable=unused-argument
|
||||
def on_user_login_failed(sender, credentials: dict[str, str], request: HttpRequest, **_):
|
||||
"""Failed Login"""
|
||||
thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials)
|
||||
def on_login_failed(
|
||||
signal,
|
||||
sender,
|
||||
credentials: dict[str, str],
|
||||
request: HttpRequest,
|
||||
stage: Optional[Stage] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Failed Login, authentik custom event"""
|
||||
thread = EventNewThread(EventAction.LOGIN_FAILED, request, **credentials, stage=stage, **kwargs)
|
||||
thread.run()
|
||||
|
||||
|
||||
|
@ -1,8 +1,11 @@
|
||||
"""Event notification tasks"""
|
||||
from typing import Optional
|
||||
|
||||
from django.db.models.query_utils import Q
|
||||
from guardian.shortcuts import get_anonymous_user
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.exceptions import PropertyMappingExpressionException
|
||||
from authentik.core.models import User
|
||||
from authentik.events.models import (
|
||||
Event,
|
||||
@ -39,10 +42,9 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
|
||||
LOGGER.warning("event doesn't exist yet or anymore", event_uuid=event_uuid)
|
||||
return
|
||||
event: Event = events.first()
|
||||
triggers: NotificationRule = NotificationRule.objects.filter(name=trigger_name)
|
||||
if not triggers.exists():
|
||||
trigger: Optional[NotificationRule] = NotificationRule.objects.filter(name=trigger_name).first()
|
||||
if not trigger:
|
||||
return
|
||||
trigger = triggers.first()
|
||||
|
||||
if "policy_uuid" in event.context:
|
||||
policy_uuid = event.context["policy_uuid"]
|
||||
@ -81,11 +83,14 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
|
||||
for transport in trigger.transports.all():
|
||||
for user in trigger.group.users.all():
|
||||
LOGGER.debug("created notification")
|
||||
notification = Notification.objects.create(
|
||||
severity=trigger.severity, body=event.summary, event=event, user=user
|
||||
)
|
||||
notification_transport.apply_async(
|
||||
args=[notification.pk, transport.pk], queue="authentik_events"
|
||||
args=[
|
||||
transport.pk,
|
||||
str(event.pk),
|
||||
user.pk,
|
||||
str(trigger.pk),
|
||||
],
|
||||
queue="authentik_events",
|
||||
)
|
||||
if transport.send_once:
|
||||
break
|
||||
@ -97,19 +102,30 @@ def event_trigger_handler(event_uuid: str, trigger_name: str):
|
||||
retry_backoff=True,
|
||||
base=MonitoredTask,
|
||||
)
|
||||
def notification_transport(self: MonitoredTask, notification_pk: int, transport_pk: int):
|
||||
def notification_transport(
|
||||
self: MonitoredTask, transport_pk: int, event_pk: str, user_pk: int, trigger_pk: str
|
||||
):
|
||||
"""Send notification over specified transport"""
|
||||
self.save_on_success = False
|
||||
try:
|
||||
notification: Notification = Notification.objects.filter(pk=notification_pk).first()
|
||||
if not notification:
|
||||
event = Event.objects.filter(pk=event_pk).first()
|
||||
if not event:
|
||||
return
|
||||
user = User.objects.filter(pk=user_pk).first()
|
||||
if not user:
|
||||
return
|
||||
trigger = NotificationRule.objects.filter(pk=trigger_pk).first()
|
||||
if not trigger:
|
||||
return
|
||||
notification = Notification(
|
||||
severity=trigger.severity, body=event.summary, event=event, user=user
|
||||
)
|
||||
transport = NotificationTransport.objects.filter(pk=transport_pk).first()
|
||||
if not transport:
|
||||
return
|
||||
transport.send(notification)
|
||||
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
|
||||
except NotificationTransportError as exc:
|
||||
except (NotificationTransportError, PropertyMappingExpressionException) as exc:
|
||||
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
|
||||
raise exc
|
||||
|
||||
|
@ -11,7 +11,10 @@ from authentik.events.models import (
|
||||
Notification,
|
||||
NotificationRule,
|
||||
NotificationTransport,
|
||||
NotificationWebhookMapping,
|
||||
TransportMode,
|
||||
)
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.policies.event_matcher.models import EventMatcherPolicy
|
||||
from authentik.policies.exceptions import PolicyException
|
||||
from authentik.policies.models import PolicyBinding
|
||||
@ -105,4 +108,26 @@ class TestEventsNotifications(TestCase):
|
||||
execute_mock = MagicMock()
|
||||
with patch("authentik.events.models.NotificationTransport.send", execute_mock):
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(Notification.objects.count(), 1)
|
||||
self.assertEqual(execute_mock.call_count, 1)
|
||||
|
||||
def test_transport_mapping(self):
|
||||
"""Test transport mapping"""
|
||||
mapping = NotificationWebhookMapping.objects.create(
|
||||
name=generate_id(),
|
||||
expression="""notification.body = 'foo'""",
|
||||
)
|
||||
|
||||
transport = NotificationTransport.objects.create(
|
||||
name="transport", webhook_mapping=mapping, mode=TransportMode.LOCAL
|
||||
)
|
||||
NotificationRule.objects.filter(name__startswith="default").delete()
|
||||
trigger = NotificationRule.objects.create(name="trigger", group=self.group)
|
||||
trigger.transports.add(transport)
|
||||
matcher = EventMatcherPolicy.objects.create(
|
||||
name="matcher", action=EventAction.CUSTOM_PREFIX
|
||||
)
|
||||
PolicyBinding.objects.create(target=trigger, policy=matcher, order=0)
|
||||
|
||||
Notification.objects.all().delete()
|
||||
Event.new(EventAction.CUSTOM_PREFIX).save()
|
||||
self.assertEqual(Notification.objects.first().body, "foo")
|
||||
|
@ -10,9 +10,11 @@ from django.db import models
|
||||
from django.db.models.base import Model
|
||||
from django.http.request import HttpRequest
|
||||
from django.views.debug import SafeExceptionReporterFilter
|
||||
from geoip2.models import City
|
||||
from guardian.utils import get_anonymous_user
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.events.geo import GEOIP_READER
|
||||
from authentik.policies.types import PolicyRequest
|
||||
|
||||
# Special keys which are *not* cleaned, even when the default filter
|
||||
@ -93,6 +95,8 @@ def sanitize_dict(source: dict[Any, Any]) -> dict[Any, Any]:
|
||||
final_dict[key] = value.hex
|
||||
elif isinstance(value, (HttpRequest, WSGIRequest)):
|
||||
continue
|
||||
elif isinstance(value, City):
|
||||
final_dict[key] = GEOIP_READER.city_to_dict(value)
|
||||
elif isinstance(value, type):
|
||||
final_dict[key] = {
|
||||
"type": value.__name__,
|
||||
|
@ -35,3 +35,4 @@ class FlowStageBindingViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = FlowStageBinding.objects.all()
|
||||
serializer_class = FlowStageBindingSerializer
|
||||
filterset_fields = "__all__"
|
||||
search_fields = ["stage__name"]
|
||||
|
@ -94,9 +94,9 @@ class Command(BaseCommand): # pragma: no cover
|
||||
|
||||
def output_overview(self, values):
|
||||
"""Output results human readable"""
|
||||
total_max: int = max([max(inner) for inner in values])
|
||||
total_min: int = min([min(inner) for inner in values])
|
||||
total_avg = sum([sum(inner) for inner in values]) / sum([len(inner) for inner in values])
|
||||
total_max: int = max(max(inner) for inner in values)
|
||||
total_min: int = min(min(inner) for inner in values)
|
||||
total_avg = sum(sum(inner) for inner in values) / sum(len(inner) for inner in values)
|
||||
|
||||
print(f"Version: {__version__}")
|
||||
print(f"Processes: {len(values)}")
|
||||
|
@ -117,7 +117,7 @@ class FlowPlanner:
|
||||
self.use_cache = True
|
||||
self.allow_empty_flows = False
|
||||
self.flow = flow
|
||||
self._logger = get_logger().bind(flow=flow)
|
||||
self._logger = get_logger().bind(flow_slug=flow.slug)
|
||||
|
||||
def plan(
|
||||
self, request: HttpRequest, default_context: Optional[dict[str, Any]] = None
|
||||
|
@ -9,7 +9,7 @@ from django.urls import reverse
|
||||
from django.views.generic.base import View
|
||||
from rest_framework.request import Request
|
||||
from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import get_logger
|
||||
from structlog.stdlib import BoundLogger, get_logger
|
||||
|
||||
from authentik.core.models import DEFAULT_AVATAR, User
|
||||
from authentik.flows.challenge import (
|
||||
@ -23,23 +23,30 @@ from authentik.flows.challenge import (
|
||||
)
|
||||
from authentik.flows.models import InvalidResponseAction
|
||||
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
|
||||
from authentik.lib.utils.reflection import class_to_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
|
||||
PLAN_CONTEXT_PENDING_USER_IDENTIFIER = "pending_user_identifier"
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class StageView(View):
|
||||
"""Abstract Stage, inherits TemplateView but can be combined with FormView"""
|
||||
"""Abstract Stage"""
|
||||
|
||||
executor: "FlowExecutorView"
|
||||
|
||||
request: HttpRequest = None
|
||||
|
||||
logger: BoundLogger
|
||||
|
||||
def __init__(self, executor: "FlowExecutorView", **kwargs):
|
||||
self.executor = executor
|
||||
current_stage = getattr(self.executor, "current_stage", None)
|
||||
self.logger = get_logger().bind(
|
||||
stage=getattr(current_stage, "name", None),
|
||||
stage_view=class_to_path(type(self)),
|
||||
)
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def get_pending_user(self, for_display=False) -> User:
|
||||
@ -60,6 +67,9 @@ class StageView(View):
|
||||
return self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
|
||||
return self.request.user
|
||||
|
||||
def cleanup(self):
|
||||
"""Cleanup session"""
|
||||
|
||||
|
||||
class ChallengeStageView(StageView):
|
||||
"""Stage view which response with a challenge"""
|
||||
@ -74,12 +84,9 @@ class ChallengeStageView(StageView):
|
||||
"""Return a challenge for the frontend to solve"""
|
||||
challenge = self._get_challenge(*args, **kwargs)
|
||||
if not challenge.is_valid():
|
||||
LOGGER.warning(
|
||||
self.logger.warning(
|
||||
"f(ch): Invalid challenge",
|
||||
binding=self.executor.current_binding,
|
||||
errors=challenge.errors,
|
||||
stage_view=self,
|
||||
challenge=challenge,
|
||||
)
|
||||
return HttpChallengeResponse(challenge)
|
||||
|
||||
@ -96,10 +103,8 @@ class ChallengeStageView(StageView):
|
||||
self.executor.current_binding.invalid_response_action
|
||||
== InvalidResponseAction.RESTART_WITH_CONTEXT
|
||||
)
|
||||
LOGGER.debug(
|
||||
self.logger.debug(
|
||||
"f(ch): Invalid response, restarting flow",
|
||||
binding=self.executor.current_binding,
|
||||
stage_view=self,
|
||||
keep_context=keep_context,
|
||||
)
|
||||
return self.executor.restart_flow(keep_context)
|
||||
@ -125,7 +130,7 @@ class ChallengeStageView(StageView):
|
||||
}
|
||||
# pylint: disable=broad-except
|
||||
except Exception as exc:
|
||||
LOGGER.warning("failed to template title", exc=exc)
|
||||
self.logger.warning("failed to template title", exc=exc)
|
||||
return self.executor.flow.title
|
||||
|
||||
def _get_challenge(self, *args, **kwargs) -> Challenge:
|
||||
@ -185,11 +190,9 @@ class ChallengeStageView(StageView):
|
||||
)
|
||||
challenge_response.initial_data["response_errors"] = full_errors
|
||||
if not challenge_response.is_valid():
|
||||
LOGGER.error(
|
||||
self.logger.error(
|
||||
"f(ch): invalid challenge response",
|
||||
binding=self.executor.current_binding,
|
||||
errors=challenge_response.errors,
|
||||
stage_view=self,
|
||||
)
|
||||
return HttpChallengeResponse(challenge_response)
|
||||
|
||||
|
@ -9,6 +9,7 @@ from rest_framework.test import APITestCase
|
||||
from authentik.core.tests.utils import create_test_admin_user
|
||||
from authentik.flows.challenge import ChallengeTypes
|
||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding, InvalidResponseAction
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.stages.dummy.models import DummyStage
|
||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||
|
||||
@ -24,8 +25,8 @@ class TestFlowInspector(APITestCase):
|
||||
def test(self):
|
||||
"""test inspector"""
|
||||
flow = Flow.objects.create(
|
||||
name="test-full",
|
||||
slug="test-full",
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
|
||||
|
@ -13,6 +13,26 @@ from authentik.policies.models import PolicyBinding
|
||||
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
||||
from authentik.stages.user_login.models import UserLoginStage
|
||||
|
||||
STATIC_PROMPT_EXPORT = """{
|
||||
"version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"identifiers": {
|
||||
"pk": "cb954fd4-65a5-4ad9-b1ee-180ee9559cf4"
|
||||
},
|
||||
"model": "authentik_stages_prompt.prompt",
|
||||
"attrs": {
|
||||
"field_key": "username",
|
||||
"label": "Username",
|
||||
"type": "username",
|
||||
"required": true,
|
||||
"placeholder": "Username",
|
||||
"order": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}"""
|
||||
|
||||
|
||||
class TestFlowTransfer(TransactionTestCase):
|
||||
"""Test flow transfer"""
|
||||
@ -58,6 +78,22 @@ class TestFlowTransfer(TransactionTestCase):
|
||||
|
||||
self.assertTrue(Flow.objects.filter(slug=flow_slug).exists())
|
||||
|
||||
def test_export_validate_import_re_import(self):
|
||||
"""Test export and import it twice"""
|
||||
count_initial = Prompt.objects.filter(field_key="username").count()
|
||||
|
||||
importer = FlowImporter(STATIC_PROMPT_EXPORT)
|
||||
self.assertTrue(importer.validate())
|
||||
self.assertTrue(importer.apply())
|
||||
|
||||
count_before = Prompt.objects.filter(field_key="username").count()
|
||||
self.assertEqual(count_initial + 1, count_before)
|
||||
|
||||
importer = FlowImporter(STATIC_PROMPT_EXPORT)
|
||||
self.assertTrue(importer.apply())
|
||||
|
||||
self.assertEqual(Prompt.objects.filter(field_key="username").count(), count_before)
|
||||
|
||||
def test_export_validate_import_policies(self):
|
||||
"""Test export and validate it"""
|
||||
flow_slug = generate_id()
|
||||
|
@ -28,6 +28,7 @@ ALLOWED_MODELS = (Flow, FlowStageBinding, Stage, Policy, PolicyBinding, Prompt)
|
||||
def transaction_rollback():
|
||||
"""Enters an atomic transaction and always triggers a rollback at the end of the block."""
|
||||
atomic = transaction.atomic()
|
||||
# pylint: disable=unnecessary-dunder-call
|
||||
atomic.__enter__()
|
||||
yield
|
||||
atomic.__exit__(IntegrityError, None, None)
|
||||
@ -115,6 +116,11 @@ class FlowImporter:
|
||||
serializer_kwargs["instance"] = model_instance
|
||||
else:
|
||||
self.logger.debug("initialise new instance", model=model, **updated_identifiers)
|
||||
model_instance = model()
|
||||
# pk needs to be set on the model instance otherwise a new one will be generated
|
||||
if "pk" in updated_identifiers:
|
||||
model_instance.pk = updated_identifiers["pk"]
|
||||
serializer_kwargs["instance"] = model_instance
|
||||
full_data = self.__update_pks_for_attrs(entry.attrs)
|
||||
full_data.update(updated_identifiers)
|
||||
serializer_kwargs["data"] = full_data
|
||||
@ -167,7 +173,7 @@ class FlowImporter:
|
||||
def validate(self) -> bool:
|
||||
"""Validate loaded flow export, ensure all models are allowed
|
||||
and serializers have no errors"""
|
||||
self.logger.debug("Starting flow import validaton")
|
||||
self.logger.debug("Starting flow import validation")
|
||||
if self.__import.version != 1:
|
||||
self.logger.warning("Invalid bundle version")
|
||||
return False
|
||||
|
@ -49,7 +49,7 @@ from authentik.flows.planner import (
|
||||
FlowPlan,
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.stage import AccessDeniedChallengeView
|
||||
from authentik.flows.stage import AccessDeniedChallengeView, StageView
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
from authentik.lib.utils.errors import exception_to_string
|
||||
from authentik.lib.utils.reflection import all_subclasses, class_to_path
|
||||
@ -59,11 +59,11 @@ from authentik.tenants.models import Tenant
|
||||
LOGGER = get_logger()
|
||||
# Argument used to redirect user after login
|
||||
NEXT_ARG_NAME = "next"
|
||||
SESSION_KEY_PLAN = "authentik_flows_plan"
|
||||
SESSION_KEY_APPLICATION_PRE = "authentik_flows_application_pre"
|
||||
SESSION_KEY_GET = "authentik_flows_get"
|
||||
SESSION_KEY_POST = "authentik_flows_post"
|
||||
SESSION_KEY_HISTORY = "authentik_flows_history"
|
||||
SESSION_KEY_PLAN = "authentik/flows/plan"
|
||||
SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
|
||||
SESSION_KEY_GET = "authentik/flows/get"
|
||||
SESSION_KEY_POST = "authentik/flows/post"
|
||||
SESSION_KEY_HISTORY = "authentik/flows/history"
|
||||
QS_KEY_TOKEN = "flow_token" # nosec
|
||||
|
||||
|
||||
@ -380,6 +380,8 @@ class FlowExecutorView(APIView):
|
||||
"f(exec): Stage ok",
|
||||
stage_class=class_to_path(self.current_stage_view.__class__),
|
||||
)
|
||||
if isinstance(self.current_stage_view, StageView):
|
||||
self.current_stage_view.cleanup()
|
||||
self.request.session.get(SESSION_KEY_HISTORY, []).append(deepcopy(self.plan))
|
||||
self.plan.pop()
|
||||
self.request.session[SESSION_KEY_PLAN] = self.plan
|
||||
@ -416,11 +418,14 @@ class FlowExecutorView(APIView):
|
||||
SESSION_KEY_APPLICATION_PRE,
|
||||
SESSION_KEY_PLAN,
|
||||
SESSION_KEY_GET,
|
||||
# We might need the initial POST payloads for later requests
|
||||
# SESSION_KEY_POST,
|
||||
# We don't delete the history on purpose, as a user might
|
||||
# still be inspecting it.
|
||||
# It's only deleted on a fresh executions
|
||||
# SESSION_KEY_HISTORY,
|
||||
]
|
||||
self._logger.debug("f(exec): cleaning up")
|
||||
for key in keys_to_delete:
|
||||
if key in self.request.session:
|
||||
del self.request.session[key]
|
||||
|
12
authentik/lib/xml.py
Normal file
12
authentik/lib/xml.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""XML Utilities"""
|
||||
from lxml.etree import XMLParser, fromstring # nosec
|
||||
|
||||
|
||||
def get_lxml_parser():
|
||||
"""Get XML parser"""
|
||||
return XMLParser(resolve_entities=False)
|
||||
|
||||
|
||||
def lxml_from_string(text: str):
|
||||
"""Wrapper around fromstring"""
|
||||
return fromstring(text, parser=get_lxml_parser())
|
@ -8,9 +8,3 @@ class AuthentikManagedConfig(AppConfig):
|
||||
name = "authentik.managed"
|
||||
label = "authentik_managed"
|
||||
verbose_name = "authentik Managed"
|
||||
|
||||
def ready(self) -> None:
|
||||
from authentik.managed.tasks import managed_reconcile
|
||||
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
managed_reconcile.delay() # pylint: disable=no-value-for-parameter
|
||||
|
@ -118,6 +118,7 @@ class DockerServiceConnectionViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = DockerServiceConnectionSerializer
|
||||
filterset_fields = ["name", "local", "url", "tls_verification", "tls_authentication"]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
||||
|
||||
class KubernetesServiceConnectionSerializer(ServiceConnectionSerializer):
|
||||
@ -152,3 +153,4 @@ class KubernetesServiceConnectionViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = KubernetesServiceConnectionSerializer
|
||||
filterset_fields = ["name", "local"]
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -2,7 +2,6 @@
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db import ProgrammingError
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
LOGGER = get_logger()
|
||||
@ -18,10 +17,3 @@ class AuthentikOutpostConfig(AppConfig):
|
||||
def ready(self):
|
||||
import_module("authentik.outposts.signals")
|
||||
import_module("authentik.outposts.managed")
|
||||
try:
|
||||
from authentik.outposts.tasks import outpost_controller_all, outpost_local_connection
|
||||
|
||||
outpost_local_connection.delay()
|
||||
outpost_controller_all.delay()
|
||||
except ProgrammingError:
|
||||
pass
|
||||
|
@ -15,7 +15,7 @@ from yaml import safe_dump
|
||||
|
||||
from authentik import __version__
|
||||
from authentik.outposts.controllers.base import BaseClient, BaseController, ControllerException
|
||||
from authentik.outposts.docker_ssh import DockerInlineSSH
|
||||
from authentik.outposts.docker_ssh import DockerInlineSSH, SSHManagedExternallyException
|
||||
from authentik.outposts.docker_tls import DockerInlineTLS
|
||||
from authentik.outposts.managed import MANAGED_OUTPOST
|
||||
from authentik.outposts.models import (
|
||||
@ -35,6 +35,7 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
||||
def __init__(self, connection: DockerServiceConnection):
|
||||
self.tls = None
|
||||
self.ssh = None
|
||||
self.logger = get_logger()
|
||||
if connection.local:
|
||||
# Same result as DockerClient.from_env
|
||||
super().__init__(**kwargs_from_env())
|
||||
@ -42,8 +43,12 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
||||
parsed_url = urlparse(connection.url)
|
||||
tls_config = False
|
||||
if parsed_url.scheme == "ssh":
|
||||
self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
|
||||
self.ssh.write()
|
||||
try:
|
||||
self.ssh = DockerInlineSSH(parsed_url.hostname, connection.tls_authentication)
|
||||
self.ssh.write()
|
||||
except SSHManagedExternallyException as exc:
|
||||
# SSH config is managed externally
|
||||
self.logger.info(f"SSH Managed externally: {exc}")
|
||||
else:
|
||||
self.tls = DockerInlineTLS(
|
||||
verification_kp=connection.tls_verification,
|
||||
@ -57,7 +62,6 @@ class DockerClient(UpstreamDockerClient, BaseClient):
|
||||
)
|
||||
except SSHException as exc:
|
||||
raise ServiceConnectionInvalid from exc
|
||||
self.logger = get_logger()
|
||||
# Ensure the client actually works
|
||||
self.containers.list()
|
||||
|
||||
|
@ -16,6 +16,10 @@ def opener(path, flags):
|
||||
return os.open(path, flags, 0o700)
|
||||
|
||||
|
||||
class SSHManagedExternallyException(DockerException):
|
||||
"""Raised when the ssh config file is managed externally."""
|
||||
|
||||
|
||||
class DockerInlineSSH:
|
||||
"""Create paramiko ssh config from CertificateKeyPair"""
|
||||
|
||||
@ -29,9 +33,15 @@ class DockerInlineSSH:
|
||||
def __init__(self, host: str, keypair: CertificateKeyPair) -> None:
|
||||
self.host = host
|
||||
self.keypair = keypair
|
||||
self.config_path = Path("~/.ssh/config").expanduser()
|
||||
if self.config_path.exists() and HEADER not in self.config_path.read_text(encoding="utf-8"):
|
||||
# SSH Config file already exists and there's no header from us, meaning that it's
|
||||
# been externally mapped into the container for more complex configs
|
||||
raise SSHManagedExternallyException(
|
||||
"SSH Config exists and does not contain authentik header"
|
||||
)
|
||||
if not self.keypair:
|
||||
raise DockerException("keypair must be set for SSH connections")
|
||||
self.config_path = Path("~/.ssh/config").expanduser()
|
||||
self.header = f"{HEADER} - {self.host}\n"
|
||||
|
||||
def write_config(self, key_path: str) -> bool:
|
||||
|
@ -52,7 +52,7 @@ def m2m_changed_update(sender, instance: Model, action: str, **_):
|
||||
|
||||
@receiver(post_save)
|
||||
# pylint: disable=unused-argument
|
||||
def post_save_update(sender, instance: Model, **_):
|
||||
def post_save_update(sender, instance: Model, created: bool, **_):
|
||||
"""If an Outpost is saved, Ensure that token is created/updated
|
||||
|
||||
If an OutpostModel, or a model that is somehow connected to an OutpostModel is saved,
|
||||
@ -63,6 +63,9 @@ def post_save_update(sender, instance: Model, **_):
|
||||
return
|
||||
if not isinstance(instance, UPDATE_TRIGGERING_MODELS):
|
||||
return
|
||||
if isinstance(instance, Outpost) and created:
|
||||
LOGGER.info("New outpost saved, ensuring initial token and user are created")
|
||||
_ = instance.token
|
||||
outpost_post_save.delay(class_to_path(instance.__class__), instance.pk)
|
||||
|
||||
|
||||
|
@ -48,9 +48,7 @@ class PolicySerializer(ModelSerializer, MetaNameSerializer):
|
||||
|
||||
def get_bound_to(self, obj: Policy) -> int:
|
||||
"""Return objects policy is bound to"""
|
||||
if not obj.bindings.exists() and not obj.promptstage_set.exists():
|
||||
return 0
|
||||
return obj.bindings.count()
|
||||
return obj.bindings.count() + obj.promptstage_set.count()
|
||||
|
||||
def to_representation(self, instance: Policy):
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
|
@ -21,3 +21,4 @@ class DummyPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = DummyPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -23,7 +23,7 @@ GAUGE_POLICIES_CACHED = Gauge(
|
||||
HIST_POLICIES_BUILD_TIME = Histogram(
|
||||
"authentik_policies_build_time",
|
||||
"Execution times complete policy result to an object",
|
||||
["object_name", "object_type", "user"],
|
||||
["object_pk", "object_type"],
|
||||
)
|
||||
|
||||
|
||||
@ -91,9 +91,8 @@ class PolicyEngine:
|
||||
op="authentik.policy.engine.build",
|
||||
description=self.__pbm,
|
||||
) as span, HIST_POLICIES_BUILD_TIME.labels(
|
||||
object_name=self.__pbm,
|
||||
object_pk=str(self.__pbm.pk),
|
||||
object_type=f"{self.__pbm._meta.app_label}.{self.__pbm._meta.model_name}",
|
||||
user=self.request.user,
|
||||
).time():
|
||||
span: Span
|
||||
span.set_data("pbm", self.__pbm)
|
||||
|
@ -25,3 +25,4 @@ class EventMatcherPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = EventMatcherPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -21,3 +21,4 @@ class PasswordExpiryPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = PasswordExpiryPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -28,3 +28,4 @@ class ExpressionPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = ExpressionPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -20,4 +20,5 @@ class HaveIBeenPwendPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = HaveIBeenPwendPolicy.objects.all()
|
||||
serializer_class = HaveIBeenPwendPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
search_fields = ["name", "password_field"]
|
||||
ordering = ["name"]
|
||||
|
@ -30,3 +30,4 @@ class PasswordPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = PasswordPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
@ -1,8 +1,8 @@
|
||||
"""Password flow tests"""
|
||||
from django.urls.base import reverse
|
||||
|
||||
from authentik.core.models import User
|
||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
|
||||
from authentik.flows.models import FlowDesignation, FlowStageBinding
|
||||
from authentik.flows.tests import FlowTestCase
|
||||
from authentik.policies.password.models import PasswordPolicy
|
||||
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
|
||||
@ -12,13 +12,9 @@ class TestPasswordPolicyFlow(FlowTestCase):
|
||||
"""Test Password Policy"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.user = User.objects.create(username="unittest", email="test@beryju.org")
|
||||
self.user = create_test_admin_user()
|
||||
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
|
||||
|
||||
self.flow = Flow.objects.create(
|
||||
name="test-prompt",
|
||||
slug="test-prompt",
|
||||
designation=FlowDesignation.AUTHENTICATION,
|
||||
)
|
||||
password_prompt = Prompt.objects.create(
|
||||
field_key="password",
|
||||
label="PASSWORD_LABEL",
|
||||
|
@ -28,9 +28,8 @@ HIST_POLICIES_EXECUTION_TIME = Histogram(
|
||||
"binding_order",
|
||||
"binding_target_type",
|
||||
"binding_target_name",
|
||||
"object_name",
|
||||
"object_pk",
|
||||
"object_type",
|
||||
"user",
|
||||
],
|
||||
)
|
||||
|
||||
@ -89,7 +88,7 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
LOGGER.debug(
|
||||
"P_ENG(proc): Running policy",
|
||||
policy=self.binding.policy,
|
||||
user=self.request.user,
|
||||
user=self.request.user.username,
|
||||
# this is used for filtering in access checking where logs are sent to the admin
|
||||
process="PolicyProcess",
|
||||
)
|
||||
@ -125,7 +124,7 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
# this is used for filtering in access checking where logs are sent to the admin
|
||||
process="PolicyProcess",
|
||||
passing=policy_result.passing,
|
||||
user=self.request.user,
|
||||
user=self.request.user.username,
|
||||
)
|
||||
return policy_result
|
||||
|
||||
@ -137,9 +136,8 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
binding_order=self.binding.order,
|
||||
binding_target_type=self.binding.target_type,
|
||||
binding_target_name=self.binding.target_name,
|
||||
object_name=self.request.obj,
|
||||
object_pk=str(self.request.obj.pk),
|
||||
object_type=f"{self.request.obj._meta.app_label}.{self.request.obj._meta.model_name}",
|
||||
user=str(self.request.user),
|
||||
).time():
|
||||
span: Span
|
||||
span.set_data("policy", self.binding.policy)
|
||||
@ -151,5 +149,5 @@ class PolicyProcess(PROCESS_CLASS):
|
||||
try:
|
||||
self.connection.send(self.profiling_wrapper())
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.warning(str(exc))
|
||||
LOGGER.warning("Policy failed to run", exc=exc)
|
||||
self.connection.send(PolicyResult(False, str(exc)))
|
||||
|
@ -26,6 +26,7 @@ class ReputationPolicyViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = ReputationPolicy.objects.all()
|
||||
serializer_class = ReputationPolicySerializer
|
||||
filterset_fields = "__all__"
|
||||
search_fields = ["name", "threshold"]
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
"""authentik reputation request signals"""
|
||||
from django.contrib.auth.signals import user_logged_in, user_login_failed
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.core.cache import cache
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.signals import login_failed
|
||||
from authentik.lib.config import CONFIG
|
||||
from authentik.lib.utils.http import get_client_ip
|
||||
from authentik.policies.reputation.models import CACHE_KEY_PREFIX
|
||||
@ -35,7 +36,7 @@ def update_score(request: HttpRequest, identifier: str, amount: int):
|
||||
save_reputation.delay()
|
||||
|
||||
|
||||
@receiver(user_login_failed)
|
||||
@receiver(login_failed)
|
||||
# pylint: disable=unused-argument
|
||||
def handle_failed_login(sender, request, credentials, **_):
|
||||
"""Lower Score for failed login attempts"""
|
||||
|
@ -1,5 +1,4 @@
|
||||
"""test reputation signals and policy"""
|
||||
from django.contrib.auth import authenticate
|
||||
from django.core.cache import cache
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
@ -7,6 +6,8 @@ from authentik.core.models import User
|
||||
from authentik.policies.reputation.models import CACHE_KEY_PREFIX, Reputation, ReputationPolicy
|
||||
from authentik.policies.reputation.tasks import save_reputation
|
||||
from authentik.policies.types import PolicyRequest
|
||||
from authentik.stages.password import BACKEND_INBUILT
|
||||
from authentik.stages.password.stage import authenticate
|
||||
|
||||
|
||||
class TestReputationPolicy(TestCase):
|
||||
@ -21,11 +22,14 @@ class TestReputationPolicy(TestCase):
|
||||
cache.delete_many(keys)
|
||||
# We need a user for the one-to-one in userreputation
|
||||
self.user = User.objects.create(username=self.test_username)
|
||||
self.backends = [BACKEND_INBUILT]
|
||||
|
||||
def test_ip_reputation(self):
|
||||
"""test IP reputation"""
|
||||
# Trigger negative reputation
|
||||
authenticate(self.request, username=self.test_username, password=self.test_username)
|
||||
authenticate(
|
||||
self.request, self.backends, username=self.test_username, password=self.test_username
|
||||
)
|
||||
# Test value in cache
|
||||
self.assertEqual(
|
||||
cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username),
|
||||
@ -38,7 +42,9 @@ class TestReputationPolicy(TestCase):
|
||||
def test_user_reputation(self):
|
||||
"""test User reputation"""
|
||||
# Trigger negative reputation
|
||||
authenticate(self.request, username=self.test_username, password=self.test_username)
|
||||
authenticate(
|
||||
self.request, self.backends, username=self.test_username, password=self.test_username
|
||||
)
|
||||
# Test value in cache
|
||||
self.assertEqual(
|
||||
cache.get(CACHE_KEY_PREFIX + self.test_ip + self.test_username),
|
||||
|
@ -47,6 +47,7 @@ class LDAPProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"uid_start_number": ["iexact"],
|
||||
"gid_start_number": ["iexact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
@ -81,3 +82,5 @@ class LDAPOutpostConfigViewSet(ReadOnlyModelViewSet):
|
||||
queryset = LDAPProvider.objects.filter(application__isnull=False)
|
||||
serializer_class = LDAPOutpostConfigSerializer
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
|
@ -35,6 +35,7 @@ class OAuth2ProviderSerializer(ProviderSerializer):
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
"verification_keys",
|
||||
"jwks_sources",
|
||||
]
|
||||
|
||||
|
||||
@ -47,6 +48,7 @@ class OAuth2ProviderSetupURLs(PassiveSerializer):
|
||||
user_info = CharField(read_only=True)
|
||||
provider_info = CharField(read_only=True)
|
||||
logout = CharField(read_only=True)
|
||||
jwks = CharField(read_only=True)
|
||||
|
||||
|
||||
class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
@ -71,6 +73,7 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"property_mappings",
|
||||
"issuer_mode",
|
||||
]
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
||||
@extend_schema(
|
||||
@ -117,6 +120,12 @@ class OAuth2ProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
kwargs={"application_slug": provider.application.slug},
|
||||
)
|
||||
)
|
||||
data["jwks"] = request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_providers_oauth2:jwks",
|
||||
kwargs={"application_slug": provider.application.slug},
|
||||
)
|
||||
)
|
||||
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
|
||||
pass
|
||||
return Response(data)
|
||||
|
@ -39,3 +39,4 @@ class ScopeMappingViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = ScopeMappingSerializer
|
||||
filterset_class = ScopeMappingFilter
|
||||
ordering = ["scope_name", "name"]
|
||||
search_fields = ["name", "scope_name"]
|
||||
|
@ -16,7 +16,7 @@ class Migration(migrations.Migration):
|
||||
model_name="oauth2provider",
|
||||
name="verification_keys",
|
||||
field=models.ManyToManyField(
|
||||
help_text="JWTs created with the configured certificates can authenticate with this provider.",
|
||||
help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.",
|
||||
related_name="+",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
verbose_name="Allowed certificates for JWT-based client_credentials",
|
||||
|
@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.0.4 on 2022-05-23 20:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"authentik_sources_oauth",
|
||||
"0007_oauthsource_oidc_jwks_oauthsource_oidc_jwks_url_and_more",
|
||||
),
|
||||
("authentik_crypto", "0003_certificatekeypair_managed"),
|
||||
("authentik_providers_oauth2", "0010_alter_oauth2provider_verification_keys"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="oauth2provider",
|
||||
name="jwks_sources",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
related_name="oauth2_providers",
|
||||
to="authentik_sources_oauth.oauthsource",
|
||||
verbose_name="Any JWT signed by the JWK of the selected source can be used to authenticate.",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="oauth2provider",
|
||||
name="verification_keys",
|
||||
field=models.ManyToManyField(
|
||||
blank=True,
|
||||
default=None,
|
||||
help_text="DEPRECATED. JWTs created with the configured certificates can authenticate with this provider.",
|
||||
related_name="oauth2_providers",
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
verbose_name="Allowed certificates for JWT-based client_credentials",
|
||||
),
|
||||
),
|
||||
]
|
@ -27,6 +27,7 @@ from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.lib.utils.time import timedelta_from_string, timedelta_string_validator
|
||||
from authentik.providers.oauth2.apps import AuthentikProviderOAuth2Config
|
||||
from authentik.providers.oauth2.constants import ACR_AUTHENTIK_DEFAULT
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
class ClientTypes(models.TextChoices):
|
||||
@ -225,9 +226,21 @@ class OAuth2Provider(Provider):
|
||||
CertificateKeyPair,
|
||||
verbose_name=_("Allowed certificates for JWT-based client_credentials"),
|
||||
help_text=_(
|
||||
"JWTs created with the configured certificates can authenticate with this provider."
|
||||
(
|
||||
"DEPRECATED. JWTs created with the configured "
|
||||
"certificates can authenticate with this provider."
|
||||
)
|
||||
),
|
||||
related_name="+",
|
||||
related_name="oauth2_providers",
|
||||
default=None,
|
||||
blank=True,
|
||||
)
|
||||
jwks_sources = models.ManyToManyField(
|
||||
OAuthSource,
|
||||
verbose_name=_(
|
||||
"Any JWT signed by the JWK of the selected source can be used to authenticate."
|
||||
),
|
||||
related_name="oauth2_providers",
|
||||
default=None,
|
||||
blank=True,
|
||||
)
|
||||
|
@ -78,6 +78,28 @@ class TestAuthorize(OAuthTestCase):
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
def test_invalid_redirect_uri_regex(self):
|
||||
"""test missing/invalid redirect URI"""
|
||||
OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id="test",
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="+",
|
||||
)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get("/", data={"response_type": "code", "client_id": "test"})
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
with self.assertRaises(RedirectUriError):
|
||||
request = self.factory.get(
|
||||
"/",
|
||||
data={
|
||||
"response_type": "code",
|
||||
"client_id": "test",
|
||||
"redirect_uri": "http://localhost",
|
||||
},
|
||||
)
|
||||
OAuthAuthorizationParams.from_request(request)
|
||||
|
||||
def test_empty_redirect_uri(self):
|
||||
"""test empty redirect URI (configure in provider)"""
|
||||
OAuth2Provider.objects.create(
|
||||
|
@ -6,8 +6,8 @@ from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from jwt import decode
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_SA, Application, Group
|
||||
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow
|
||||
from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.policies.models import PolicyBinding
|
||||
@ -40,9 +40,6 @@ class TestTokenClientCredentialsJWT(OAuthTestCase):
|
||||
self.provider.verification_keys.set([self.cert])
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
self.user = create_test_admin_user("sa")
|
||||
self.user.attributes[USER_ATTRIBUTE_SA] = True
|
||||
self.user.save()
|
||||
|
||||
def test_invalid_type(self):
|
||||
"""test invalid type"""
|
||||
@ -76,7 +73,7 @@ class TestTokenClientCredentialsJWT(OAuthTestCase):
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_signautre(self):
|
||||
def test_invalid_signature(self):
|
||||
"""test invalid JWT"""
|
||||
token = self.provider.encode(
|
||||
{
|
223
authentik/providers/oauth2/tests/test_token_cc_jwt_source.py
Normal file
223
authentik/providers/oauth2/tests/test_token_cc_jwt_source.py
Normal file
@ -0,0 +1,223 @@
|
||||
"""Test token view"""
|
||||
from datetime import datetime, timedelta
|
||||
from json import loads
|
||||
|
||||
from django.test import RequestFactory
|
||||
from django.urls import reverse
|
||||
from jwt import decode
|
||||
|
||||
from authentik.core.models import Application, Group
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id, generate_key
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.policies.models import PolicyBinding
|
||||
from authentik.providers.oauth2.constants import (
|
||||
GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
SCOPE_OPENID,
|
||||
SCOPE_OPENID_EMAIL,
|
||||
SCOPE_OPENID_PROFILE,
|
||||
)
|
||||
from authentik.providers.oauth2.models import OAuth2Provider, ScopeMapping
|
||||
from authentik.providers.oauth2.tests.utils import OAuthTestCase
|
||||
from authentik.providers.oauth2.views.jwks import JWKSView
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
|
||||
class TestTokenClientCredentialsJWTSource(OAuthTestCase):
|
||||
"""Test token (client_credentials, with JWT) view"""
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
ObjectManager().run()
|
||||
self.factory = RequestFactory()
|
||||
self.cert = create_test_cert()
|
||||
|
||||
jwk = JWKSView().get_jwk_for_key(self.cert)
|
||||
self.source: OAuthSource = OAuthSource.objects.create(
|
||||
name=generate_id(),
|
||||
slug=generate_id(),
|
||||
provider_type="openidconnect",
|
||||
consumer_key=generate_id(),
|
||||
consumer_secret=generate_key(),
|
||||
authorization_url="http://foo",
|
||||
access_token_url=f"http://{generate_id()}",
|
||||
profile_url="http://foo",
|
||||
oidc_well_known_url="",
|
||||
oidc_jwks_url="",
|
||||
oidc_jwks={
|
||||
"keys": [jwk],
|
||||
},
|
||||
)
|
||||
|
||||
self.provider: OAuth2Provider = OAuth2Provider.objects.create(
|
||||
name="test",
|
||||
client_id=generate_id(),
|
||||
client_secret=generate_key(),
|
||||
authorization_flow=create_test_flow(),
|
||||
redirect_uris="http://testserver",
|
||||
signing_key=self.cert,
|
||||
)
|
||||
self.provider.jwks_sources.add(self.source)
|
||||
self.provider.property_mappings.set(ScopeMapping.objects.all())
|
||||
self.app = Application.objects.create(name="test", slug="test", provider=self.provider)
|
||||
|
||||
def test_invalid_type(self):
|
||||
"""test invalid type"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "foo",
|
||||
"client_assertion": "foo.bar",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_jwt(self):
|
||||
"""test invalid JWT"""
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": "foo.bar",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_signature(self):
|
||||
"""test invalid JWT"""
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token + "foo",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_expired(self):
|
||||
"""test invalid JWT"""
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() - timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_no_app(self):
|
||||
"""test invalid JWT"""
|
||||
self.app.provider = None
|
||||
self.app.save()
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_invalid_access_denied(self):
|
||||
"""test invalid JWT"""
|
||||
group = Group.objects.create(name="foo")
|
||||
PolicyBinding.objects.create(
|
||||
group=group,
|
||||
target=self.app,
|
||||
order=0,
|
||||
)
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["error"], "invalid_grant")
|
||||
|
||||
def test_successful(self):
|
||||
"""test successful"""
|
||||
token = self.provider.encode(
|
||||
{
|
||||
"sub": "foo",
|
||||
"exp": datetime.now() + timedelta(hours=2),
|
||||
}
|
||||
)
|
||||
response = self.client.post(
|
||||
reverse("authentik_providers_oauth2:token"),
|
||||
{
|
||||
"grant_type": GRANT_TYPE_CLIENT_CREDENTIALS,
|
||||
"scope": f"{SCOPE_OPENID} {SCOPE_OPENID_EMAIL} {SCOPE_OPENID_PROFILE}",
|
||||
"client_id": self.provider.client_id,
|
||||
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
"client_assertion": token,
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = loads(response.content.decode())
|
||||
self.assertEqual(body["token_type"], "bearer")
|
||||
_, alg = self.provider.get_jwt_key()
|
||||
jwt = decode(
|
||||
body["access_token"],
|
||||
key=self.provider.signing_key.public_key,
|
||||
algorithms=[alg],
|
||||
audience=self.provider.client_id,
|
||||
)
|
||||
self.assertEqual(
|
||||
jwt["given_name"], "Autogenerated user from application test (client credentials JWT)"
|
||||
)
|
||||
self.assertEqual(jwt["preferred_username"], "test-foo")
|
@ -1,7 +1,8 @@
|
||||
"""authentik OAuth2 Authorization views"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from re import fullmatch
|
||||
from re import error as RegexError
|
||||
from re import escape, fullmatch
|
||||
from typing import Optional
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlsplit, urlunsplit
|
||||
from uuid import uuid4
|
||||
@ -68,7 +69,7 @@ from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_PARAMS = "params"
|
||||
SESSION_NEEDS_LOGIN = "authentik_oauth2_needs_login"
|
||||
SESSION_KEY_NEEDS_LOGIN = "authentik/providers/oauth2/needs_login"
|
||||
|
||||
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSENT, PROMPT_LOGIN}
|
||||
|
||||
@ -180,16 +181,26 @@ class OAuthAuthorizationParams:
|
||||
|
||||
if self.provider.redirect_uris == "":
|
||||
LOGGER.info("Setting redirect for blank redirect_uris", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = self.redirect_uri
|
||||
self.provider.redirect_uris = escape(self.redirect_uri)
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
excepted=allowed_redirect_urls,
|
||||
)
|
||||
if self.provider.redirect_uris == "*":
|
||||
LOGGER.info("Converting redirect_uris to regex", redirect=self.redirect_uri)
|
||||
self.provider.redirect_uris = ".*"
|
||||
self.provider.save()
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
except RegexError as exc:
|
||||
LOGGER.warning("Invalid regular expression configured", exc=exc)
|
||||
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
|
||||
if self.request:
|
||||
raise AuthorizeError(
|
||||
@ -315,13 +326,13 @@ class AuthorizationFlowInitView(PolicyAccessView):
|
||||
# If prompt=login, we need to re-authenticate the user regardless
|
||||
if (
|
||||
PROMPT_LOGIN in self.params.prompt
|
||||
and SESSION_NEEDS_LOGIN not in self.request.session
|
||||
and SESSION_KEY_NEEDS_LOGIN not in self.request.session
|
||||
# To prevent the user from having to double login when prompt is set to login
|
||||
# and the user has just signed it. This session variable is set in the UserLoginStage
|
||||
# and is (quite hackily) removed from the session in applications's API's List method
|
||||
and USER_LOGIN_AUTHENTICATED not in self.request.session
|
||||
):
|
||||
self.request.session[SESSION_NEEDS_LOGIN] = True
|
||||
self.request.session[SESSION_KEY_NEEDS_LOGIN] = True
|
||||
return self.handle_no_permission()
|
||||
# Regardless, we start the planner and return to it
|
||||
planner = FlowPlanner(self.provider.authorization_flow)
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""authentik OAuth2 JWKS Views"""
|
||||
from base64 import urlsafe_b64encode
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ec import (
|
||||
EllipticCurvePrivateKey,
|
||||
@ -26,8 +27,37 @@ def b64_enc(number: int) -> str:
|
||||
class JWKSView(View):
|
||||
"""Show RSA Key data for Provider"""
|
||||
|
||||
def get_jwk_for_key(self, key: CertificateKeyPair) -> Optional[dict]:
|
||||
"""Convert a certificate-key pair into JWK"""
|
||||
private_key = key.private_key
|
||||
if not private_key:
|
||||
return None
|
||||
if isinstance(private_key, RSAPrivateKey):
|
||||
public_key: RSAPublicKey = private_key.public_key()
|
||||
public_numbers = public_key.public_numbers()
|
||||
return {
|
||||
"kty": "RSA",
|
||||
"alg": JWTAlgorithms.RS256,
|
||||
"use": "sig",
|
||||
"kid": key.kid,
|
||||
"n": b64_enc(public_numbers.n),
|
||||
"e": b64_enc(public_numbers.e),
|
||||
}
|
||||
if isinstance(private_key, EllipticCurvePrivateKey):
|
||||
public_key: EllipticCurvePublicKey = private_key.public_key()
|
||||
public_numbers = public_key.public_numbers()
|
||||
return {
|
||||
"kty": "EC",
|
||||
"alg": JWTAlgorithms.ES256,
|
||||
"use": "sig",
|
||||
"kid": key.kid,
|
||||
"n": b64_enc(public_numbers.n),
|
||||
"e": b64_enc(public_numbers.e),
|
||||
}
|
||||
return None
|
||||
|
||||
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
|
||||
"""Show RSA Key data for Provider"""
|
||||
"""Show JWK Key data for Provider"""
|
||||
application = get_object_or_404(Application, slug=application_slug)
|
||||
provider: OAuth2Provider = get_object_or_404(OAuth2Provider, pk=application.provider_id)
|
||||
signing_key: CertificateKeyPair = provider.signing_key
|
||||
@ -35,33 +65,9 @@ class JWKSView(View):
|
||||
response_data = {}
|
||||
|
||||
if signing_key:
|
||||
private_key = signing_key.private_key
|
||||
if isinstance(private_key, RSAPrivateKey):
|
||||
public_key: RSAPublicKey = private_key.public_key()
|
||||
public_numbers = public_key.public_numbers()
|
||||
response_data["keys"] = [
|
||||
{
|
||||
"kty": "RSA",
|
||||
"alg": JWTAlgorithms.RS256,
|
||||
"use": "sig",
|
||||
"kid": signing_key.kid,
|
||||
"n": b64_enc(public_numbers.n),
|
||||
"e": b64_enc(public_numbers.e),
|
||||
}
|
||||
]
|
||||
elif isinstance(private_key, EllipticCurvePrivateKey):
|
||||
public_key: EllipticCurvePublicKey = private_key.public_key()
|
||||
public_numbers = public_key.public_numbers()
|
||||
response_data["keys"] = [
|
||||
{
|
||||
"kty": "EC",
|
||||
"alg": JWTAlgorithms.ES256,
|
||||
"use": "sig",
|
||||
"kid": signing_key.kid,
|
||||
"n": b64_enc(public_numbers.n),
|
||||
"e": b64_enc(public_numbers.e),
|
||||
}
|
||||
]
|
||||
jwk = self.get_jwk_for_key(signing_key)
|
||||
if jwk:
|
||||
response_data["keys"] = [jwk]
|
||||
|
||||
response = JsonResponse(response_data)
|
||||
response["Access-Control-Allow-Origin"] = "*"
|
||||
|
@ -2,13 +2,14 @@
|
||||
from base64 import urlsafe_b64encode
|
||||
from dataclasses import InitVar, dataclass
|
||||
from hashlib import sha256
|
||||
from re import error as RegexError
|
||||
from re import fullmatch
|
||||
from typing import Any, Optional
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.utils.timezone import datetime, now
|
||||
from django.views import View
|
||||
from jwt import InvalidTokenError, decode
|
||||
from jwt import PyJWK, PyJWTError, decode
|
||||
from sentry_sdk.hub import Hub
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
@ -42,6 +43,7 @@ from authentik.providers.oauth2.models import (
|
||||
RefreshToken,
|
||||
)
|
||||
from authentik.providers.oauth2.utils import TokenResponse, cors_allow, extract_client_auth
|
||||
from authentik.sources.oauth.models import OAuthSource
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@ -126,7 +128,7 @@ class TokenParams:
|
||||
with Hub.current.start_span(
|
||||
op="authentik.providers.oauth2.post.parse.code",
|
||||
):
|
||||
self.__post_init_code(raw_code)
|
||||
self.__post_init_code(raw_code, request)
|
||||
elif self.grant_type == GRANT_TYPE_REFRESH_TOKEN:
|
||||
with Hub.current.start_span(
|
||||
op="authentik.providers.oauth2.post.parse.refresh",
|
||||
@ -141,7 +143,7 @@ class TokenParams:
|
||||
LOGGER.warning("Invalid grant type", grant_type=self.grant_type)
|
||||
raise TokenError("unsupported_grant_type")
|
||||
|
||||
def __post_init_code(self, raw_code: str):
|
||||
def __post_init_code(self, raw_code: str, request: HttpRequest):
|
||||
if not raw_code:
|
||||
LOGGER.warning("Missing authorization code")
|
||||
raise TokenError("invalid_grant")
|
||||
@ -149,12 +151,28 @@ class TokenParams:
|
||||
allowed_redirect_urls = self.provider.redirect_uris.split()
|
||||
# At this point, no provider should have a blank redirect_uri, in case they do
|
||||
# this will check an empty array and raise an error
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
excepted=allowed_redirect_urls,
|
||||
)
|
||||
try:
|
||||
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
|
||||
LOGGER.warning(
|
||||
"Invalid redirect uri",
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect URI used by provider",
|
||||
provider=self.provider,
|
||||
redirect_uri=self.redirect_uri,
|
||||
expected=allowed_redirect_urls,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
except RegexError as exc:
|
||||
LOGGER.warning("Invalid regular expression configured", exc=exc)
|
||||
Event.new(
|
||||
EventAction.CONFIGURATION_ERROR,
|
||||
message="Invalid redirect_uri RegEx configured",
|
||||
provider=self.provider,
|
||||
).from_http(request)
|
||||
raise TokenError("invalid_client")
|
||||
|
||||
try:
|
||||
@ -253,17 +271,22 @@ class TokenParams:
|
||||
).from_http(request, user=user)
|
||||
return None
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def __post_init_client_credentials_jwt(self, request: HttpRequest):
|
||||
assertion_type = request.POST.get(CLIENT_ASSERTION_TYPE, "")
|
||||
if assertion_type != CLIENT_ASSERTION_TYPE_JWT:
|
||||
LOGGER.warning("Invalid assertion type", assertion_type=assertion_type)
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
client_secret = request.POST.get("client_secret", None)
|
||||
assertion = request.POST.get(CLIENT_ASSERTION, client_secret)
|
||||
if not assertion:
|
||||
LOGGER.warning("Missing client assertion")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
token = None
|
||||
|
||||
# TODO: Remove in 2022.7, deprecated field `verification_keys``
|
||||
for cert in self.provider.verification_keys.all():
|
||||
LOGGER.debug("verifying jwt with key", key=cert.name)
|
||||
cert: CertificateKeyPair
|
||||
@ -279,9 +302,34 @@ class TokenParams:
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
except (InvalidTokenError, ValueError, TypeError) as last_exc:
|
||||
LOGGER.warning("failed to validate jwt", last_exc=last_exc)
|
||||
except (PyJWTError, ValueError, TypeError) as exc:
|
||||
LOGGER.warning("failed to validate jwt", exc=exc)
|
||||
# TODO: End remove block
|
||||
|
||||
source: Optional[OAuthSource] = None
|
||||
parsed_key: Optional[PyJWK] = None
|
||||
for source in self.provider.jwks_sources.all():
|
||||
LOGGER.debug("verifying jwt with source", source=source.name)
|
||||
keys = source.oidc_jwks.get("keys", [])
|
||||
for key in keys:
|
||||
LOGGER.debug("verifying jwt with key", source=source.name, key=key.get("kid"))
|
||||
try:
|
||||
parsed_key = PyJWK.from_dict(key)
|
||||
token = decode(
|
||||
assertion,
|
||||
parsed_key.key,
|
||||
algorithms=[key.get("alg")],
|
||||
options={
|
||||
"verify_aud": False,
|
||||
},
|
||||
)
|
||||
# AttributeError is raised when the configured JWK is a private key
|
||||
# and not a public key
|
||||
except (PyJWTError, ValueError, TypeError, AttributeError) as exc:
|
||||
LOGGER.warning("failed to validate jwt", exc=exc)
|
||||
|
||||
if not token:
|
||||
LOGGER.warning("No token could be verified")
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
if "exp" in token:
|
||||
@ -297,27 +345,38 @@ class TokenParams:
|
||||
raise TokenError("invalid_grant")
|
||||
|
||||
self.__check_policy_access(app, request, oauth_jwt=token)
|
||||
self.__create_user_from_jwt(token, app)
|
||||
|
||||
self.user, _ = User.objects.update_or_create(
|
||||
method_args = {
|
||||
"jwt": token,
|
||||
}
|
||||
if source:
|
||||
method_args["source"] = source
|
||||
if parsed_key:
|
||||
method_args["jwk_id"] = parsed_key.key_id
|
||||
Event.new(
|
||||
action=EventAction.LOGIN,
|
||||
PLAN_CONTEXT_METHOD="jwt",
|
||||
PLAN_CONTEXT_METHOD_ARGS=method_args,
|
||||
PLAN_CONTEXT_APPLICATION=app,
|
||||
).from_http(request, user=self.user)
|
||||
|
||||
def __create_user_from_jwt(self, token: dict[str, Any], app: Application):
|
||||
"""Create user from JWT"""
|
||||
exp = token.get("exp")
|
||||
self.user, created = User.objects.update_or_create(
|
||||
username=f"{self.provider.name}-{token.get('sub')}",
|
||||
defaults={
|
||||
"attributes": {
|
||||
USER_ATTRIBUTE_GENERATED: True,
|
||||
USER_ATTRIBUTE_EXPIRES: token.get("exp"),
|
||||
},
|
||||
"last_login": now(),
|
||||
"name": f"Autogenerated user from application {app.name} (client credentials JWT)",
|
||||
},
|
||||
)
|
||||
|
||||
Event.new(
|
||||
action=EventAction.LOGIN,
|
||||
PLAN_CONTEXT_METHOD="jwt",
|
||||
PLAN_CONTEXT_METHOD_ARGS={
|
||||
"jwt": token,
|
||||
},
|
||||
PLAN_CONTEXT_APPLICATION=app,
|
||||
).from_http(request, user=self.user)
|
||||
if created and exp:
|
||||
self.user.attributes[USER_ATTRIBUTE_EXPIRES] = exp
|
||||
self.user.save()
|
||||
|
||||
|
||||
class TokenView(View):
|
||||
|
@ -103,6 +103,7 @@ class ProxyProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
"redirect_uris": ["iexact"],
|
||||
"cookie_domain": ["iexact"],
|
||||
}
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
||||
|
||||
@ -166,3 +167,5 @@ class ProxyOutpostConfigViewSet(ReadOnlyModelViewSet):
|
||||
queryset = ProxyProvider.objects.filter(application__isnull=False)
|
||||
serializer_class = ProxyOutpostConfigSerializer
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["name"]
|
||||
|
@ -12,8 +12,4 @@ class AuthentikProviderProxyConfig(AppConfig):
|
||||
verbose_name = "authentik Providers.Proxy"
|
||||
|
||||
def ready(self) -> None:
|
||||
from authentik.providers.proxy.tasks import proxy_set_defaults
|
||||
|
||||
import_module("authentik.providers.proxy.managed")
|
||||
|
||||
proxy_set_defaults.delay()
|
||||
|
@ -99,6 +99,7 @@ class SAMLProviderViewSet(UsedByMixin, ModelViewSet):
|
||||
serializer_class = SAMLProviderSerializer
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["name"]
|
||||
search_fields = ["name"]
|
||||
|
||||
@extend_schema(
|
||||
responses={
|
||||
@ -216,4 +217,5 @@ class SAMLPropertyMappingViewSet(UsedByMixin, ModelViewSet):
|
||||
queryset = SAMLPropertyMapping.objects.all()
|
||||
serializer_class = SAMLPropertyMappingSerializer
|
||||
filterset_class = SAMLPropertyMappingFilter
|
||||
search_fields = ["name"]
|
||||
ordering = ["name"]
|
||||
|
@ -52,7 +52,7 @@ class SAMLProvider(Provider):
|
||||
default=SAMLBindings.REDIRECT,
|
||||
verbose_name=_("Service Provider Binding"),
|
||||
help_text=_(
|
||||
("This determines how authentik sends the " "response back to the Service Provider.")
|
||||
("This determines how authentik sends the response back to the Service Provider.")
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -3,12 +3,13 @@ from base64 import b64decode
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import quote_plus
|
||||
from xml.etree.ElementTree import ParseError # nosec
|
||||
|
||||
import xmlsec
|
||||
from defusedxml import ElementTree
|
||||
from lxml import etree # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
@ -94,7 +95,7 @@ class AuthNRequestParser:
|
||||
|
||||
verifier = self.provider.verification_kp
|
||||
|
||||
root = etree.fromstring(decoded_xml) # nosec
|
||||
root = lxml_from_string(decoded_xml)
|
||||
xmlsec.tree.add_ids(root, ["ID"])
|
||||
signature_nodes = root.xpath("/samlp:AuthnRequest/ds:Signature", namespaces=NS_MAP)
|
||||
# No signatures, no verifier configured -> decode xml directly
|
||||
@ -175,7 +176,10 @@ class AuthNRequestParser:
|
||||
)
|
||||
except xmlsec.Error as exc:
|
||||
raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
try:
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
except ParseError as exc:
|
||||
raise CannotHandleAssertion(ERROR_FAILED_TO_VERIFY) from exc
|
||||
|
||||
def idp_initiated(self) -> AuthNRequest:
|
||||
"""Create IdP Initiated AuthNRequest"""
|
||||
|
@ -19,7 +19,7 @@ from authentik.sources.saml.processors.constants import (
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
)
|
||||
from authentik.sources.saml.processors.request import SESSION_REQUEST_ID, RequestProcessor
|
||||
from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID, RequestProcessor
|
||||
from authentik.sources.saml.processors.response import ResponseProcessor
|
||||
|
||||
POST_REQUEST = (
|
||||
@ -142,7 +142,7 @@ class TestAuthNRequest(TestCase):
|
||||
request = request_proc.build_auth_n()
|
||||
|
||||
# change the request ID
|
||||
http_request.session[SESSION_REQUEST_ID] = "test"
|
||||
http_request.session[SESSION_KEY_REQUEST_ID] = "test"
|
||||
http_request.session.save()
|
||||
|
||||
# To get an assertion we need a parsed request (parsed by provider)
|
||||
|
@ -6,6 +6,7 @@ from lxml import etree # nosec
|
||||
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.tests.utils import get_request
|
||||
from authentik.lib.xml import lxml_from_string
|
||||
from authentik.managed.manager import ObjectManager
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.assertion import AssertionProcessor
|
||||
@ -44,7 +45,7 @@ class TestSchema(TestCase):
|
||||
request_proc = RequestProcessor(self.source, http_request, "test_state")
|
||||
request = request_proc.build_auth_n()
|
||||
|
||||
metadata = etree.fromstring(request) # nosec
|
||||
metadata = lxml_from_string(request)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-protocol-2.0.xsd")) # nosec
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
@ -65,7 +66,7 @@ class TestSchema(TestCase):
|
||||
response_proc = AssertionProcessor(self.provider, http_request, parsed_request)
|
||||
response = response_proc.build_response()
|
||||
|
||||
metadata = etree.fromstring(response) # nosec
|
||||
metadata = lxml_from_string(response)
|
||||
|
||||
schema = etree.XMLSchema(etree.parse("xml/saml-schema-protocol-2.0.xsd"))
|
||||
self.assertTrue(schema.validate(metadata))
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user