Compare commits

..

5 Commits

Author SHA1 Message Date
fe5d22ce6c release: 2021.8.5 2021-09-10 22:10:35 +02:00
0e30b6ee55 lifecycle: fix worker startup error when docker socket's group is not called docker
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-09-10 22:05:00 +02:00
6cbba45291 web: ignore network error
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-09-10 21:51:11 +02:00
ba023a3bba outpost: update global outpost config on refresh
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-09-10 21:51:02 +02:00
6c805bcf32 sources/oauth: don't cancel flow when redirecting
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-09-10 21:50:45 +02:00
952 changed files with 24294 additions and 47632 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2021.12.1 current_version = 2021.8.5
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)

View File

@ -27,7 +27,7 @@ If applicable, add screenshots to help explain your problem.
Output of docker-compose logs or kubectl logs respectively Output of docker-compose logs or kubectl logs respectively
**Version and Deployment (please complete the following information):** **Version and Deployment (please complete the following information):**
- authentik version: [e.g. 2021.8.5] - authentik version: [e.g. 0.10.0-stable]
- Deployment: [e.g. docker-compose, helm] - Deployment: [e.g. docker-compose, helm]
**Additional context** **Additional context**

View File

@ -20,7 +20,7 @@ If applicable, add screenshots to help explain your problem.
Output of docker-compose logs or kubectl logs respectively Output of docker-compose logs or kubectl logs respectively
**Version and Deployment (please complete the following information):** **Version and Deployment (please complete the following information):**
- authentik version: [e.g. 2021.8.5] - authentik version: [e.g. 0.10.0-stable]
- Deployment: [e.g. docker-compose, helm] - Deployment: [e.g. docker-compose, helm]
**Additional context** **Additional context**

View File

@ -1,3 +0,0 @@
keypair
keypairs
hass

1
.github/stale.yml vendored
View File

@ -7,7 +7,6 @@ exemptLabels:
- pinned - pinned
- security - security
- pr_wanted - pr_wanted
- enhancement/confirmed
# Comment to post when marking an issue as stale. Set to `false` to disable # Comment to post when marking an issue as stale. Set to `false` to disable
markComment: > markComment: >
This issue has been automatically marked as stale because it has not had This issue has been automatically marked as stale because it has not had

View File

@ -18,17 +18,79 @@ env:
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77" POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
jobs: jobs:
lint: lint-pylint:
strategy: runs-on: ubuntu-latest
fail-fast: false steps:
matrix: - uses: actions/checkout@v2
job: - uses: actions/setup-python@v2
- pylint with:
- black python-version: '3.9'
- isort - id: cache-pipenv
- bandit uses: actions/cache@v2.1.6
- pyright with:
- pending-migrations path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- name: run pylint
run: pipenv run pylint authentik tests lifecycle
lint-black:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- id: cache-pipenv
uses: actions/cache@v2.1.6
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- name: run black
run: pipenv run black --check authentik tests lifecycle
lint-isort:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- id: cache-pipenv
uses: actions/cache@v2.1.6
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- name: run isort
run: pipenv run isort --check authentik tests lifecycle
lint-bandit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- id: cache-pipenv
uses: actions/cache@v2.1.6
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- name: run bandit
run: pipenv run bandit -r authentik tests lifecycle
lint-pyright:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -38,17 +100,12 @@ jobs:
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2
with: with:
node-version: '16' node-version: '16'
- id: cache-pipenv
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare - name: prepare
env: run: |
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} scripts/ci_prepare.sh
run: scripts/ci_prepare.sh npm install -g pyright@1.1.136
- name: run pylint - name: run bandit
run: pipenv run make ci-${{ matrix.job }} run: pipenv run pyright e2e lifecycle
test-migrations: test-migrations:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -57,10 +114,10 @@ jobs:
with: with:
python-version: '3.9' python-version: '3.9'
- id: cache-pipenv - id: cache-pipenv
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.6
with: with:
path: ~/.local/share/virtualenvs path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
@ -71,49 +128,30 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: actions/setup-python@v2 - uses: actions/setup-python@v2
with: with:
python-version: '3.9' python-version: '3.9'
- name: prepare variables
id: ev
run: |
python ./scripts/gh_env.py
- id: cache-pipenv
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
- name: checkout stable - name: checkout stable
run: | run: |
# Copy current, latest config to local # Copy current, latest config to local
cp authentik/lib/default.yml local.env.yml cp authentik/lib/default.yml local.env.yml
cp -R .github ..
cp -R scripts ..
git checkout $(git describe --abbrev=0 --match 'version/*') git checkout $(git describe --abbrev=0 --match 'version/*')
rm -rf .github/ scripts/ - id: cache-pipenv
mv ../.github ../scripts . uses: actions/cache@v2.1.6
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
run: | run: scripts/ci_prepare.sh
scripts/ci_prepare.sh
# Sync anyways since stable will have different dependencies
pipenv sync --dev
- name: run migrations to stable - name: run migrations to stable
run: pipenv run python -m lifecycle.migrate run: pipenv run python -m lifecycle.migrate
- name: checkout current code - name: checkout current code
run: | run: |
set -x set -x
git fetch git checkout $GITHUB_REF
git reset --hard HEAD
git checkout $GITHUB_HEAD_REF
pipenv sync --dev pipenv sync --dev
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
run: scripts/ci_prepare.sh
- name: migrate to latest - name: migrate to latest
run: pipenv run python -m lifecycle.migrate run: pipenv run python -m lifecycle.migrate
test-unittest: test-unittest:
@ -124,10 +162,10 @@ jobs:
with: with:
python-version: '3.9' python-version: '3.9'
- id: cache-pipenv - id: cache-pipenv
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.6
with: with:
path: ~/.local/share/virtualenvs path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
@ -153,10 +191,10 @@ jobs:
with: with:
python-version: '3.9' python-version: '3.9'
- id: cache-pipenv - id: cache-pipenv
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.6
with: with:
path: ~/.local/share/virtualenvs path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
@ -192,18 +230,18 @@ jobs:
with: with:
domain: ${{github.repository_owner}} domain: ${{github.repository_owner}}
- id: cache-pipenv - id: cache-pipenv
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.6
with: with:
path: ~/.local/share/virtualenvs path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }} key: ${{ runner.os }}-pipenv-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare - name: prepare
env: env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }} INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
run: | run: |
scripts/ci_prepare.sh scripts/ci_prepare.sh
docker-compose -f tests/e2e/docker-compose.yml up -d docker-compose -f tests/e2e/ci.docker-compose.yml up -d
- id: cache-web - id: cache-web
uses: actions/cache@v2.1.7 uses: actions/cache@v2.1.6
with: with:
path: web/dist path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }} key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
@ -223,30 +261,21 @@ jobs:
testspace [e2e]unittest.xml --link=codecov testspace [e2e]unittest.xml --link=codecov
- if: ${{ always() }} - if: ${{ always() }}
uses: codecov/codecov-action@v2 uses: codecov/codecov-action@v2
ci-core-mark: build:
needs: needs:
- lint - lint-pylint
- lint-black
- lint-isort
- lint-bandit
- lint-pyright
- test-migrations - test-migrations
- test-migrations-from-stable - test-migrations-from-stable
- test-unittest - test-unittest
- test-integration - test-integration
- test-e2e - test-e2e
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- run: echo mark
build:
needs: ci-core-mark
runs-on: ubuntu-latest
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
arch:
- 'linux/amd64'
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v1
- name: prepare variables - name: prepare variables
@ -254,21 +283,20 @@ jobs:
env: env:
DOCKER_USERNAME: ${{ secrets.HARBOR_USERNAME }} DOCKER_USERNAME: ${{ secrets.HARBOR_USERNAME }}
run: | run: |
python ./scripts/gh_env.py python ./scripts/gh_do_set_branch.py
- name: Login to Container Registry - name: Login to Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v1
if: ${{ steps.ev.outputs.shouldBuild == 'true' }} if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with: with:
registry: ghcr.io registry: beryju.org
username: ${{ github.repository_owner }} username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.HARBOR_PASSWORD }}
- name: Building Docker Image - name: Building Docker Image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
push: ${{ steps.ev.outputs.shouldBuild == 'true' }} push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
tags: | tags: |
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }} beryju.org/authentik/server:gh-${{ steps.ev.outputs.branchName }}
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }} beryju.org/authentik/server:gh-${{ steps.ev.outputs.branchName }}-${{ steps.ev.outputs.timestamp }}
build-args: | build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: ${{ matrix.arch }}

View File

@ -18,6 +18,9 @@ jobs:
- uses: actions/setup-go@v2 - uses: actions/setup-go@v2
with: with:
go-version: '^1.16.3' go-version: '^1.16.3'
- name: Generate API
run: |
make gen-outpost
- name: Run linter - name: Run linter
run: | run: |
# Create folder structure for go embeds # Create folder structure for go embeds
@ -30,24 +33,14 @@ jobs:
-w /app \ -w /app \
golangci/golangci-lint:v1.39.0 \ golangci/golangci-lint:v1.39.0 \
golangci-lint run -v --timeout 200s golangci-lint run -v --timeout 200s
ci-outpost-mark: build:
needs: needs:
- lint-golint - lint-golint
runs-on: ubuntu-latest
steps:
- run: echo mark
build:
timeout-minutes: 120
needs:
- ci-outpost-mark
strategy: strategy:
fail-fast: false
matrix: matrix:
type: type:
- proxy - proxy
- ldap - ldap
arch:
- 'linux/amd64'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -60,23 +53,23 @@ jobs:
env: env:
DOCKER_USERNAME: ${{ secrets.HARBOR_USERNAME }} DOCKER_USERNAME: ${{ secrets.HARBOR_USERNAME }}
run: | run: |
python ./scripts/gh_env.py python ./scripts/gh_do_set_branch.py
- name: Login to Container Registry - name: Login to Container Registry
uses: docker/login-action@v1 uses: docker/login-action@v1
if: ${{ steps.ev.outputs.shouldBuild == 'true' }} if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with: with:
registry: ghcr.io registry: beryju.org
username: ${{ github.repository_owner }} username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.HARBOR_PASSWORD }}
- name: Building Docker Image - name: Building Docker Image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
push: ${{ steps.ev.outputs.shouldBuild == 'true' }} push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
tags: | tags: |
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }} beryju.org/authentik/outpost-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchName }}
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }} beryju.org/authentik/outpost-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchName }}-${{ steps.ev.outputs.timestamp }}
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }} beryju.org/authentik/outpost-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
file: ${{ matrix.type }}.Dockerfile file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
build-args: | build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }} GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: ${{ matrix.arch }}

View File

@ -61,22 +61,16 @@ jobs:
npm install npm install
- name: Generate API - name: Generate API
run: make gen-web run: make gen-web
- name: lit-analyse - name: prettier
run: | run: |
cd web cd web
npm run lit-analyse npm run lit-analyse
ci-web-mark: build:
needs: needs:
- lint-eslint - lint-eslint
- lint-prettier - lint-prettier
- lint-lit-analyse - lint-lit-analyse
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- run: echo mark
build:
needs:
- ci-web-mark
runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2

View File

@ -1,22 +0,0 @@
name: ghcr-retention
on:
schedule:
- cron: '0 0 * * *' # every day at midnight
workflow_dispatch:
jobs:
clean-ghcr:
name: Delete old unused container images
runs-on: ubuntu-latest
steps:
- name: Delete 'dev' containers older than a week
uses: sondrelg/container-retention-policy@v1
with:
image-names: dev-server,dev-ldap,dev-proxy
cut-off: One week ago UTC
account-type: org
org-name: goauthentik
untagged-only: false
token: ${{ secrets.GHCR_CLEANUP_TOKEN }}
skip-tags: gh-next,gh-master

View File

@ -3,6 +3,9 @@ name: authentik-on-release
on: on:
release: release:
types: [published, created] types: [published, created]
push:
branches:
- version-*
jobs: jobs:
# Build # Build
@ -30,14 +33,14 @@ jobs:
with: with:
push: ${{ github.event_name == 'release' }} push: ${{ github.event_name == 'release' }}
tags: | tags: |
beryju/authentik:2021.12.1, beryju/authentik:2021.8.5,
beryju/authentik:latest, beryju/authentik:latest,
ghcr.io/goauthentik/server:2021.12.1, ghcr.io/goauthentik/server:2021.8.5,
ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:latest
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
context: . context: .
- name: Building Docker Image (stable) - name: Building Docker Image (stable)
if: ${{ github.event_name == 'release' && !contains('2021.12.1', 'rc') }} if: ${{ github.event_name == 'release' && !contains('2021.8.5', 'rc') }}
run: | run: |
docker pull beryju/authentik:latest docker pull beryju/authentik:latest
docker tag beryju/authentik:latest beryju/authentik:stable docker tag beryju/authentik:latest beryju/authentik:stable
@ -45,14 +48,8 @@ jobs:
docker pull ghcr.io/goauthentik/server:latest docker pull ghcr.io/goauthentik/server:latest
docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable
docker push ghcr.io/goauthentik/server:stable docker push ghcr.io/goauthentik/server:stable
build-outpost: build-proxy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
type:
- proxy
- ldap
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-go@v2 - uses: actions/setup-go@v2
@ -78,47 +75,97 @@ jobs:
with: with:
push: ${{ github.event_name == 'release' }} push: ${{ github.event_name == 'release' }}
tags: | tags: |
beryju/authentik-${{ matrix.type }}:2021.12.1, beryju/authentik-proxy:2021.8.5,
beryju/authentik-${{ matrix.type }}:latest, beryju/authentik-proxy:latest,
ghcr.io/goauthentik/${{ matrix.type }}:2021.12.1, ghcr.io/goauthentik/proxy:2021.8.5,
ghcr.io/goauthentik/${{ matrix.type }}:latest ghcr.io/goauthentik/proxy:latest
file: ${{ matrix.type }}.Dockerfile file: proxy.Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
- name: Building Docker Image (stable) - name: Building Docker Image (stable)
if: ${{ github.event_name == 'release' && !contains('2021.12.1', 'rc') }} if: ${{ github.event_name == 'release' && !contains('2021.8.5', 'rc') }}
run: | run: |
docker pull beryju/authentik-${{ matrix.type }}:latest docker pull beryju/authentik-proxy:latest
docker tag beryju/authentik-${{ matrix.type }}:latest beryju/authentik-${{ matrix.type }}:stable docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable
docker push beryju/authentik-${{ matrix.type }}:stable docker push beryju/authentik-proxy:stable
docker pull ghcr.io/goauthentik/${{ matrix.type }}:latest docker pull ghcr.io/goauthentik/proxy:latest
docker tag ghcr.io/goauthentik/${{ matrix.type }}:latest ghcr.io/goauthentik/${{ matrix.type }}:stable docker tag ghcr.io/goauthentik/proxy:latest ghcr.io/goauthentik/proxy:stable
docker push ghcr.io/goauthentik/${{ matrix.type }}:stable docker push ghcr.io/goauthentik/proxy:stable
build-ldap:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: "^1.15"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Docker Login Registry
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Building Docker Image
uses: docker/build-push-action@v2
with:
push: ${{ github.event_name == 'release' }}
tags: |
beryju/authentik-ldap:2021.8.5,
beryju/authentik-ldap:latest,
ghcr.io/goauthentik/ldap:2021.8.5,
ghcr.io/goauthentik/ldap:latest
file: ldap.Dockerfile
platforms: linux/amd64,linux/arm64
- name: Building Docker Image (stable)
if: ${{ github.event_name == 'release' && !contains('2021.8.5', 'rc') }}
run: |
docker pull beryju/authentik-ldap:latest
docker tag beryju/authentik-ldap:latest beryju/authentik-ldap:stable
docker push beryju/authentik-ldap:stable
docker pull ghcr.io/goauthentik/ldap:latest
docker tag ghcr.io/goauthentik/ldap:latest ghcr.io/goauthentik/ldap:stable
docker push ghcr.io/goauthentik/ldap:stable
test-release: test-release:
needs: needs:
- build-server - build-server
- build-outpost - build-proxy
- build-ldap
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Run test suite in final docker images - name: Run test suite in final docker images
run: | run: |
echo "PG_PASS=$(openssl rand -base64 32)" >> .env sudo apt-get install -y pwgen
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env echo "PG_PASS=$(pwgen 40 1)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env
docker-compose pull -q docker-compose pull -q
docker-compose up --no-start docker-compose up --no-start
docker-compose start postgresql redis docker-compose start postgresql redis
docker-compose run -u root server test docker-compose run -u root server test
sentry-release: sentry-release:
if: ${{ github.event_name == 'release' }}
needs: needs:
- test-release - test-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Get static files from docker image - name: Setup Node.js environment
uses: actions/setup-node@v2
with:
node-version: '16'
- name: Build web api client and web ui
run: | run: |
docker pull ghcr.io/goauthentik/server:latest export NODE_ENV=production
container=$(docker container create ghcr.io/goauthentik/server:latest) cd web
docker cp ${container}:web/ . npm i
npm run build
- name: Create a Sentry.io release - name: Create a Sentry.io release
uses: getsentry/action-release@v1 uses: getsentry/action-release@v1
if: ${{ github.event_name == 'release' }} if: ${{ github.event_name == 'release' }}
@ -128,7 +175,7 @@ jobs:
SENTRY_PROJECT: authentik SENTRY_PROJECT: authentik
SENTRY_URL: https://sentry.beryju.org SENTRY_URL: https://sentry.beryju.org
with: with:
version: authentik@2021.12.1 version: authentik@2021.8.5
environment: beryjuorg-prod environment: beryjuorg-prod
sourcemaps: './web/dist' sourcemaps: './web/dist'
url_prefix: '~/static/dist' url_prefix: '~/static/dist'

View File

@ -13,21 +13,21 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Pre-release test - name: Pre-release test
run: | run: |
echo "PG_PASS=$(openssl rand -base64 32)" >> .env sudo apt-get install -y pwgen
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env echo "AUTHENTIK_TAG=latest" >> .env
docker buildx install echo "PG_PASS=$(pwgen 40 1)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env
docker-compose pull -q
docker build \ docker build \
--no-cache \ --no-cache \
-t testing:latest \ -t ghcr.io/goauthentik/server:latest \
-f Dockerfile . -f Dockerfile .
echo "AUTHENTIK_IMAGE=testing" >> .env
echo "AUTHENTIK_TAG=latest" >> .env
docker-compose up --no-start docker-compose up --no-start
docker-compose start postgresql redis docker-compose start postgresql redis
docker-compose run -u root server test docker-compose run -u root server test
- name: Extract version number - name: Extract version number
id: get_version id: get_version
uses: actions/github-script@v5 uses: actions/github-script@v4.1
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

View File

@ -1,58 +0,0 @@
name: authentik-backend-translate-compile
on:
push:
branches: [ master ]
paths:
- '/locale/'
pull_request:
paths:
- '/locale/'
schedule:
- cron: "0 */2 * * *"
workflow_dispatch:
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
jobs:
compile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: '3.9'
- id: cache-pipenv
uses: actions/cache@v2.1.7
with:
path: ~/.local/share/virtualenvs
key: ${{ runner.os }}-pipenv-v2-${{ hashFiles('**/Pipfile.lock') }}
- name: prepare
env:
INSTALL: ${{ steps.cache-pipenv.outputs.cache-hit }}
run: |
sudo apt-get update
sudo apt-get install -y gettext
scripts/ci_prepare.sh
- name: run compile
run: pipenv run ./manage.py compilemessages
- name: Create Pull Request
uses: peter-evans/create-pull-request@v3
id: cpr
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: compile-backend-translation
commit-message: "core: compile backend translations"
title: "core: compile backend translations"
body: "core: compile backend translations"
delete-branch: true
signoff: true
- name: Enable Pull Request Automerge
if: steps.cpr.outputs.pull-request-operation == 'created'
uses: peter-evans/enable-pull-request-automerge@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: squash

View File

@ -30,19 +30,10 @@ jobs:
npm i @goauthentik/api@$VERSION npm i @goauthentik/api@$VERSION
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v3 uses: peter-evans/create-pull-request@v3
id: cpr
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
branch: update-web-api-client branch: update-web-api-client
commit-message: "web: Update Web API Client version" commit-message: "web: Update Web API Client version"
title: "web: Update Web API Client version" title: "web: Update Web API Client version"
body: "web: Update Web API Client version"
delete-branch: true delete-branch: true
signoff: true signoff: true
- name: Enable Pull Request Automerge
if: steps.cpr.outputs.pull-request-operation == 'created'
uses: peter-evans/enable-pull-request-automerge@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: squash

4
.gitignore vendored
View File

@ -66,9 +66,7 @@ coverage.xml
unittest.xml unittest.xml
# Translations # Translations
# Have to include binary mo files as they are annoying to compile at build time *.mo
# since a full postgres and redis instance are required
# *.mo
# Django stuff: # Django stuff:

View File

@ -1 +0,0 @@
3.9.7

View File

@ -10,8 +10,7 @@
"plex", "plex",
"saml", "saml",
"totp", "totp",
"webauthn", "webauthn"
"traefik"
], ],
"python.linting.pylintEnabled": true, "python.linting.pylintEnabled": true,
"todo-tree.tree.showCountsInTree": true, "todo-tree.tree.showCountsInTree": true,

View File

@ -31,7 +31,7 @@ Basically, don't be a dickhead. This is an open-source non-profit project, that
## I don't want to read this whole thing I just have a question!!! ## I don't want to read this whole thing I just have a question!!!
Either [create a question on GitHub](https://github.com/goauthentik/authentik/issues/new?assignees=&labels=question&template=question.md&title=) or join [the Discord server](https://goauthentik.io/discord) Either [create a question on GitHub](https://github.com/goauthentik/authentik/issues/new?assignees=&labels=question&template=question.md&title=) or join [the Discord server](https://discord.gg/jg33eMhnj6)
## What should I know before I get started? ## What should I know before I get started?
@ -117,7 +117,7 @@ This section guides you through submitting a bug report for authentik. Following
Whenever authentik encounters an error, it will be logged as an Event with the type `system_exception`. This event type has a button to directly open a pre-filled GitHub issue form. Whenever authentik encounters an error, it will be logged as an Event with the type `system_exception`. This event type has a button to directly open a pre-filled GitHub issue form.
This form will have the full stack trace of the error that occurred and shouldn't contain any sensitive data. This form will have the full stack trace of the error that ocurred and shouldn't contain any sensitive data.
### Suggesting Enhancements ### Suggesting Enhancements
@ -131,7 +131,7 @@ When you are creating an enhancement suggestion, please fill in [the template](h
authentik can be run locally, all though depending on which part you want to work on, different pre-requisites are required. authentik can be run locally, all though depending on which part you want to work on, different pre-requisites are required.
This is documented in the [developer docs](https://goauthentik.io/developer-docs/?utm_source=github) This is documented in the [developer docs](https://goauthentik.io/developer-docs/)
### Pull Requests ### Pull Requests

View File

@ -1,5 +1,5 @@
# Stage 1: Lock python dependencies # Stage 1: Lock python dependencies
FROM docker.io/python:3.10.1-slim-bullseye as locker FROM python:3.9-slim-buster as locker
COPY ./Pipfile /app/ COPY ./Pipfile /app/
COPY ./Pipfile.lock /app/ COPY ./Pipfile.lock /app/
@ -11,40 +11,59 @@ RUN pip install pipenv && \
pipenv lock -r --dev-only > requirements-dev.txt pipenv lock -r --dev-only > requirements-dev.txt
# Stage 2: Build website # Stage 2: Build website
FROM --platform=${BUILDPLATFORM} docker.io/node:16 as website-builder FROM node as website-builder
COPY ./website /work/website/ COPY ./website /static/
ENV NODE_ENV=production ENV NODE_ENV=production
RUN cd /work/website && npm i && npm run build-docs-only RUN cd /static && npm i && npm run build-docs-only
# Stage 3: Build webui # Stage 3: Generate API Client
FROM --platform=${BUILDPLATFORM} docker.io/node:16 as web-builder FROM openapitools/openapi-generator-cli as go-api-builder
COPY ./web /work/web/ COPY ./schema.yml /local/schema.yml
COPY ./website /work/website/
RUN docker-entrypoint.sh generate \
--git-host goauthentik.io \
--git-repo-id outpost \
--git-user-id api \
-i /local/schema.yml \
-g go \
-o /local/api \
--additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true && \
rm -f /local/api/go.mod /local/api/go.sum
# Stage 4: Build webui
FROM node as web-builder
COPY ./web /static/
ENV NODE_ENV=production ENV NODE_ENV=production
RUN cd /work/web && npm i && npm run build RUN cd /static && npm i && npm run build
# Stage 4: Build go proxy # Stage 5: Build go proxy
FROM docker.io/golang:1.17.5-bullseye AS builder FROM golang:1.17.0 AS builder
WORKDIR /work WORKDIR /work
COPY --from=web-builder /work/web/robots.txt /work/web/robots.txt COPY --from=web-builder /static/robots.txt /work/web/robots.txt
COPY --from=web-builder /work/web/security.txt /work/web/security.txt COPY --from=web-builder /static/security.txt /work/web/security.txt
COPY --from=web-builder /static/dist/ /work/web/dist/
COPY --from=web-builder /static/authentik/ /work/web/authentik/
COPY --from=website-builder /static/help/ /work/website/help/
COPY --from=go-api-builder /local/api api
COPY ./cmd /work/cmd COPY ./cmd /work/cmd
COPY ./web/static.go /work/web/static.go COPY ./web/static.go /work/web/static.go
COPY ./website/static.go /work/website/static.go
COPY ./internal /work/internal COPY ./internal /work/internal
COPY ./go.mod /work/go.mod COPY ./go.mod /work/go.mod
COPY ./go.sum /work/go.sum COPY ./go.sum /work/go.sum
RUN go build -o /work/authentik ./cmd/server/main.go RUN go build -o /work/authentik ./cmd/server/main.go
# Stage 5: Run # Stage 6: Run
FROM docker.io/python:3.10.1-slim-bullseye FROM python:3.9-slim-buster
WORKDIR / WORKDIR /
COPY --from=locker /app/requirements.txt / COPY --from=locker /app/requirements.txt /
@ -54,18 +73,19 @@ ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends curl ca-certificates gnupg git runit && \
curl ca-certificates gnupg git runit libpq-dev \ curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
postgresql-client build-essential libxmlsec1-dev \ echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
pkg-config libmaxminddb0 && \ apt-get update && \
apt-get install -y --no-install-recommends libpq-dev postgresql-client build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
pip install -r /requirements.txt --no-cache-dir && \ pip install -r /requirements.txt --no-cache-dir && \
apt-get remove --purge -y build-essential git && \ apt-get remove --purge -y build-essential git && \
apt-get autoremove --purge -y && \ apt-get autoremove --purge -y && \
apt-get clean && \ apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \ rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \ adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
mkdir -p /backups /certs /media && \ mkdir /backups && \
chown authentik:authentik /backups /certs /media chown authentik:authentik /backups
COPY ./authentik/ /authentik COPY ./authentik/ /authentik
COPY ./pyproject.toml / COPY ./pyproject.toml /
@ -74,16 +94,10 @@ COPY ./tests /tests
COPY ./manage.py / COPY ./manage.py /
COPY ./lifecycle/ /lifecycle COPY ./lifecycle/ /lifecycle
COPY --from=builder /work/authentik /authentik-proxy COPY --from=builder /work/authentik /authentik-proxy
COPY --from=web-builder /work/web/dist/ /web/dist/
COPY --from=web-builder /work/web/authentik/ /web/authentik/
COPY --from=website-builder /work/website/help/ /website/help/
USER authentik USER authentik
ENV TMPDIR /dev/shm/ ENV TMPDIR /dev/shm/
ENV PYTHONUNBUFFERED 1 ENV PYTHONUBUFFERED 1
ENV prometheus_multiproc_dir /dev/shm/
ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle" ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle"
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak", "healthcheck" ]
ENTRYPOINT [ "/lifecycle/ak" ] ENTRYPOINT [ "/lifecycle/ak" ]

View File

@ -4,40 +4,28 @@ UID = $(shell id -u)
GID = $(shell id -g) GID = $(shell id -g)
NPM_VERSION = $(shell python -m scripts.npm_version) NPM_VERSION = $(shell python -m scripts.npm_version)
all: lint-fix lint test gen web all: lint-fix lint test gen
test-integration: test-integration:
coverage run manage.py test tests/integration coverage run manage.py test -v 3 tests/integration
test-e2e: test-e2e:
coverage run manage.py test tests/e2e coverage run manage.py test --failfast -v 3 tests/e2e
test: test:
coverage run manage.py test authentik coverage run manage.py test -v 3 authentik
coverage html coverage html
coverage report coverage report
lint-fix: lint-fix:
isort authentik tests lifecycle isort authentik tests lifecycle
black authentik tests lifecycle black authentik tests lifecycle
codespell -I .github/codespell-words.txt -S 'web/src/locales/**' -w \
authentik \
internal \
cmd \
web/src \
website/src \
website/docs \
website/developer-docs
lint: lint:
pyright authentik tests lifecycle
bandit -r authentik tests lifecycle -x node_modules bandit -r authentik tests lifecycle -x node_modules
pylint authentik tests lifecycle pylint authentik tests lifecycle
i18n-extract: i18n-extract-core web-extract
i18n-extract-core:
./manage.py makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
gen-build: gen-build:
./manage.py spectacular --file schema.yml ./manage.py spectacular --file schema.yml
@ -61,61 +49,23 @@ gen-web:
\cp -rfv web-api/* web/node_modules/@goauthentik/api \cp -rfv web-api/* web/node_modules/@goauthentik/api
gen-outpost: gen-outpost:
wget https://raw.githubusercontent.com/goauthentik/client-go/main/config.yaml -O config.yaml
mkdir -p templates
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/README.mustache -O templates/README.mustache
wget https://raw.githubusercontent.com/goauthentik/client-go/main/templates/go.mod.mustache -O templates/go.mod.mustache
docker run \ docker run \
--rm -v ${PWD}:/local \ --rm -v ${PWD}:/local \
--user ${UID}:${GID} \ --user ${UID}:${GID} \
openapitools/openapi-generator-cli:v5.2.1 generate \ openapitools/openapi-generator-cli generate \
--git-host goauthentik.io \
--git-repo-id outpost \
--git-user-id api \
-i /local/schema.yml \ -i /local/schema.yml \
-g go \ -g go \
-o /local/api \ -o /local/api \
-c /local/config.yaml --additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true,disallowAdditionalPropertiesIfNotPresent=false
go mod edit -replace goauthentik.io/api=./api rm -f api/go.mod api/go.sum
rm -rf config.yaml ./templates/
gen: gen-build gen-clean gen-web gen: gen-build gen-clean gen-web gen-outpost
migrate: migrate:
python -m lifecycle.migrate python -m lifecycle.migrate
run: run:
go run -v cmd/server/main.go go run -v cmd/server/main.go
web-watch:
cd web && npm run watch
web: web-lint-fix web-lint web-extract
web-lint-fix:
cd web && npm run prettier
web-lint:
cd web && npm run lint
cd web && npm run lit-analyse
web-extract:
cd web && npm run extract
# These targets are use by GitHub actions to allow usage of matrix
# which makes the YAML File a lot smaller
ci-pylint:
pylint authentik tests lifecycle
ci-black:
black --check authentik tests lifecycle
ci-isort:
isort --check authentik tests lifecycle
ci-bandit:
bandit -r authentik tests lifecycle
ci-pyright:
pyright e2e lifecycle
ci-pending-migrations:
./manage.py makemigrations --check

28
Pipfile
View File

@ -8,10 +8,7 @@ boto3 = "*"
celery = "*" celery = "*"
channels = "*" channels = "*"
channels-redis = "*" channels-redis = "*"
codespell = "*"
colorama = "*"
dacite = "*" dacite = "*"
deepmerge = "*"
defusedxml = "*" defusedxml = "*"
django = "*" django = "*"
django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' } django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' }
@ -26,43 +23,44 @@ djangorestframework = "*"
djangorestframework-guardian = "*" djangorestframework-guardian = "*"
docker = "*" docker = "*"
drf-spectacular = "*" drf-spectacular = "*"
duo-client = "*"
facebook-sdk = "*" facebook-sdk = "*"
geoip2 = "*" geoip2 = "*"
gunicorn = "*" gunicorn = "*"
kubernetes = "==v19.15.0" kubernetes = "*"
ldap3 = "*" ldap3 = "*"
lxml = "*" lxml = ">=4.6.3"
packaging = "*" packaging = "*"
psycopg2-binary = "*" psycopg2-binary = "*"
pycryptodome = "*" pycryptodome = "*"
pyjwt = "*" pyjwt = "*"
pyyaml = "*" pyyaml = "*"
requests-oauthlib = "*" requests-oauthlib = "*"
sentry-sdk = { git = 'https://github.com/beryju/sentry-python.git', ref = '379aee28b15d3b87b381317746c4efd24b3d7bc3' } sentry-sdk = "*"
service_identity = "*" service_identity = "*"
structlog = "*" structlog = "*"
swagger-spec-validator = "*" swagger-spec-validator = "*"
twisted = "==21.7.0" twisted = "==21.7.0"
ua-parser = "*"
urllib3 = {extras = ["secure"],version = "*"} urllib3 = {extras = ["secure"],version = "*"}
uvicorn = {extras = ["standard"],version = "*"} uvicorn = {extras = ["standard"],version = "*"}
webauthn = "*" webauthn = "*"
xmlsec = "*" xmlsec = "*"
flower = "*" duo-client = "*"
wsproto = "*" ua-parser = "*"
deepmerge = "*"
colorama = "*"
[requires]
python_version = "3.9"
[dev-packages] [dev-packages]
bandit = "*" bandit = "*"
black = "==21.11b1" black = "==21.5b1"
bump2version = "*" bump2version = "*"
colorama = "*" colorama = "*"
coverage = {extras = ["toml"],version = "*"} coverage = "*"
pylint = "*" pylint = "*"
pylint-django = "*" pylint-django = "*"
pytest = "*" pytest = "*"
pytest-django = "*" pytest-django = "*"
pytest-randomly = "*"
requests-mock = "*"
selenium = "*" selenium = "*"
importlib-metadata = "*" requests-mock = "*"

2192
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
--- ---
[![Join Discord](https://img.shields.io/discord/809154715984199690?label=Discord&style=for-the-badge)](https://goauthentik.io/discord) [![Join Discord](https://img.shields.io/discord/809154715984199690?label=Discord&style=for-the-badge)](https://discord.gg/jg33eMhnj6)
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/goauthentik/authentik/authentik-ci-main?label=core%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-main.yml) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/goauthentik/authentik/authentik-ci-main?label=core%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-main.yml)
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/goauthentik/authentik/authentik-ci-outpost?label=outpost%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/goauthentik/authentik/authentik-ci-outpost?label=outpost%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml)
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/goauthentik/authentik/authentik-ci-web?label=web%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/goauthentik/authentik/authentik-ci-web?label=web%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-web.yml)
@ -20,9 +20,9 @@ authentik is an open-source Identity Provider focused on flexibility and versati
## Installation ## Installation
For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/?utm_source=github) For small/test setups it is recommended to use docker-compose, see the [documentation](https://goauthentik.io/docs/installation/docker-compose/)
For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/?utm_source=github) For bigger setups, there is a Helm Chart [here](https://github.com/goauthentik/helm). This is documented [here](https://goauthentik.io/docs/installation/kubernetes/)
## Screenshots ## Screenshots
@ -33,28 +33,8 @@ Light | Dark
## Development ## Development
See [Development Documentation](https://goauthentik.io/developer-docs/?utm_source=github) See [Development Documentation](https://goauthentik.io/developer-docs/)
## Security ## Security
See [SECURITY.md](SECURITY.md) See [SECURITY.md](SECURITY.md)
## Sponsors
This project is proudly sponsored by:
<p>
<a href="https://www.digitalocean.com/?utm_medium=opensource&utm_source=goauthentik.io">
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" width="201px">
</a>
</p>
DigitalOcean provides development and testing resources for authentik.
<p>
<a href="https://www.netlify.com">
<img src="https://www.netlify.com/img/global/badges/netlify-color-accent.svg" alt="Deploys by Netlify" />
</a>
</p>
Netlify hosts the [goauthentik.io](goauthentik.io) site.

View File

@ -6,8 +6,8 @@
| Version | Supported | | Version | Supported |
| ---------- | ------------------ | | ---------- | ------------------ |
| 2021.9.x | :white_check_mark: | | 2021.7.x | :white_check_mark: |
| 2021.10.x | :white_check_mark: | | 2021.8.x | :white_check_mark: |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@ -1,3 +1,3 @@
"""authentik""" """authentik"""
__version__ = "2021.12.1" __version__ = "2021.8.5"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -1,6 +1,13 @@
"""authentik administration metrics""" """authentik administration metrics"""
import time
from collections import Counter
from datetime import timedelta
from django.db.models import Count, ExpressionWrapper, F
from django.db.models.fields import DurationField
from django.db.models.functions import ExtractHour
from django.utils.timezone import now
from drf_spectacular.utils import extend_schema, extend_schema_field from drf_spectacular.utils import extend_schema, extend_schema_field
from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import IntegerField, SerializerMethodField from rest_framework.fields import IntegerField, SerializerMethodField
from rest_framework.permissions import IsAdminUser from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request from rest_framework.request import Request
@ -8,7 +15,31 @@ from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import EventAction from authentik.events.models import Event, EventAction
def get_events_per_1h(**filter_kwargs) -> list[dict[str, int]]:
"""Get event count by hour in the last day, fill with zeros"""
date_from = now() - timedelta(days=1)
result = (
Event.objects.filter(created__gte=date_from, **filter_kwargs)
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
.annotate(age_hours=ExtractHour("age"))
.values("age_hours")
.annotate(count=Count("pk"))
.order_by("age_hours")
)
data = Counter({int(d["age_hours"]): d["count"] for d in result})
results = []
_now = now()
for hour in range(0, -24, -1):
results.append(
{
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
"y_cord": data[hour * -1],
}
)
return results
class CoordinateSerializer(PassiveSerializer): class CoordinateSerializer(PassiveSerializer):
@ -27,22 +58,12 @@ class LoginMetricsSerializer(PassiveSerializer):
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_per_1h(self, _): def get_logins_per_1h(self, _):
"""Get successful logins per hour for the last 24 hours""" """Get successful logins per hour for the last 24 hours"""
user = self.context["user"] return get_events_per_1h(action=EventAction.LOGIN)
return (
get_objects_for_user(user, "authentik_events.view_event")
.filter(action=EventAction.LOGIN)
.get_events_per_hour()
)
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_failed_per_1h(self, _): def get_logins_failed_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours""" """Get failed logins per hour for the last 24 hours"""
user = self.context["user"] return get_events_per_1h(action=EventAction.LOGIN_FAILED)
return (
get_objects_for_user(user, "authentik_events.view_event")
.filter(action=EventAction.LOGIN_FAILED)
.get_events_per_hour()
)
class AdministrationMetricsViewSet(APIView): class AdministrationMetricsViewSet(APIView):
@ -54,5 +75,4 @@ class AdministrationMetricsViewSet(APIView):
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
"""Login Metrics per 1h""" """Login Metrics per 1h"""
serializer = LoginMetricsSerializer(True) serializer = LoginMetricsSerializer(True)
serializer.context["user"] = request.user
return Response(serializer.data) return Response(serializer.data)

View File

@ -84,9 +84,9 @@ class SystemSerializer(PassiveSerializer):
return now() return now()
def get_embedded_outpost_host(self, request: Request) -> str: def get_embedded_outpost_host(self, request: Request) -> str:
"""Get the FQDN configured on the embedded outpost""" """Get the FQDN configured on the embeddded outpost"""
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts.exists(): # pragma: no cover if not outposts.exists():
return "" return ""
return outposts.first().config.authentik_host return outposts.first().config.authentik_host

View File

@ -36,7 +36,7 @@ class TaskSerializer(PassiveSerializer):
are pickled in cache. In that case, just delete the info""" are pickled in cache. In that case, just delete the info"""
try: try:
return super().to_representation(instance) return super().to_representation(instance)
except AttributeError: # pragma: no cover except AttributeError:
if isinstance(self.instance, list): if isinstance(self.instance, list):
for inst in self.instance: for inst in self.instance:
inst.delete() inst.delete()

View File

@ -1,5 +1,4 @@
"""authentik administration overview""" """authentik administration overview"""
from django.conf import settings
from drf_spectacular.utils import extend_schema, inline_serializer from drf_spectacular.utils import extend_schema, inline_serializer
from prometheus_client import Gauge from prometheus_client import Gauge
from rest_framework.fields import IntegerField from rest_framework.fields import IntegerField
@ -22,7 +21,4 @@ class WorkerView(APIView):
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
"""Get currently connected worker count.""" """Get currently connected worker count."""
count = len(CELERY_APP.control.ping(timeout=0.5)) count = len(CELERY_APP.control.ping(timeout=0.5))
# In debug we run with `CELERY_TASK_ALWAYS_EAGER`, so tasks are ran on the main process
if settings.DEBUG: # pragma: no cover
count += 1
return Response({"count": count}) return Response({"count": count})

View File

@ -8,8 +8,3 @@ class AuthentikAdminConfig(AppConfig):
name = "authentik.admin" name = "authentik.admin"
label = "authentik_admin" label = "authentik_admin"
verbose_name = "authentik Admin" verbose_name = "authentik Admin"
def ready(self):
from authentik.admin.tasks import clear_update_notifications
clear_update_notifications.delay()

View File

@ -6,19 +6,12 @@ from django.core.cache import cache
from django.core.validators import URLValidator from django.core.validators import URLValidator
from packaging.version import parse from packaging.version import parse
from prometheus_client import Info from prometheus_client import Info
from requests import RequestException from requests import RequestException, get
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik import ENV_GIT_HASH_KEY, __version__ from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.events.models import Event, EventAction, Notification from authentik.events.models import Event, EventAction
from authentik.events.monitored_tasks import ( from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_http_session
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
LOGGER = get_logger() LOGGER = get_logger()
@ -27,7 +20,6 @@ VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
# Chop of the first ^ because we want to search the entire string # Chop of the first ^ because we want to search the entire string
URL_FINDER = URLValidator.regex.pattern[1:] URL_FINDER = URLValidator.regex.pattern[1:]
PROM_INFO = Info("authentik_version", "Currently running authentik version") PROM_INFO = Info("authentik_version", "Currently running authentik version")
LOCAL_VERSION = parse(__version__)
def _set_prom_info(): def _set_prom_info():
@ -41,33 +33,15 @@ def _set_prom_info():
) )
@CELERY_APP.task()
def clear_update_notifications():
"""Clear update notifications on startup if the notification was for the version
we're running now."""
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
if "new_version" not in notification.event.context:
continue
notification_version = notification.event.context["new_version"]
if LOCAL_VERSION >= parse(notification_version):
notification.delete()
@CELERY_APP.task(bind=True, base=MonitoredTask) @CELERY_APP.task(bind=True, base=MonitoredTask)
@prefill_task
def update_latest_version(self: MonitoredTask): def update_latest_version(self: MonitoredTask):
"""Update latest version info""" """Update latest version info"""
if CONFIG.y_bool("disable_update_check"):
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
self.set_status(TaskResult(TaskResultStatus.WARNING, messages=["Version check disabled."]))
return
try: try:
response = get_http_session().get( response = get("https://api.github.com/repos/goauthentik/authentik/releases/latest")
"https://version.goauthentik.io/version.json",
)
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
upstream_version = data.get("stable", {}).get("version") tag_name = data.get("tag_name")
upstream_version = tag_name.split("/")[1]
cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT) cache.set(VERSION_CACHE_KEY, upstream_version, VERSION_CACHE_TIMEOUT)
self.set_status( self.set_status(
TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]) TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"])
@ -75,7 +49,8 @@ def update_latest_version(self: MonitoredTask):
_set_prom_info() _set_prom_info()
# Check if upstream version is newer than what we're running, # Check if upstream version is newer than what we're running,
# and if no event exists yet, create one. # and if no event exists yet, create one.
if LOCAL_VERSION < parse(upstream_version): local_version = parse(__version__)
if local_version < parse(upstream_version):
# Event has already been created, don't create duplicate # Event has already been created, don't create duplicate
if Event.objects.filter( if Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE, action=EventAction.UPDATE_AVAILABLE,
@ -83,7 +58,7 @@ def update_latest_version(self: MonitoredTask):
).exists(): ).exists():
return return
event_dict = {"new_version": upstream_version} event_dict = {"new_version": upstream_version}
if match := re.search(URL_FINDER, data.get("stable", {}).get("changelog", "")): if match := re.search(URL_FINDER, data.get("body", "")):
event_dict["message"] = f"Changelog: {match.group()}" event_dict["message"] = f"Changelog: {match.group()}"
Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save() Event.new(EventAction.UPDATE_AVAILABLE, **event_dict).save()
except (RequestException, IndexError) as exc: except (RequestException, IndexError) as exc:

View File

@ -8,7 +8,6 @@ from authentik import __version__
from authentik.core.models import Group, User from authentik.core.models import Group, User
from authentik.core.tasks import clean_expired_models from authentik.core.tasks import clean_expired_models
from authentik.events.monitored_tasks import TaskResultStatus from authentik.events.monitored_tasks import TaskResultStatus
from authentik.managed.tasks import managed_reconcile
class TestAdminAPI(TestCase): class TestAdminAPI(TestCase):
@ -95,7 +94,5 @@ class TestAdminAPI(TestCase):
def test_system(self): def test_system(self):
"""Test system API""" """Test system API"""
# pyright: reportGeneralTypeIssues=false
managed_reconcile() # pylint: disable=no-value-for-parameter
response = self.client.get(reverse("authentik_api:admin_system")) response = self.client.get(reverse("authentik_api:admin_system"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)

View File

@ -1,33 +1,52 @@
"""test admin tasks""" """test admin tasks"""
import json
from dataclasses import dataclass
from unittest.mock import Mock, patch
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase from django.test import TestCase
from requests_mock import Mocker from requests.exceptions import RequestException
from authentik.admin.tasks import ( from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
VERSION_CACHE_KEY,
clear_update_notifications,
update_latest_version,
)
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG
RESPONSE_VALID = {
"$schema": "https://version.goauthentik.io/schema.json", @dataclass
"stable": { class MockResponse:
"version": "99999999.9999999", """Mock class to emulate the methods of requests's Response we need"""
"changelog": "See https://goauthentik.io/test",
"reason": "bugfix", status_code: int
}, response: str
}
def json(self) -> dict:
"""Get json parsed response"""
return json.loads(self.response)
def raise_for_status(self):
"""raise RequestException if status code is 400 or more"""
if self.status_code >= 400:
raise RequestException
REQUEST_MOCK_VALID = Mock(
return_value=MockResponse(
200,
"""{
"tag_name": "version/99999999.9999999",
"body": "https://goauthentik.io/test"
}""",
)
)
REQUEST_MOCK_INVALID = Mock(return_value=MockResponse(400, "{}"))
class TestAdminTasks(TestCase): class TestAdminTasks(TestCase):
"""test admin tasks""" """test admin tasks"""
@patch("authentik.admin.tasks.get", REQUEST_MOCK_VALID)
def test_version_valid_response(self): def test_version_valid_response(self):
"""Test Update checker with valid response""" """Test Update checker with valid response"""
with Mocker() as mocker:
mocker.get("https://version.goauthentik.io/version.json", json=RESPONSE_VALID)
update_latest_version.delay().get() update_latest_version.delay().get()
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999") self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
self.assertTrue( self.assertTrue(
@ -50,10 +69,9 @@ class TestAdminTasks(TestCase):
1, 1,
) )
@patch("authentik.admin.tasks.get", REQUEST_MOCK_INVALID)
def test_version_error(self): def test_version_error(self):
"""Test Update checker with invalid response""" """Test Update checker with invalid response"""
with Mocker() as mocker:
mocker.get("https://version.goauthentik.io/version.json", status_code=400)
update_latest_version.delay().get() update_latest_version.delay().get()
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0") self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
self.assertFalse( self.assertFalse(
@ -61,23 +79,3 @@ class TestAdminTasks(TestCase):
action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0" action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
).exists() ).exists()
) )
def test_version_disabled(self):
"""Test Update checker while its disabled"""
with CONFIG.patch("disable_update_check", True):
update_latest_version.delay().get()
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
def test_clear_update_notifications(self):
"""Test clear of previous notification"""
Event.objects.create(
action=EventAction.UPDATE_AVAILABLE, context={"new_version": "99999999.9999999.9999999"}
)
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"})
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={})
clear_update_notifications()
self.assertFalse(
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1"
).exists()
)

View File

@ -9,7 +9,6 @@ from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request from rest_framework.request import Request
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.middleware import KEY_AUTH_VIA, LOCAL
from authentik.core.models import Token, TokenIntents, User from authentik.core.models import Token, TokenIntents, User
from authentik.outposts.models import Outpost from authentik.outposts.models import Outpost
@ -41,12 +40,11 @@ def bearer_auth(raw_header: bytes) -> Optional[User]:
raise AuthenticationFailed("Malformed header") raise AuthenticationFailed("Malformed header")
tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API) tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
if not tokens.exists(): if not tokens.exists():
LOGGER.info("Authenticating via secret_key")
user = token_secret_key(password) user = token_secret_key(password)
if not user: if not user:
raise AuthenticationFailed("Token invalid/expired") raise AuthenticationFailed("Token invalid/expired")
return user return user
if hasattr(LOCAL, "authentik"):
LOCAL.authentik[KEY_AUTH_VIA] = "api_token"
return tokens.first().user return tokens.first().user
@ -60,8 +58,6 @@ def token_secret_key(value: str) -> Optional[User]:
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST) outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts: if not outposts:
return None return None
if hasattr(LOCAL, "authentik"):
LOCAL.authentik[KEY_AUTH_VIA] = "secret_key"
outpost = outposts.first() outpost = outposts.first()
return outpost.user return outpost.user

View File

@ -33,12 +33,3 @@ class OwnerPermissions(BasePermission):
if owner != request.user: if owner != request.user:
return False return False
return True return True
class OwnerSuperuserPermissions(OwnerPermissions):
"""Similar to OwnerPermissions, except always allow access for superusers"""
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
if request.user.is_superuser:
return True
return super().has_object_permission(request, view, obj)

View File

@ -5,9 +5,6 @@ from typing import Callable, Optional
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
LOGGER = get_logger()
def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None): def permission_required(perm: Optional[str] = None, other_perms: Optional[list[str]] = None):
@ -21,12 +18,10 @@ def permission_required(perm: Optional[str] = None, other_perms: Optional[list[s
if perm: if perm:
obj = self.get_object() obj = self.get_object()
if not request.user.has_perm(perm, obj): if not request.user.has_perm(perm, obj):
LOGGER.debug("denying access for object", user=request.user, perm=perm, obj=obj)
return self.permission_denied(request) return self.permission_denied(request)
if other_perms: if other_perms:
for other_perm in other_perms: for other_perm in other_perms:
if not request.user.has_perm(other_perm): if not request.user.has_perm(other_perm):
LOGGER.debug("denying access for other", user=request.user, perm=perm)
return self.permission_denied(request) return self.permission_denied(request)
return func(self, request, *args, **kwargs) return func(self, request, *args, **kwargs)

View File

@ -11,7 +11,7 @@ from drf_spectacular.types import OpenApiTypes
def build_standard_type(obj, **kwargs): def build_standard_type(obj, **kwargs):
"""Build a basic type with optional add owns.""" """Build a basic type with optional add ons."""
schema = build_basic_type(obj) schema = build_basic_type(obj)
schema.update(kwargs) schema.update(kwargs)
return schema return schema
@ -31,7 +31,7 @@ VALIDATION_ERROR = build_object_type(
"non_field_errors": build_array_type(build_standard_type(OpenApiTypes.STR)), "non_field_errors": build_array_type(build_standard_type(OpenApiTypes.STR)),
"code": build_standard_type(OpenApiTypes.STR), "code": build_standard_type(OpenApiTypes.STR),
}, },
required=[], required=["detail"],
additionalProperties={}, additionalProperties={},
) )

View File

@ -4,7 +4,7 @@ from django.urls import include, path
from authentik.api.v3.urls import urlpatterns as v3_urls from authentik.api.v3.urls import urlpatterns as v3_urls
urlpatterns = [ urlpatterns = [
# TODO: Remove in 2022.1 # Remove in 2022.1
path("v2beta/", include(v3_urls)), path("v2beta/", include(v3_urls)),
path("v3/", include(v3_urls)), path("v3/", include(v3_urls)),
] ]

View File

@ -5,14 +5,7 @@ from django.conf import settings
from django.db import models from django.db import models
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
from rest_framework.fields import ( from rest_framework.fields import BooleanField, CharField, ChoiceField, IntegerField, ListField
BooleanField,
CharField,
ChoiceField,
FloatField,
IntegerField,
ListField,
)
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
@ -31,19 +24,13 @@ class Capabilities(models.TextChoices):
CAN_BACKUP = "can_backup" CAN_BACKUP = "can_backup"
class ErrorReportingConfigSerializer(PassiveSerializer):
"""Config for error reporting"""
enabled = BooleanField(read_only=True)
environment = CharField(read_only=True)
send_pii = BooleanField(read_only=True)
traces_sample_rate = FloatField(read_only=True)
class ConfigSerializer(PassiveSerializer): class ConfigSerializer(PassiveSerializer):
"""Serialize authentik Config into DRF Object""" """Serialize authentik Config into DRF Object"""
error_reporting = ErrorReportingConfigSerializer(required=True) error_reporting_enabled = BooleanField(read_only=True)
error_reporting_environment = CharField(read_only=True)
error_reporting_send_pii = BooleanField(read_only=True)
capabilities = ListField(child=ChoiceField(choices=Capabilities.choices)) capabilities = ListField(child=ChoiceField(choices=Capabilities.choices))
cache_timeout = IntegerField(required=True) cache_timeout = IntegerField(required=True)
@ -76,15 +63,12 @@ class ConfigView(APIView):
@extend_schema(responses={200: ConfigSerializer(many=False)}) @extend_schema(responses={200: ConfigSerializer(many=False)})
def get(self, request: Request) -> Response: def get(self, request: Request) -> Response:
"""Retrieve public configuration options""" """Retrive public configuration options"""
config = ConfigSerializer( config = ConfigSerializer(
{ {
"error_reporting": { "error_reporting_enabled": CONFIG.y("error_reporting.enabled"),
"enabled": CONFIG.y("error_reporting.enabled"), "error_reporting_environment": CONFIG.y("error_reporting.environment"),
"environment": CONFIG.y("error_reporting.environment"), "error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"),
"send_pii": CONFIG.y("error_reporting.send_pii"),
"traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)),
},
"capabilities": self.get_capabilities(), "capabilities": self.get_capabilities(),
"cache_timeout": int(CONFIG.y("redis.cache_timeout")), "cache_timeout": int(CONFIG.y("redis.cache_timeout")),
"cache_timeout_flows": int(CONFIG.y("redis.cache_timeout_flows")), "cache_timeout_flows": int(CONFIG.y("redis.cache_timeout_flows")),

View File

@ -0,0 +1,66 @@
"""Sentry tunnel"""
from json import loads
from django.conf import settings
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from requests import post
from requests.exceptions import RequestException
from rest_framework.authentication import SessionAuthentication
from rest_framework.parsers import BaseParser
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.throttling import AnonRateThrottle
from rest_framework.views import APIView
from authentik.lib.config import CONFIG
class PlainTextParser(BaseParser):
"""Plain text parser."""
media_type = "text/plain"
def parse(self, stream, media_type=None, parser_context=None) -> str:
"""Simply return a string representing the body of the request."""
return stream.read()
class CsrfExemptSessionAuthentication(SessionAuthentication):
"""CSRF-exempt Session authentication"""
def enforce_csrf(self, request: Request):
return # To not perform the csrf check previously happening
class SentryTunnelView(APIView):
"""Sentry tunnel, to prevent ad blockers from blocking sentry"""
serializer_class = None
parser_classes = [PlainTextParser]
throttle_classes = [AnonRateThrottle]
permission_classes = [AllowAny]
authentication_classes = [CsrfExemptSessionAuthentication]
def post(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
"""Sentry tunnel, to prevent ad blockers from blocking sentry"""
# Only allow usage of this endpoint when error reporting is enabled
if not CONFIG.y_bool("error_reporting.enabled", False):
return HttpResponse(status=400)
# Body is 2 json objects separated by \n
full_body = request.body
header = loads(full_body.splitlines()[0])
# Check that the DSN is what we expect
dsn = header.get("dsn", "")
if dsn != settings.SENTRY_DSN:
return HttpResponse(status=400)
response = post(
"https://sentry.beryju.org/api/8/envelope/",
data=full_body,
headers={"Content-Type": "application/octet-stream"},
)
try:
response.raise_for_status()
except RequestException:
return HttpResponse(status=500)
return HttpResponse(status=response.status_code)

View File

@ -11,27 +11,25 @@ from authentik.admin.api.tasks import TaskViewSet
from authentik.admin.api.version import VersionView from authentik.admin.api.version import VersionView
from authentik.admin.api.workers import WorkerView from authentik.admin.api.workers import WorkerView
from authentik.api.v3.config import ConfigView from authentik.api.v3.config import ConfigView
from authentik.api.v3.sentry import SentryTunnelView
from authentik.api.views import APIBrowserView from authentik.api.views import APIBrowserView
from authentik.core.api.applications import ApplicationViewSet from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
from authentik.core.api.devices import DeviceViewSet
from authentik.core.api.groups import GroupViewSet from authentik.core.api.groups import GroupViewSet
from authentik.core.api.propertymappings import PropertyMappingViewSet from authentik.core.api.propertymappings import PropertyMappingViewSet
from authentik.core.api.providers import ProviderViewSet from authentik.core.api.providers import ProviderViewSet
from authentik.core.api.sources import SourceViewSet, UserSourceConnectionViewSet from authentik.core.api.sources import SourceViewSet
from authentik.core.api.tokens import TokenViewSet from authentik.core.api.tokens import TokenViewSet
from authentik.core.api.users import UserViewSet from authentik.core.api.users import UserViewSet
from authentik.crypto.api import CertificateKeyPairViewSet from authentik.crypto.api import CertificateKeyPairViewSet
from authentik.events.api.event import EventViewSet from authentik.events.api.event import EventViewSet
from authentik.events.api.notification import NotificationViewSet from authentik.events.api.notification import NotificationViewSet
from authentik.events.api.notification_mapping import NotificationWebhookMappingViewSet
from authentik.events.api.notification_rule import NotificationRuleViewSet from authentik.events.api.notification_rule import NotificationRuleViewSet
from authentik.events.api.notification_transport import NotificationTransportViewSet from authentik.events.api.notification_transport import NotificationTransportViewSet
from authentik.flows.api.bindings import FlowStageBindingViewSet from authentik.flows.api.bindings import FlowStageBindingViewSet
from authentik.flows.api.flows import FlowViewSet from authentik.flows.api.flows import FlowViewSet
from authentik.flows.api.stages import StageViewSet from authentik.flows.api.stages import StageViewSet
from authentik.flows.views.executor import FlowExecutorView from authentik.flows.views import FlowExecutorView
from authentik.flows.views.inspector import FlowInspectorView
from authentik.outposts.api.outposts import OutpostViewSet from authentik.outposts.api.outposts import OutpostViewSet
from authentik.outposts.api.service_connections import ( from authentik.outposts.api.service_connections import (
DockerServiceConnectionViewSet, DockerServiceConnectionViewSet,
@ -68,11 +66,6 @@ from authentik.stages.authenticator_duo.api import (
DuoAdminDeviceViewSet, DuoAdminDeviceViewSet,
DuoDeviceViewSet, DuoDeviceViewSet,
) )
from authentik.stages.authenticator_sms.api import (
AuthenticatorSMSStageViewSet,
SMSAdminDeviceViewSet,
SMSDeviceViewSet,
)
from authentik.stages.authenticator_static.api import ( from authentik.stages.authenticator_static.api import (
AuthenticatorStaticStageViewSet, AuthenticatorStaticStageViewSet,
StaticAdminDeviceViewSet, StaticAdminDeviceViewSet,
@ -105,7 +98,6 @@ from authentik.stages.user_write.api import UserWriteStageViewSet
from authentik.tenants.api import TenantViewSet from authentik.tenants.api import TenantViewSet
router = routers.DefaultRouter() router = routers.DefaultRouter()
router.include_format_suffixes = False
router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks") router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
router.register("admin/apps", AppsViewSet, basename="apps") router.register("admin/apps", AppsViewSet, basename="apps")
@ -136,7 +128,6 @@ router.register("events/transports", NotificationTransportViewSet)
router.register("events/rules", NotificationRuleViewSet) router.register("events/rules", NotificationRuleViewSet)
router.register("sources/all", SourceViewSet) router.register("sources/all", SourceViewSet)
router.register("sources/user_connections/all", UserSourceConnectionViewSet)
router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet) router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet)
router.register("sources/user_connections/plex", PlexSourceConnectionViewSet) router.register("sources/user_connections/plex", PlexSourceConnectionViewSet)
router.register("sources/ldap", LDAPSourceViewSet) router.register("sources/ldap", LDAPSourceViewSet)
@ -168,11 +159,8 @@ router.register("propertymappings/all", PropertyMappingViewSet)
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet) router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
router.register("propertymappings/saml", SAMLPropertyMappingViewSet) router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
router.register("propertymappings/scope", ScopeMappingViewSet) router.register("propertymappings/scope", ScopeMappingViewSet)
router.register("propertymappings/notification", NotificationWebhookMappingViewSet)
router.register("authenticators/all", DeviceViewSet, basename="device")
router.register("authenticators/duo", DuoDeviceViewSet) router.register("authenticators/duo", DuoDeviceViewSet)
router.register("authenticators/sms", SMSDeviceViewSet)
router.register("authenticators/static", StaticDeviceViewSet) router.register("authenticators/static", StaticDeviceViewSet)
router.register("authenticators/totp", TOTPDeviceViewSet) router.register("authenticators/totp", TOTPDeviceViewSet)
router.register("authenticators/webauthn", WebAuthnDeviceViewSet) router.register("authenticators/webauthn", WebAuthnDeviceViewSet)
@ -181,11 +169,6 @@ router.register(
DuoAdminDeviceViewSet, DuoAdminDeviceViewSet,
basename="admin-duodevice", basename="admin-duodevice",
) )
router.register(
"authenticators/admin/sms",
SMSAdminDeviceViewSet,
basename="admin-smsdevice",
)
router.register( router.register(
"authenticators/admin/static", "authenticators/admin/static",
StaticAdminDeviceViewSet, StaticAdminDeviceViewSet,
@ -200,7 +183,6 @@ router.register(
router.register("stages/all", StageViewSet) router.register("stages/all", StageViewSet)
router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet) router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet)
router.register("stages/authenticator/sms", AuthenticatorSMSStageViewSet)
router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet) router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet)
router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet) router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet)
router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet) router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet)
@ -243,11 +225,7 @@ urlpatterns = (
FlowExecutorView.as_view(), FlowExecutorView.as_view(),
name="flow-executor", name="flow-executor",
), ),
path( path("sentry/", SentryTunnelView.as_view(), name="sentry"),
"flows/inspector/<slug:flow_slug>/",
FlowInspectorView.as_view(),
name="flow-inspector",
),
path("schema/", cache_page(86400)(SpectacularAPIView.as_view()), name="schema"), path("schema/", cache_page(86400)(SpectacularAPIView.as_view()), name="schema"),
] ]
) )

View File

@ -5,7 +5,6 @@ from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField from rest_framework.fields import ReadOnlyField
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
@ -16,7 +15,7 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter from rest_framework_guardian.filters import ObjectPermissionsFilter
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
from authentik.api.decorators import permission_required from authentik.api.decorators import permission_required
from authentik.core.api.providers import ProviderSerializer from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
@ -240,10 +239,8 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Metrics for application logins""" """Metrics for application logins"""
app = self.get_object() app = self.get_object()
return Response( return Response(
get_objects_for_user(request.user, "authentik_events.view_event") get_events_per_1h(
.filter(
action=EventAction.AUTHORIZE_APPLICATION, action=EventAction.AUTHORIZE_APPLICATION,
context__authorized_application__pk=app.pk.hex, context__authorized_application__pk=app.pk.hex,
) )
.get_events_per_hour()
) )

View File

@ -11,7 +11,6 @@ from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser from ua_parser import user_agent_parser
from authentik.api.authorization import OwnerSuperuserPermissions
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.models import AuthenticatedSession from authentik.core.models import AuthenticatedSession
from authentik.events.geo import GEOIP_READER, GeoIPDict from authentik.events.geo import GEOIP_READER, GeoIPDict
@ -103,8 +102,11 @@ class AuthenticatedSessionViewSet(
search_fields = ["user__username", "last_ip", "last_user_agent"] search_fields = ["user__username", "last_ip", "last_user_agent"]
filterset_fields = ["user__username", "last_ip", "last_user_agent"] filterset_fields = ["user__username", "last_ip", "last_user_agent"]
ordering = ["user__username"] ordering = ["user__username"]
permission_classes = [OwnerSuperuserPermissions] filter_backends = [
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter] DjangoFilterBackend,
OrderingFilter,
SearchFilter,
]
def get_queryset(self): def get_queryset(self):
user = self.request.user if self.request else get_anonymous_user() user = self.request.user if self.request else get_anonymous_user()

View File

@ -1,36 +0,0 @@
"""Authenticator Devices API Views"""
from django_otp import devices_for_user
from django_otp.models import Device
from drf_spectacular.utils import extend_schema
from rest_framework.fields import CharField, IntegerField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import MetaNameSerializer
class DeviceSerializer(MetaNameSerializer):
"""Serializer for Duo authenticator devices"""
pk = IntegerField()
name = CharField()
type = SerializerMethodField()
def get_type(self, instance: Device) -> str:
"""Get type of device"""
return instance._meta.label
class DeviceViewSet(ViewSet):
"""Viewset for authenticator devices"""
serializer_class = DeviceSerializer
permission_classes = [IsAuthenticated]
@extend_schema(responses={200: DeviceSerializer(many=True)})
def list(self, request: Request) -> Response:
"""Get all devices for current user"""
devices = devices_for_user(request.user)
return Response(DeviceSerializer(devices, many=True).data)

View File

@ -1,11 +1,9 @@
"""Groups API Viewset""" """Groups API Viewset"""
from json import loads
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter from django_filters.filters import ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
from rest_framework.fields import CharField, JSONField from rest_framework.fields import BooleanField, CharField, JSONField
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter from rest_framework_guardian.filters import ObjectPermissionsFilter
@ -17,6 +15,7 @@ from authentik.core.models import Group, User
class GroupMemberSerializer(ModelSerializer): class GroupMemberSerializer(ModelSerializer):
"""Stripped down user serializer to show relevant users for groups""" """Stripped down user serializer to show relevant users for groups"""
is_superuser = BooleanField(read_only=True)
avatar = CharField(read_only=True) avatar = CharField(read_only=True)
attributes = JSONField(validators=[is_dict], required=False) attributes = JSONField(validators=[is_dict], required=False)
uid = CharField(read_only=True) uid = CharField(read_only=True)
@ -30,6 +29,7 @@ class GroupMemberSerializer(ModelSerializer):
"name", "name",
"is_active", "is_active",
"last_login", "last_login",
"is_superuser",
"email", "email",
"avatar", "avatar",
"attributes", "attributes",
@ -44,7 +44,6 @@ class GroupSerializer(ModelSerializer):
users_obj = ListSerializer( users_obj = ListSerializer(
child=GroupMemberSerializer(), read_only=True, source="users", required=False child=GroupMemberSerializer(), read_only=True, source="users", required=False
) )
parent_name = CharField(source="parent.name", read_only=True)
class Meta: class Meta:
@ -54,7 +53,6 @@ class GroupSerializer(ModelSerializer):
"name", "name",
"is_superuser", "is_superuser",
"parent", "parent",
"parent_name",
"users", "users",
"attributes", "attributes",
"users_obj", "users_obj",
@ -64,13 +62,6 @@ class GroupSerializer(ModelSerializer):
class GroupFilter(FilterSet): class GroupFilter(FilterSet):
"""Filter for groups""" """Filter for groups"""
attributes = CharFilter(
field_name="attributes",
lookup_expr="",
label="Attributes",
method="filter_attributes",
)
members_by_username = ModelMultipleChoiceFilter( members_by_username = ModelMultipleChoiceFilter(
field_name="users__username", field_name="users__username",
to_field_name="username", to_field_name="username",
@ -81,28 +72,10 @@ class GroupFilter(FilterSet):
queryset=User.objects.all(), queryset=User.objects.all(),
) )
# pylint: disable=unused-argument
def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args"""
try:
value = loads(value)
except ValueError:
raise ValidationError(detail="filter: failed to parse JSON")
if not isinstance(value, dict):
raise ValidationError(detail="filter: value must be key:value mapping")
qs = {}
for key, _value in value.items():
qs[f"attributes__{key}"] = _value
try:
_ = len(queryset.filter(**qs))
return queryset.filter(**qs)
except ValueError:
return queryset
class Meta: class Meta:
model = Group model = Group
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"] fields = ["name", "is_superuser", "members_by_pk", "members_by_username"]
class GroupViewSet(UsedByMixin, ModelViewSet): class GroupViewSet(UsedByMixin, ModelViewSet):

View File

@ -56,7 +56,6 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
"component", "component",
"verbose_name", "verbose_name",
"verbose_name_plural", "verbose_name_plural",
"meta_model_name",
] ]

View File

@ -43,7 +43,6 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"assigned_application_name", "assigned_application_name",
"verbose_name", "verbose_name",
"verbose_name_plural", "verbose_name_plural",
"meta_model_name",
] ]

View File

@ -1,21 +1,18 @@
"""Source API Views""" """Source API Views"""
from typing import Iterable from typing import Iterable
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import mixins from rest_framework import mixins
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer, SerializerMethodField from rest_framework.serializers import ModelSerializer, SerializerMethodField
from rest_framework.viewsets import GenericViewSet from rest_framework.viewsets import GenericViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.api.authorization import OwnerFilter, OwnerPermissions
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.models import Source, UserSourceConnection from authentik.core.models import Source
from authentik.core.types import UserSettingSerializer from authentik.core.types import UserSettingSerializer
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
@ -48,7 +45,6 @@ class SourceSerializer(ModelSerializer, MetaNameSerializer):
"component", "component",
"verbose_name", "verbose_name",
"verbose_name_plural", "verbose_name_plural",
"meta_model_name",
"policy_engine_mode", "policy_engine_mode",
"user_matching_mode", "user_matching_mode",
] ]
@ -99,57 +95,19 @@ class SourceViewSet(
@action(detail=False, pagination_class=None, filter_backends=[]) @action(detail=False, pagination_class=None, filter_backends=[])
def user_settings(self, request: Request) -> Response: def user_settings(self, request: Request) -> Response:
"""Get all sources the user can configure""" """Get all sources the user can configure"""
_all_sources: Iterable[Source] = ( _all_sources: Iterable[Source] = Source.objects.filter(enabled=True).select_subclasses()
Source.objects.filter(enabled=True).select_subclasses().order_by("name")
)
matching_sources: list[UserSettingSerializer] = [] matching_sources: list[UserSettingSerializer] = []
for source in _all_sources: for source in _all_sources:
user_settings = source.ui_user_settings() user_settings = source.ui_user_settings
if not user_settings: if not user_settings:
continue continue
policy_engine = PolicyEngine(source, request.user, request) policy_engine = PolicyEngine(source, request.user, request)
policy_engine.build() policy_engine.build()
if not policy_engine.passing: if not policy_engine.passing:
continue continue
source_settings = source.ui_user_settings() source_settings = source.ui_user_settings
source_settings.initial_data["object_uid"] = source.slug source_settings.initial_data["object_uid"] = source.slug
if not source_settings.is_valid(): if not source_settings.is_valid():
LOGGER.warning(source_settings.errors) LOGGER.warning(source_settings.errors)
matching_sources.append(source_settings.validated_data) matching_sources.append(source_settings.validated_data)
return Response(matching_sources) return Response(matching_sources)
class UserSourceConnectionSerializer(SourceSerializer):
"""OAuth Source Serializer"""
source = SourceSerializer(read_only=True)
class Meta:
model = UserSourceConnection
fields = [
"pk",
"user",
"source",
"created",
]
extra_kwargs = {
"user": {"read_only": True},
"created": {"read_only": True},
}
class UserSourceConnectionViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""User-source connection Viewset"""
queryset = UserSourceConnection.objects.all()
serializer_class = UserSourceConnectionSerializer
permission_classes = [OwnerPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["pk"]

View File

@ -2,19 +2,15 @@
from typing import Any from typing import Any
from django.http.response import Http404 from django.http.response import Http404
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from guardian.shortcuts import get_anonymous_user
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField from rest_framework.fields import CharField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import OwnerSuperuserPermissions
from authentik.api.decorators import permission_required from authentik.api.decorators import permission_required
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer from authentik.core.api.users import UserSerializer
@ -82,25 +78,14 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
"description", "description",
"expires", "expires",
"expiring", "expiring",
"managed",
] ]
ordering = ["identifier", "expires"] ordering = ["expires"]
permission_classes = [OwnerSuperuserPermissions]
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
def get_queryset(self):
user = self.request.user if self.request else get_anonymous_user()
if user.is_superuser:
return super().get_queryset()
return super().get_queryset().filter(user=user.pk)
def perform_create(self, serializer: TokenSerializer): def perform_create(self, serializer: TokenSerializer):
if not self.request.user.is_superuser: serializer.save(
return serializer.save(
user=self.request.user, user=self.request.user,
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True), expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True),
) )
return super().perform_create(serializer)
@permission_required("authentik_core.view_token_key") @permission_required("authentik_core.view_token_key")
@extend_schema( @extend_schema(

View File

@ -1,5 +1,4 @@
"""User API Views""" """User API Views"""
from datetime import timedelta
from json import loads from json import loads
from typing import Optional from typing import Optional
@ -8,8 +7,6 @@ from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.http import urlencode from django.utils.http import urlencode
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_filters.filters import BooleanFilter, CharFilter, ModelMultipleChoiceFilter from django_filters.filters import BooleanFilter, CharFilter, ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
@ -22,7 +19,7 @@ from drf_spectacular.utils import (
) )
from guardian.shortcuts import get_anonymous_user, get_objects_for_user from guardian.shortcuts import get_anonymous_user, get_objects_for_user
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import CharField, DictField, JSONField, SerializerMethodField from rest_framework.fields import CharField, JSONField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
@ -38,15 +35,13 @@ from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter from rest_framework_guardian.filters import ObjectPermissionsFilter
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer from authentik.admin.api.metrics import CoordinateSerializer, get_events_per_1h
from authentik.api.decorators import permission_required from authentik.api.decorators import permission_required
from authentik.core.api.groups import GroupSerializer from authentik.core.api.groups import GroupSerializer
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import LinkSerializer, PassiveSerializer, is_dict 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_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
from authentik.core.models import ( from authentik.core.models import (
USER_ATTRIBUTE_CHANGE_EMAIL,
USER_ATTRIBUTE_CHANGE_USERNAME,
USER_ATTRIBUTE_SA, USER_ATTRIBUTE_SA,
USER_ATTRIBUTE_TOKEN_EXPIRING, USER_ATTRIBUTE_TOKEN_EXPIRING,
Group, Group,
@ -55,7 +50,6 @@ from authentik.core.models import (
User, User,
) )
from authentik.events.models import EventAction from authentik.events.models import EventAction
from authentik.lib.config import CONFIG
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
@ -93,9 +87,6 @@ class UserSerializer(ModelSerializer):
"attributes", "attributes",
"uid", "uid",
] ]
extra_kwargs = {
"name": {"allow_blank": True},
}
class UserSelfSerializer(ModelSerializer): class UserSelfSerializer(ModelSerializer):
@ -104,45 +95,8 @@ class UserSelfSerializer(ModelSerializer):
is_superuser = BooleanField(read_only=True) is_superuser = BooleanField(read_only=True)
avatar = CharField(read_only=True) avatar = CharField(read_only=True)
groups = SerializerMethodField() groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
uid = CharField(read_only=True) uid = CharField(read_only=True)
settings = DictField(source="attributes.settings", default=dict)
@extend_schema_field(
ListSerializer(
child=inline_serializer(
"UserSelfGroups",
{"name": CharField(read_only=True), "pk": CharField(read_only=True)},
)
)
)
def get_groups(self, _: User):
"""Return only the group names a user is member of"""
for group in self.instance.ak_groups.all():
yield {
"name": group.name,
"pk": group.pk,
}
def validate_email(self, email: str):
"""Check if the user is allowed to change their email"""
if self.instance.group_attributes().get(
USER_ATTRIBUTE_CHANGE_EMAIL, CONFIG.y_bool("default_user_change_email", True)
):
return email
if email != self.instance.email:
raise ValidationError("Not allowed to change email.")
return email
def validate_username(self, username: str):
"""Check if the user is allowed to change their username"""
if self.instance.group_attributes().get(
USER_ATTRIBUTE_CHANGE_USERNAME, CONFIG.y_bool("default_user_change_username", True)
):
return username
if username != self.instance.username:
raise ValidationError("Not allowed to change username.")
return username
class Meta: class Meta:
@ -157,11 +111,9 @@ class UserSelfSerializer(ModelSerializer):
"email", "email",
"avatar", "avatar",
"uid", "uid",
"settings",
] ]
extra_kwargs = { extra_kwargs = {
"is_active": {"read_only": True}, "is_active": {"read_only": True},
"name": {"allow_blank": True},
} }
@ -184,31 +136,19 @@ class UserMetricsSerializer(PassiveSerializer):
def get_logins_per_1h(self, _): def get_logins_per_1h(self, _):
"""Get successful logins per hour for the last 24 hours""" """Get successful logins per hour for the last 24 hours"""
user = self.context["user"] user = self.context["user"]
return ( return get_events_per_1h(action=EventAction.LOGIN, user__pk=user.pk)
get_objects_for_user(user, "authentik_events.view_event")
.filter(action=EventAction.LOGIN, user__pk=user.pk)
.get_events_per_hour()
)
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_logins_failed_per_1h(self, _): def get_logins_failed_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours""" """Get failed logins per hour for the last 24 hours"""
user = self.context["user"] user = self.context["user"]
return ( return get_events_per_1h(action=EventAction.LOGIN_FAILED, context__username=user.username)
get_objects_for_user(user, "authentik_events.view_event")
.filter(action=EventAction.LOGIN_FAILED, context__username=user.username)
.get_events_per_hour()
)
@extend_schema_field(CoordinateSerializer(many=True)) @extend_schema_field(CoordinateSerializer(many=True))
def get_authorizations_per_1h(self, _): def get_authorizations_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours""" """Get failed logins per hour for the last 24 hours"""
user = self.context["user"] user = self.context["user"]
return ( return get_events_per_1h(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
get_objects_for_user(user, "authentik_events.view_event")
.filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
.get_events_per_hour()
)
class UsersFilter(FilterSet): class UsersFilter(FilterSet):
@ -245,11 +185,7 @@ class UsersFilter(FilterSet):
qs = {} qs = {}
for key, _value in value.items(): for key, _value in value.items():
qs[f"attributes__{key}"] = _value qs[f"attributes__{key}"] = _value
try:
_ = len(queryset.filter(**qs))
return queryset.filter(**qs) return queryset.filter(**qs)
except ValueError:
return queryset
class Meta: class Meta:
model = User model = User
@ -269,7 +205,6 @@ class UserViewSet(UsedByMixin, ModelViewSet):
"""User Viewset""" """User Viewset"""
queryset = User.objects.none() queryset = User.objects.none()
ordering = ["username"]
serializer_class = UserSerializer serializer_class = UserSerializer
search_fields = ["username", "name", "is_active", "email"] search_fields = ["username", "name", "is_active", "email"]
filterset_class = UsersFilter filterset_class = UsersFilter
@ -330,16 +265,15 @@ class UserViewSet(UsedByMixin, ModelViewSet):
name=username, name=username,
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False}, attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
) )
if create_group and self.request.user.has_perm("authentik_core.add_group"): if create_group:
group = Group.objects.create( group = Group.objects.create(
name=username, name=username,
) )
group.users.add(user) group.users.add(user)
token = Token.objects.create( token = Token.objects.create(
identifier=slugify(f"service-account-{username}-password"), identifier=f"service-account-{username}-password",
intent=TokenIntents.INTENT_APP_PASSWORD, intent=TokenIntents.INTENT_APP_PASSWORD,
user=user, user=user,
expires=now() + timedelta(days=360),
) )
return Response({"username": user.username, "token": token.key}) return Response({"username": user.username, "token": token.key})
except (IntegrityError) as exc: except (IntegrityError) as exc:
@ -350,14 +284,13 @@ class UserViewSet(UsedByMixin, ModelViewSet):
# pylint: disable=invalid-name # pylint: disable=invalid-name
def me(self, request: Request) -> Response: def me(self, request: Request) -> Response:
"""Get information about current user""" """Get information about current user"""
serializer = SessionUserSerializer( serializer = SessionUserSerializer(data={"user": UserSelfSerializer(request.user).data})
data={"user": UserSelfSerializer(instance=request.user).data}
)
if SESSION_IMPERSONATE_USER in request._request.session: if SESSION_IMPERSONATE_USER in request._request.session:
serializer.initial_data["original"] = UserSelfSerializer( serializer.initial_data["original"] = UserSelfSerializer(
instance=request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER] request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
).data ).data
return Response(serializer.initial_data) serializer.is_valid()
return Response(serializer.data)
@extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)}) @extend_schema(request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)})
@action( @action(
@ -371,13 +304,15 @@ class UserViewSet(UsedByMixin, ModelViewSet):
"""Allow users to change information on their own profile""" """Allow users to change information on their own profile"""
data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data) data = UserSelfSerializer(instance=User.objects.get(pk=request.user.pk), data=request.data)
if not data.is_valid(): if not data.is_valid():
return Response(data.errors, status=400) return Response(data.errors)
new_user = data.save() new_user = data.save()
# If we're impersonating, we need to update that user object # If we're impersonating, we need to update that user object
# since it caches the full object # since it caches the full object
if SESSION_IMPERSONATE_USER in request.session: if SESSION_IMPERSONATE_USER in request.session:
request.session[SESSION_IMPERSONATE_USER] = new_user request.session[SESSION_IMPERSONATE_USER] = new_user
return Response({"user": data.data}) serializer = SessionUserSerializer(data={"user": UserSelfSerializer(request.user).data})
serializer.is_valid()
return Response(serializer.data)
@permission_required("authentik_core.view_user", ["authentik_events.view_event"]) @permission_required("authentik_core.view_user", ["authentik_events.view_event"])
@extend_schema(responses={200: UserMetricsSerializer(many=False)}) @extend_schema(responses={200: UserMetricsSerializer(many=False)})

View File

@ -41,7 +41,6 @@ class MetaNameSerializer(PassiveSerializer):
verbose_name = SerializerMethodField() verbose_name = SerializerMethodField()
verbose_name_plural = SerializerMethodField() verbose_name_plural = SerializerMethodField()
meta_model_name = SerializerMethodField()
def get_verbose_name(self, obj: Model) -> str: def get_verbose_name(self, obj: Model) -> str:
"""Return object's verbose_name""" """Return object's verbose_name"""
@ -51,10 +50,6 @@ class MetaNameSerializer(PassiveSerializer):
"""Return object's plural verbose_name""" """Return object's plural verbose_name"""
return obj._meta.verbose_name_plural return obj._meta.verbose_name_plural
def get_meta_model_name(self, obj: Model) -> str:
"""Return internal model name"""
return f"{obj._meta.app_label}.{obj._meta.model_name}"
class TypeCreateSerializer(PassiveSerializer): class TypeCreateSerializer(PassiveSerializer):
"""Types of an object that can be created""" """Types of an object that can be created"""

View File

@ -8,7 +8,7 @@ from django.http.request import HttpRequest
from authentik.core.models import Token, TokenIntents, User from authentik.core.models import Token, TokenIntents, User
from authentik.events.utils import cleanse_dict, sanitize_dict from authentik.events.utils import cleanse_dict, sanitize_dict
from authentik.flows.planner import FlowPlan from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.flows.views import SESSION_KEY_PLAN
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
@ -55,5 +55,5 @@ class TokenBackend(InbuiltBackend):
if not tokens.exists(): if not tokens.exists():
return None return None
token = tokens.first() token = tokens.first()
self.set_method("token", request, token=token) self.set_method("password", request, token=token)
return token.user return token.user

View File

@ -5,14 +5,11 @@ from typing import Callable
from uuid import uuid4 from uuid import uuid4
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from sentry_sdk.api import set_tag
SESSION_IMPERSONATE_USER = "authentik_impersonate_user" SESSION_IMPERSONATE_USER = "authentik_impersonate_user"
SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user" SESSION_IMPERSONATE_ORIGINAL_USER = "authentik_impersonate_original_user"
LOCAL = local() LOCAL = local()
RESPONSE_HEADER_ID = "X-authentik-id" RESPONSE_HEADER_ID = "X-authentik-id"
KEY_AUTH_VIA = "auth_via"
KEY_USER = "user"
class ImpersonateMiddleware: class ImpersonateMiddleware:
@ -51,22 +48,17 @@ class RequestIDMiddleware:
"request_id": request_id, "request_id": request_id,
"host": request.get_host(), "host": request.get_host(),
} }
set_tag("authentik.request_id", request_id)
response = self.get_response(request) response = self.get_response(request)
response[RESPONSE_HEADER_ID] = request.request_id response[RESPONSE_HEADER_ID] = request.request_id
setattr(response, "ak_context", {}) del LOCAL.authentik["request_id"]
response.ak_context.update(LOCAL.authentik) del LOCAL.authentik["host"]
response.ak_context[KEY_USER] = request.user.username
for key in list(LOCAL.authentik.keys()):
del LOCAL.authentik[key]
return response return response
# pylint: disable=unused-argument # pylint: disable=unused-argument
def structlog_add_request_id(logger: Logger, method_name: str, event_dict: dict): def structlog_add_request_id(logger: Logger, method_name: str, event_dict):
"""If threadlocal has authentik defined, add request_id to log""" """If threadlocal has authentik defined, add request_id to log"""
if hasattr(LOCAL, "authentik"): if hasattr(LOCAL, "authentik"):
event_dict.update(LOCAL.authentik) event_dict["request_id"] = LOCAL.authentik.get("request_id", "")
if hasattr(LOCAL, "authentik_task"): event_dict["host"] = LOCAL.authentik.get("host", "")
event_dict.update(LOCAL.authentik_task)
return event_dict return event_dict

View File

@ -1,221 +0,0 @@
# Generated by Django 3.2.8 on 2021-10-10 16:16
from os import environ
import django.db.models.deletion
from django.apps.registry import Apps
from django.conf import settings
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import authentik.core.models
def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
# We have to use a direct import here, otherwise we get an object manager error
from authentik.core.models import User
db_alias = schema_editor.connection.alias
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
else:
akadmin.set_unusable_password()
akadmin.save()
def create_default_admin_group(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
User = apps.get_model("authentik_core", "User")
# Creates a default admin group
group, _ = Group.objects.using(db_alias).get_or_create(
is_superuser=True,
defaults={
"name": "authentik Admins",
},
)
group.users.set(User.objects.filter(username="akadmin"))
group.save()
class Migration(migrations.Migration):
replaces = [
("authentik_core", "0002_auto_20200523_1133"),
("authentik_core", "0003_default_user"),
("authentik_core", "0004_auto_20200703_2213"),
("authentik_core", "0005_token_intent"),
("authentik_core", "0006_auto_20200709_1608"),
("authentik_core", "0007_auto_20200815_1841"),
("authentik_core", "0008_auto_20200824_1532"),
("authentik_core", "0009_group_is_superuser"),
("authentik_core", "0010_auto_20200917_1021"),
("authentik_core", "0011_provider_name_temp"),
]
dependencies = [
("authentik_core", "0001_initial"),
("authentik_flows", "0003_auto_20200523_1133"),
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.RemoveField(
model_name="application",
name="skip_authorization",
),
migrations.AddField(
model_name="source",
name="authentication_flow",
field=models.ForeignKey(
blank=True,
default=None,
help_text="Flow to use when authenticating existing users.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="source_authentication",
to="authentik_flows.flow",
),
),
migrations.AddField(
model_name="source",
name="enrollment_flow",
field=models.ForeignKey(
blank=True,
default=None,
help_text="Flow to use when enrolling new users.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="source_enrollment",
to="authentik_flows.flow",
),
),
migrations.AddField(
model_name="provider",
name="authorization_flow",
field=models.ForeignKey(
help_text="Flow used when authorizing this provider.",
on_delete=django.db.models.deletion.CASCADE,
related_name="provider_authorization",
to="authentik_flows.flow",
),
),
migrations.RemoveField(
model_name="user",
name="is_superuser",
),
migrations.RemoveField(
model_name="user",
name="is_staff",
),
migrations.RunPython(
code=create_default_user,
),
migrations.AddField(
model_name="user",
name="is_superuser",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="user",
name="is_staff",
field=models.BooleanField(default=False),
),
migrations.AlterModelOptions(
name="application",
options={"verbose_name": "Application", "verbose_name_plural": "Applications"},
),
migrations.AlterModelOptions(
name="user",
options={
"permissions": (("reset_user_password", "Reset Password"),),
"verbose_name": "User",
"verbose_name_plural": "Users",
},
),
migrations.AddField(
model_name="token",
name="intent",
field=models.TextField(
choices=[("verification", "Intent Verification"), ("api", "Intent Api")],
default="verification",
),
),
migrations.AlterField(
model_name="source",
name="slug",
field=models.SlugField(help_text="Internal source name, used in URLs.", unique=True),
),
migrations.AlterField(
model_name="user",
name="first_name",
field=models.CharField(blank=True, max_length=150, verbose_name="first name"),
),
migrations.RemoveField(
model_name="user",
name="groups",
),
migrations.AddField(
model_name="user",
name="groups",
field=models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.Group",
verbose_name="groups",
),
),
migrations.RemoveField(
model_name="user",
name="is_superuser",
),
migrations.RemoveField(
model_name="user",
name="is_staff",
),
migrations.AddField(
model_name="user",
name="pb_groups",
field=models.ManyToManyField(related_name="users", to="authentik_core.Group"),
),
migrations.AddField(
model_name="group",
name="is_superuser",
field=models.BooleanField(
default=False, help_text="Users added to this group will be superusers."
),
),
migrations.RunPython(
code=create_default_admin_group,
),
migrations.AlterModelManagers(
name="user",
managers=[
("objects", authentik.core.models.UserManager()),
],
),
migrations.AlterModelOptions(
name="user",
options={
"permissions": (
("reset_user_password", "Reset Password"),
("impersonate", "Can impersonate other users"),
),
"verbose_name": "User",
"verbose_name_plural": "Users",
},
),
migrations.AddField(
model_name="provider",
name="name_temp",
field=models.TextField(default=""),
preserve_default=False,
),
]

View File

@ -1,118 +0,0 @@
# Generated by Django 3.2.8 on 2021-10-12 15:36
from django.apps.registry import Apps
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import authentik.core.models
def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Token = apps.get_model("authentik_core", "Token")
for token in Token.objects.using(db_alias).all():
token.key = token.pk.hex
token.save()
class Migration(migrations.Migration):
replaces = [
("authentik_core", "0012_auto_20201003_1737"),
("authentik_core", "0013_auto_20201003_2132"),
("authentik_core", "0014_auto_20201018_1158"),
("authentik_core", "0015_application_icon"),
("authentik_core", "0016_auto_20201202_2234"),
]
dependencies = [
("authentik_providers_saml", "0006_remove_samlprovider_name"),
("authentik_providers_oauth2", "0006_remove_oauth2provider_name"),
("authentik_core", "0011_provider_name_temp"),
]
operations = [
migrations.RenameField(
model_name="provider",
old_name="name_temp",
new_name="name",
),
migrations.AddField(
model_name="token",
name="identifier",
field=models.TextField(default=""),
preserve_default=False,
),
migrations.AlterField(
model_name="token",
name="intent",
field=models.TextField(
choices=[
("verification", "Intent Verification"),
("api", "Intent Api"),
("recovery", "Intent Recovery"),
],
default="verification",
),
),
migrations.AlterUniqueTogether(
name="token",
unique_together={("identifier", "user")},
),
migrations.AddField(
model_name="token",
name="key",
field=models.TextField(default=authentik.core.models.default_token_key),
),
migrations.AlterUniqueTogether(
name="token",
unique_together=set(),
),
migrations.AlterField(
model_name="token",
name="identifier",
field=models.SlugField(max_length=255),
),
migrations.AddIndex(
model_name="token",
index=models.Index(fields=["key"], name="authentik_co_key_e45007_idx"),
),
migrations.AddIndex(
model_name="token",
index=models.Index(fields=["identifier"], name="authentik_co_identif_1a34a8_idx"),
),
migrations.RunPython(
code=set_default_token_key,
),
migrations.RemoveField(
model_name="application",
name="meta_icon_url",
),
migrations.AddField(
model_name="application",
name="meta_icon",
field=models.FileField(blank=True, default="", upload_to="application-icons/"),
),
migrations.RemoveIndex(
model_name="token",
name="authentik_co_key_e45007_idx",
),
migrations.RemoveIndex(
model_name="token",
name="authentik_co_identif_1a34a8_idx",
),
migrations.RenameField(
model_name="user",
old_name="pb_groups",
new_name="ak_groups",
),
migrations.AddIndex(
model_name="token",
index=models.Index(fields=["identifier"], name="authentik_c_identif_d9d032_idx"),
),
migrations.AddIndex(
model_name="token",
index=models.Index(fields=["key"], name="authentik_c_key_f71355_idx"),
),
]

View File

@ -1,210 +0,0 @@
# Generated by Django 3.2.8 on 2021-10-10 16:12
import uuid
from os import environ
import django.db.models.deletion
from django.apps.registry import Apps
from django.conf import settings
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.models import Count
import authentik.core.models
import authentik.lib.models
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
session_keys = cache.keys(KEY_PREFIX + "*")
cache.delete_many(session_keys)
def fix_duplicates(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Token = apps.get_model("authentik_core", "token")
identifiers = (
Token.objects.using(db_alias)
.values("identifier")
.annotate(identifier_count=Count("identifier"))
.filter(identifier_count__gt=1)
)
for ident in identifiers:
Token.objects.using(db_alias).filter(identifier=ident["identifier"]).delete()
def create_default_user_token(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
# We have to use a direct import here, otherwise we get an object manager error
from authentik.core.models import Token, TokenIntents, User
db_alias = schema_editor.connection.alias
akadmin = User.objects.using(db_alias).filter(username="akadmin")
if not akadmin.exists():
return
if "AK_ADMIN_TOKEN" not in environ:
return
Token.objects.using(db_alias).create(
identifier="authentik-boostrap-token",
user=akadmin.first(),
intent=TokenIntents.INTENT_API,
expiring=False,
key=environ["AK_ADMIN_TOKEN"],
)
class Migration(migrations.Migration):
replaces = [
("authentik_core", "0018_auto_20210330_1345"),
("authentik_core", "0019_source_managed"),
("authentik_core", "0020_source_user_matching_mode"),
("authentik_core", "0021_alter_application_slug"),
("authentik_core", "0022_authenticatedsession"),
("authentik_core", "0023_alter_application_meta_launch_url"),
("authentik_core", "0024_alter_token_identifier"),
("authentik_core", "0025_alter_application_meta_icon"),
("authentik_core", "0026_alter_application_meta_icon"),
("authentik_core", "0027_bootstrap_token"),
("authentik_core", "0028_alter_token_intent"),
]
dependencies = [
("authentik_core", "0017_managed"),
]
operations = [
migrations.AlterModelOptions(
name="token",
options={
"permissions": (("view_token_key", "View token's key"),),
"verbose_name": "Token",
"verbose_name_plural": "Tokens",
},
),
migrations.AddField(
model_name="source",
name="managed",
field=models.TextField(
default=None,
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
null=True,
unique=True,
verbose_name="Managed by authentik",
),
),
migrations.AddField(
model_name="source",
name="user_matching_mode",
field=models.TextField(
choices=[
("identifier", "Use the source-specific identifier"),
(
"email_link",
"Link to a user with identical email address. Can have security implications when a source doesn't validate email addresses.",
),
(
"email_deny",
"Use the user's email address, but deny enrollment when the email address already exists.",
),
(
"username_link",
"Link to a user with identical username. Can have security implications when a username is used with another source.",
),
(
"username_deny",
"Use the user's username, but deny enrollment when the username already exists.",
),
],
default="identifier",
help_text="How the source determines if an existing user should be authenticated or a new user enrolled.",
),
),
migrations.AlterField(
model_name="application",
name="slug",
field=models.SlugField(
help_text="Internal application name, used in URLs.", unique=True
),
),
migrations.CreateModel(
name="AuthenticatedSession",
fields=[
(
"expires",
models.DateTimeField(default=authentik.core.models.default_token_duration),
),
("expiring", models.BooleanField(default=True)),
("uuid", models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
("session_key", models.CharField(max_length=40)),
("last_ip", models.TextField()),
("last_user_agent", models.TextField(blank=True)),
("last_used", models.DateTimeField(auto_now=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"abstract": False,
},
),
migrations.RunPython(
code=migrate_sessions,
),
migrations.AlterField(
model_name="application",
name="meta_launch_url",
field=models.TextField(
blank=True, default="", validators=[authentik.lib.models.DomainlessURLValidator()]
),
),
migrations.RunPython(
code=fix_duplicates,
),
migrations.AlterField(
model_name="token",
name="identifier",
field=models.SlugField(max_length=255, unique=True),
),
migrations.AlterField(
model_name="application",
name="meta_icon",
field=models.FileField(default=None, null=True, upload_to="application-icons/"),
),
migrations.AlterField(
model_name="application",
name="meta_icon",
field=models.FileField(
default=None, max_length=500, null=True, upload_to="application-icons/"
),
),
migrations.AlterModelOptions(
name="authenticatedsession",
options={
"verbose_name": "Authenticated Session",
"verbose_name_plural": "Authenticated Sessions",
},
),
migrations.RunPython(
code=create_default_user_token,
),
migrations.AlterField(
model_name="token",
name="intent",
field=models.TextField(
choices=[
("verification", "Intent Verification"),
("api", "Intent Api"),
("recovery", "Intent Recovery"),
("app_password", "Intent App Password"),
],
default="verification",
),
),
]

View File

@ -26,7 +26,7 @@ class Migration(migrations.Migration):
), ),
( (
"username_link", "username_link",
"Link to a user with identical username. Can have security implications when a username is used with another source.", "Link to a user with identical username address. Can have security implications when a username is used with another source.",
), ),
( (
"username_deny", "username_deny",

View File

@ -1,9 +1,8 @@
# Generated by Django 3.2.3 on 2021-06-02 21:51 # Generated by Django 3.2.3 on 2021-06-02 21:51
import django.core.validators
from django.db import migrations, models from django.db import migrations, models
import authentik.lib.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -18,7 +17,7 @@ class Migration(migrations.Migration):
field=models.TextField( field=models.TextField(
blank=True, blank=True,
default="", default="",
validators=[authentik.lib.models.DomainlessURLValidator()], validators=[django.core.validators.URLValidator()],
), ),
), ),
] ]

View File

@ -9,6 +9,7 @@ from deepmerge import always_merger
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager from django.contrib.auth.models import UserManager as DjangoUserManager
from django.core import validators
from django.db import models from django.db import models
from django.db.models import Q, QuerySet, options from django.db.models import Q, QuerySet, options
from django.http import HttpRequest from django.http import HttpRequest
@ -25,9 +26,10 @@ from structlog.stdlib import get_logger
from authentik.core.exceptions import PropertyMappingExpressionException from authentik.core.exceptions import PropertyMappingExpressionException
from authentik.core.signals import password_changed from authentik.core.signals import password_changed
from authentik.core.types import UILoginButton, UserSettingSerializer from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, SerializerModel from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.lib.utils.http import get_client_ip from authentik.lib.utils.http import get_client_ip
from authentik.managed.models import ManagedModel from authentik.managed.models import ManagedModel
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
@ -37,8 +39,6 @@ USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug"
USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account" USER_ATTRIBUTE_SA = "goauthentik.io/user/service-account"
USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources" USER_ATTRIBUTE_SOURCES = "goauthentik.io/user/sources"
USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec USER_ATTRIBUTE_TOKEN_EXPIRING = "goauthentik.io/user/token-expires" # nosec
USER_ATTRIBUTE_CHANGE_USERNAME = "goauthentik.io/user/can-change-username"
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
GRAVATAR_URL = "https://secure.gravatar.com" GRAVATAR_URL = "https://secure.gravatar.com"
@ -79,27 +79,6 @@ class Group(models.Model):
) )
attributes = models.JSONField(default=dict, blank=True) attributes = models.JSONField(default=dict, blank=True)
def is_member(self, user: "User") -> bool:
"""Recursively check if `user` is member of us, or any parent."""
query = """
WITH RECURSIVE parents AS (
SELECT authentik_core_group.*, 0 AS relative_depth
FROM authentik_core_group
WHERE authentik_core_group.group_uuid = %s
UNION ALL
SELECT authentik_core_group.*, parents.relative_depth - 1
FROM authentik_core_group,parents
WHERE authentik_core_group.parent_id = parents.group_uuid
)
SELECT group_uuid
FROM parents
GROUP BY group_uuid;
"""
groups = Group.objects.raw(query, [self.group_uuid])
return user.ak_groups.filter(pk__in=[group.pk for group in groups]).exists()
def __str__(self): def __str__(self):
return f"Group {self.name}" return f"Group {self.name}"
@ -172,7 +151,7 @@ class User(GuardianUserMixin, AbstractUser):
if mode == "none": if mode == "none":
return DEFAULT_AVATAR return DEFAULT_AVATAR
# gravatar uses md5 for their URLs, so md5 can't be avoided # gravatar uses md5 for their URLs, so md5 can't be avoided
mail_hash = md5(self.email.lower().encode("utf-8")).hexdigest() # nosec mail_hash = md5(self.email.encode("utf-8")).hexdigest() # nosec
if mode == "gravatar": if mode == "gravatar":
parameters = [ parameters = [
("s", "158"), ("s", "158"),
@ -202,7 +181,7 @@ class Provider(SerializerModel):
name = models.TextField() name = models.TextField()
authorization_flow = models.ForeignKey( authorization_flow = models.ForeignKey(
"authentik_flows.Flow", Flow,
on_delete=models.CASCADE, on_delete=models.CASCADE,
help_text=_("Flow used when authorizing this provider."), help_text=_("Flow used when authorizing this provider."),
related_name="provider_authorization", related_name="provider_authorization",
@ -244,7 +223,7 @@ class Application(PolicyBindingModel):
) )
meta_launch_url = models.TextField( meta_launch_url = models.TextField(
default="", blank=True, validators=[DomainlessURLValidator()] default="", blank=True, validators=[validators.URLValidator()]
) )
# For template applications, this can be set to /static/authentik/applications/* # For template applications, this can be set to /static/authentik/applications/*
meta_icon = models.FileField( meta_icon = models.FileField(
@ -262,7 +241,7 @@ class Application(PolicyBindingModel):
it is returned as-is""" it is returned as-is"""
if not self.meta_icon: if not self.meta_icon:
return None return None
if "://" in self.meta_icon.name or self.meta_icon.name.startswith("/static"): if self.meta_icon.name.startswith("http") or self.meta_icon.name.startswith("/static"):
return self.meta_icon.name return self.meta_icon.name
return self.meta_icon.url return self.meta_icon.url
@ -278,13 +257,7 @@ class Application(PolicyBindingModel):
"""Get casted provider instance""" """Get casted provider instance"""
if not self.provider: if not self.provider:
return None return None
# if the Application class has been cache, self.provider is set
# but doing a direct query lookup will fail.
# In that case, just return None
try:
return Provider.objects.get_subclass(pk=self.provider.pk) return Provider.objects.get_subclass(pk=self.provider.pk)
except Provider.DoesNotExist:
return None
def __str__(self): def __str__(self):
return self.name return self.name
@ -310,7 +283,7 @@ class SourceUserMatchingModes(models.TextChoices):
) )
USERNAME_LINK = "username_link", _( USERNAME_LINK = "username_link", _(
( (
"Link to a user with identical username. Can have security implications " "Link to a user with identical username address. Can have security implications "
"when a username is used with another source." "when a username is used with another source."
) )
) )
@ -329,7 +302,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True)
authentication_flow = models.ForeignKey( authentication_flow = models.ForeignKey(
"authentik_flows.Flow", Flow,
blank=True, blank=True,
null=True, null=True,
default=None, default=None,
@ -338,7 +311,7 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
related_name="source_authentication", related_name="source_authentication",
) )
enrollment_flow = models.ForeignKey( enrollment_flow = models.ForeignKey(
"authentik_flows.Flow", Flow,
blank=True, blank=True,
null=True, null=True,
default=None, default=None,
@ -365,11 +338,13 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"""Return component used to edit this object""" """Return component used to edit this object"""
raise NotImplementedError raise NotImplementedError
def ui_login_button(self, request: HttpRequest) -> Optional[UILoginButton]: @property
def ui_login_button(self) -> Optional[UILoginButton]:
"""If source uses a http-based flow, return UI Information about the login """If source uses a http-based flow, return UI Information about the login
button. If source doesn't use http-based flow, return None.""" button. If source doesn't use http-based flow, return None."""
return None return None
@property
def ui_user_settings(self) -> Optional[UserSettingSerializer]: def ui_user_settings(self) -> Optional[UserSettingSerializer]:
"""Entrypoint to integrate with User settings. Can either return None if no """Entrypoint to integrate with User settings. Can either return None if no
user settings are available, or UserSettingSerializer.""" user settings are available, or UserSettingSerializer."""

View File

@ -22,7 +22,7 @@ from authentik.flows.planner import (
PLAN_CONTEXT_SSO, PLAN_CONTEXT_SSO,
FlowPlanner, FlowPlanner,
) )
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN from authentik.flows.views import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs from authentik.lib.utils.urls import redirect_with_qs
from authentik.policies.utils import delete_none_keys from authentik.policies.utils import delete_none_keys
from authentik.stages.password import BACKEND_INBUILT from authentik.stages.password import BACKEND_INBUILT
@ -184,7 +184,7 @@ class SourceFlowManager:
# Ensure redirect is carried through when user was trying to # Ensure redirect is carried through when user was trying to
# authorize application # authorize application
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get( final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user" NEXT_ARG_NAME, "authentik_core:if-admin"
) )
kwargs.update( kwargs.update(
{ {
@ -243,9 +243,9 @@ class SourceFlowManager:
return self.handle_auth_user(connection) return self.handle_auth_user(connection)
return redirect( return redirect(
reverse( reverse(
"authentik_core:if-user", "authentik_core:if-admin",
) )
+ f"#/settings;page-{self.source.slug}" + f"#/user;page-{self.source.slug}"
) )
def handle_enroll( def handle_enroll(

View File

@ -28,7 +28,3 @@ class PostUserEnrollmentStage(StageView):
source=connection.source, source=connection.source,
).from_http(self.request) ).from_http(self.request)
return self.executor.stage_ok() return self.executor.stage_ok()
def post(self, request: HttpRequest) -> HttpResponse:
"""Wrapper for post requests"""
return self.get(request)

View File

@ -6,7 +6,6 @@ from os import environ
from boto3.exceptions import Boto3Error from boto3.exceptions import Boto3Error
from botocore.exceptions import BotoCoreError, ClientError from botocore.exceptions import BotoCoreError, ClientError
from dbbackup.db.exceptions import CommandConnectorError from dbbackup.db.exceptions import CommandConnectorError
from django.conf import settings
from django.contrib.humanize.templatetags.humanize import naturaltime from django.contrib.humanize.templatetags.humanize import naturaltime
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core import management from django.core import management
@ -16,12 +15,7 @@ from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import AuthenticatedSession, ExpiringModel from authentik.core.models import AuthenticatedSession, ExpiringModel
from authentik.events.monitored_tasks import ( from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
@ -29,7 +23,6 @@ LOGGER = get_logger()
@CELERY_APP.task(bind=True, base=MonitoredTask) @CELERY_APP.task(bind=True, base=MonitoredTask)
@prefill_task
def clean_expired_models(self: MonitoredTask): def clean_expired_models(self: MonitoredTask):
"""Remove expired objects""" """Remove expired objects"""
messages = [] messages = []
@ -56,25 +49,23 @@ def clean_expired_models(self: MonitoredTask):
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
def should_backup() -> bool:
"""Check if we should be doing backups"""
if SERVICE_HOST_ENV_NAME in environ and not CONFIG.y("postgresql.s3_backup.bucket"):
LOGGER.info("Running in k8s and s3 backups are not configured, skipping")
return False
if not CONFIG.y_bool("postgresql.backup.enabled"):
return False
if settings.DEBUG:
return False
return True
@CELERY_APP.task(bind=True, base=MonitoredTask) @CELERY_APP.task(bind=True, base=MonitoredTask)
@prefill_task
def backup_database(self: MonitoredTask): # pragma: no cover def backup_database(self: MonitoredTask): # pragma: no cover
"""Database backup""" """Database backup"""
self.result_timeout_hours = 25 self.result_timeout_hours = 25
if not should_backup(): if SERVICE_HOST_ENV_NAME in environ and not CONFIG.y("postgresql.s3_backup"):
self.set_status(TaskResult(TaskResultStatus.UNKNOWN, ["Backups are not configured."])) LOGGER.info("Running in k8s and s3 backups are not configured, skipping")
self.set_status(
TaskResult(
TaskResultStatus.WARNING,
[
(
"Skipping backup as authentik is running in Kubernetes "
"without S3 backups configured."
),
],
)
)
return return
try: try:
start = datetime.now() start = datetime.now()

View File

@ -8,18 +8,18 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title> <title>{% block title %}{% trans title|default:tenant.branding_title %}{% endblock %}</title>
<link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}"> <link rel="shortcut icon" type="image/png" href="{% static 'dist/assets/icons/icon.png' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly-base.css' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/page.css' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/empty-state.css' %}?v={{ ak_version }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/spinner.css' %}?v={{ ak_version }}">
{% block head_before %} {% block head_before %}
{% endblock %} {% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}?v={{ ak_version }}">
<script src="{% static 'dist/poly.js' %}" type="module"></script> <script src="{% static 'dist/poly.js' %}?v={{ ak_version }}" type="module"></script>
<script>window["polymerSkipLoadingFontRoboto"] = true;</script>
{% block head %} {% block head %}
{% endblock %} {% endblock %}
<meta name="sentry-trace" content="{{ sentry_trace }}" />
</head> </head>
<body> <body>
{% block body %} {% block body %}

View File

@ -4,7 +4,7 @@
{% load i18n %} {% load i18n %}
{% block head %} {% block head %}
<script src="{% static 'dist/admin/AdminInterface.js' %}" type="module"></script> <script src="{% static 'dist/AdminInterface.js' %}?v={{ ak_version }}" type="module"></script>
{% endblock %} {% endblock %}
{% block body %} {% block body %}

View File

@ -21,7 +21,7 @@ You've logged out of {{ application }}.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
<a id="ak-back-home" href="{% url 'authentik_core:root-redirect' %}" class="pf-c-button pf-m-primary">{% trans 'Go back to overview' %}</a> <a id="ak-back-home" href="{% url 'authentik_core:if-admin' %}" class="pf-c-button pf-m-primary">{% trans 'Go back to overview' %}</a>
<a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">{% trans 'Log out of authentik' %}</a> <a id="logout" href="{% url 'authentik_flows:default-invalidation' %}" class="pf-c-button pf-m-secondary">{% trans 'Log out of authentik' %}</a>

View File

@ -4,14 +4,13 @@
{% load i18n %} {% load i18n %}
{% block head_before %} {% block head_before %}
{{ block.super }} {% if flow.compatibility_mode %}
{% if flow.compatibility_mode and not inspector %}
<script>ShadyDOM = { force: !navigator.webdriver };</script> <script>ShadyDOM = { force: !navigator.webdriver };</script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block head %} {% block head %}
<script src="{% static 'dist/flow/FlowInterface.js' %}" type="module"></script> <script src="{% static 'dist/FlowInterface.js' %}?v={{ ak_version }}" type="module"></script>
<style> <style>
.pf-c-background-image::before { .pf-c-background-image::before {
--ak-flow-background: url("{{ flow.background_url }}"); --ak-flow-background: url("{{ flow.background_url }}");

View File

@ -1,28 +0,0 @@
{% extends "base/skeleton.html" %}
{% load static %}
{% load i18n %}
{% block head %}
<script src="{% static 'dist/user/UserInterface.js' %}" type="module"></script>
{% endblock %}
{% block body %}
<ak-message-container></ak-message-container>
<ak-interface-user>
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl">
<div class="pf-c-empty-state" style="height: 100vh;">
<div class="pf-c-empty-state__content">
<span class="pf-c-spinner pf-m-xl pf-c-empty-state__icon" role="progressbar" aria-valuetext="{% trans 'Loading...' %}">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
<h1 class="pf-c-title pf-m-lg">
{% trans "Loading..." %}
</h1>
</div>
</div>
</section>
</ak-interface-user>
{% endblock %}

View File

@ -4,7 +4,7 @@
{% load i18n %} {% load i18n %}
{% block head_before %} {% block head_before %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}?v={{ ak_version }}">
{% endblock %} {% endblock %}
{% block head %} {% block head %}
@ -61,7 +61,7 @@
{% endfor %} {% endfor %}
{% if tenant.branding_title != "authentik" %} {% if tenant.branding_title != "authentik" %}
<li> <li>
<a href="https://goauthentik.io?utm_source=authentik"> <a href="https://goauthentik.io">
{% trans 'Powered by authentik' %} {% trans 'Powered by authentik' %}
</a> </a>
</li> </li>

View File

@ -3,8 +3,7 @@ from django.urls import reverse
from django.utils.encoding import force_str from django.utils.encoding import force_str
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import Application from authentik.core.models import Application, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.policies.dummy.models import DummyPolicy from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
@ -13,7 +12,7 @@ class TestApplicationsAPI(APITestCase):
"""Test applications API""" """Test applications API"""
def setUp(self) -> None: def setUp(self) -> None:
self.user = create_test_admin_user() self.user = User.objects.get(username="akadmin")
self.allowed = Application.objects.create(name="allowed", slug="allowed") self.allowed = Application.objects.create(name="allowed", slug="allowed")
self.denied = Application.objects.create(name="denied", slug="denied") self.denied = Application.objects.create(name="denied", slug="denied")
PolicyBinding.objects.create( PolicyBinding.objects.create(

View File

@ -6,7 +6,6 @@ from django.utils.encoding import force_str
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
class TestAuthenticatedSessionsAPI(APITestCase): class TestAuthenticatedSessionsAPI(APITestCase):
@ -14,7 +13,7 @@ class TestAuthenticatedSessionsAPI(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
self.user = create_test_admin_user() self.user = User.objects.get(username="akadmin")
self.other_user = User.objects.create(username="normal-user") self.other_user = User.objects.create(username="normal-user")
def test_list(self): def test_list(self):

View File

@ -1,40 +0,0 @@
"""group tests"""
from django.test.testcases import TestCase
from authentik.core.models import Group, User
class TestGroups(TestCase):
"""Test group membership"""
def test_group_membership_simple(self):
"""Test simple membership"""
user = User.objects.create(username="user")
user2 = User.objects.create(username="user2")
group = Group.objects.create(name="group")
group.users.add(user)
self.assertTrue(group.is_member(user))
self.assertFalse(group.is_member(user2))
def test_group_membership_parent(self):
"""Test parent membership"""
user = User.objects.create(username="user")
user2 = User.objects.create(username="user2")
first = Group.objects.create(name="first")
second = Group.objects.create(name="second", parent=first)
second.users.add(user)
self.assertTrue(first.is_member(user))
self.assertFalse(first.is_member(user2))
def test_group_membership_parent_extra(self):
"""Test parent membership"""
user = User.objects.create(username="user")
user2 = User.objects.create(username="user2")
first = Group.objects.create(name="first")
second = Group.objects.create(name="second", parent=first)
third = Group.objects.create(name="third", parent=second)
second.users.add(user)
self.assertTrue(first.is_member(user))
self.assertFalse(first.is_member(user2))
self.assertFalse(third.is_member(user))
self.assertFalse(third.is_member(user2))

View File

@ -5,7 +5,6 @@ from django.test.testcases import TestCase
from django.urls import reverse from django.urls import reverse
from authentik.core.models import User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
class TestImpersonation(TestCase): class TestImpersonation(TestCase):
@ -14,14 +13,14 @@ class TestImpersonation(TestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
self.other_user = User.objects.create(username="to-impersonate") self.other_user = User.objects.create(username="to-impersonate")
self.user = create_test_admin_user() self.akadmin = User.objects.get(username="akadmin")
def test_impersonate_simple(self): def test_impersonate_simple(self):
"""test simple impersonation and un-impersonation""" """test simple impersonation and un-impersonation"""
# test with an inactive user to ensure that still works # test with an inactive user to ensure that still works
self.other_user.is_active = False self.other_user.is_active = False
self.other_user.save() self.other_user.save()
self.client.force_login(self.user) self.client.force_login(self.akadmin)
self.client.get( self.client.get(
reverse( reverse(
@ -33,13 +32,13 @@ class TestImpersonation(TestCase):
response = self.client.get(reverse("authentik_api:user-me")) response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode()) response_body = loads(response.content.decode())
self.assertEqual(response_body["user"]["username"], self.other_user.username) self.assertEqual(response_body["user"]["username"], self.other_user.username)
self.assertEqual(response_body["original"]["username"], self.user.username) self.assertEqual(response_body["original"]["username"], self.akadmin.username)
self.client.get(reverse("authentik_core:impersonate-end")) self.client.get(reverse("authentik_core:impersonate-end"))
response = self.client.get(reverse("authentik_api:user-me")) response = self.client.get(reverse("authentik_api:user-me"))
response_body = loads(response.content.decode()) response_body = loads(response.content.decode())
self.assertEqual(response_body["user"]["username"], self.user.username) self.assertEqual(response_body["user"]["username"], self.akadmin.username)
self.assertNotIn("original", response_body) self.assertNotIn("original", response_body)
def test_impersonate_denied(self): def test_impersonate_denied(self):
@ -47,7 +46,7 @@ class TestImpersonation(TestCase):
self.client.force_login(self.other_user) self.client.force_login(self.other_user)
self.client.get( self.client.get(
reverse("authentik_core:impersonate-init", kwargs={"user_id": self.user.pk}) reverse("authentik_core:impersonate-init", kwargs={"user_id": self.akadmin.pk})
) )
response = self.client.get(reverse("authentik_api:user-me")) response = self.client.get(reverse("authentik_api:user-me"))
@ -59,4 +58,4 @@ class TestImpersonation(TestCase):
self.client.force_login(self.other_user) self.client.force_login(self.other_user)
response = self.client.get(reverse("authentik_core:impersonate-end")) response = self.client.get(reverse("authentik_core:impersonate-end"))
self.assertRedirects(response, reverse("authentik_core:if-user")) self.assertRedirects(response, reverse("authentik_core:if-admin"))

View File

@ -2,7 +2,7 @@
from time import sleep from time import sleep
from typing import Callable, Type from typing import Callable, Type
from django.test import RequestFactory, TestCase from django.test import TestCase
from django.utils.timezone import now from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
@ -30,9 +30,6 @@ class TestModels(TestCase):
def source_tester_factory(test_model: Type[Stage]) -> Callable: def source_tester_factory(test_model: Type[Stage]) -> Callable:
"""Test source""" """Test source"""
factory = RequestFactory()
request = factory.get("/")
def tester(self: TestModels): def tester(self: TestModels):
model_class = None model_class = None
if test_model._meta.abstract: if test_model._meta.abstract:
@ -41,8 +38,8 @@ def source_tester_factory(test_model: Type[Stage]) -> Callable:
model_class = test_model() model_class = test_model()
model_class.slug = "test" model_class.slug = "test"
self.assertIsNotNone(model_class.component) self.assertIsNotNone(model_class.component)
_ = model_class.ui_login_button(request) _ = model_class.ui_login_button
_ = model_class.ui_user_settings() _ = model_class.ui_user_settings
return tester return tester
@ -52,7 +49,7 @@ def provider_tester_factory(test_model: Type[Stage]) -> Callable:
def tester(self: TestModels): def tester(self: TestModels):
model_class = None model_class = None
if test_model._meta.abstract: # pragma: no cover if test_model._meta.abstract:
model_class = test_model.__bases__[0]() model_class = test_model.__bases__[0]()
else: else:
model_class = test_model() model_class = test_model()
@ -62,6 +59,6 @@ def provider_tester_factory(test_model: Type[Stage]) -> Callable:
for model in all_subclasses(Source): for model in all_subclasses(Source):
setattr(TestModels, f"test_source_{model.__name__}", source_tester_factory(model)) setattr(TestModels, f"test_model_{model.__name__}", source_tester_factory(model))
for model in all_subclasses(Provider): for model in all_subclasses(Provider):
setattr(TestModels, f"test_provider_{model.__name__}", provider_tester_factory(model)) setattr(TestModels, f"test_model_{model.__name__}", provider_tester_factory(model))

View File

@ -6,8 +6,7 @@ from rest_framework.serializers import ValidationError
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.api.propertymappings import PropertyMappingSerializer from authentik.core.api.propertymappings import PropertyMappingSerializer
from authentik.core.models import PropertyMapping from authentik.core.models import PropertyMapping, User
from authentik.core.tests.utils import create_test_admin_user
class TestPropertyMappingAPI(APITestCase): class TestPropertyMappingAPI(APITestCase):
@ -18,7 +17,7 @@ class TestPropertyMappingAPI(APITestCase):
self.mapping = PropertyMapping.objects.create( self.mapping = PropertyMapping.objects.create(
name="dummy", expression="""return {'foo': 'bar'}""" name="dummy", expression="""return {'foo': 'bar'}"""
) )
self.user = create_test_admin_user() self.user = User.objects.get(username="akadmin")
self.client.force_login(self.user) self.client.force_login(self.user)
def test_test_call(self): def test_test_call(self):
@ -41,7 +40,7 @@ class TestPropertyMappingAPI(APITestCase):
expr = "return True" expr = "return True"
self.assertEqual(PropertyMappingSerializer().validate_expression(expr), expr) self.assertEqual(PropertyMappingSerializer().validate_expression(expr), expr)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
PropertyMappingSerializer().validate_expression("/") print(PropertyMappingSerializer().validate_expression("/"))
def test_types(self): def test_types(self):
"""Test PropertyMappigns's types endpoint""" """Test PropertyMappigns's types endpoint"""

View File

@ -2,8 +2,7 @@
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import PropertyMapping from authentik.core.models import PropertyMapping, User
from authentik.core.tests.utils import create_test_admin_user
class TestProvidersAPI(APITestCase): class TestProvidersAPI(APITestCase):
@ -14,7 +13,7 @@ class TestProvidersAPI(APITestCase):
self.mapping = PropertyMapping.objects.create( self.mapping = PropertyMapping.objects.create(
name="dummy", expression="""return {'foo': 'bar'}""" name="dummy", expression="""return {'foo': 'bar'}"""
) )
self.user = create_test_admin_user() self.user = User.objects.get(username="akadmin")
self.client.force_login(self.user) self.client.force_login(self.user)
def test_types(self): def test_types(self):

View File

@ -1,6 +1,4 @@
"""Test token API""" """Test token API"""
from json import loads
from django.urls.base import reverse from django.urls.base import reverse
from django.utils.timezone import now from django.utils.timezone import now
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
@ -8,7 +6,6 @@ from rest_framework.test import APITestCase
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents, User
from authentik.core.tasks import clean_expired_models from authentik.core.tasks import clean_expired_models
from authentik.core.tests.utils import create_test_admin_user
class TestTokenAPI(APITestCase): class TestTokenAPI(APITestCase):
@ -16,8 +13,7 @@ class TestTokenAPI(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
super().setUp() super().setUp()
self.user = User.objects.create(username="testuser") self.user = User.objects.get(username="akadmin")
self.admin = create_test_admin_user()
self.client.force_login(self.user) self.client.force_login(self.user)
def test_token_create(self): def test_token_create(self):
@ -59,29 +55,3 @@ class TestTokenAPI(APITestCase):
clean_expired_models.delay().get() clean_expired_models.delay().get()
token.refresh_from_db() token.refresh_from_db()
self.assertNotEqual(key, token.key) self.assertNotEqual(key, token.key)
def test_list(self):
"""Test Token List (Test normal authentication)"""
token_should: Token = Token.objects.create(
identifier="test", expiring=False, user=self.user
)
Token.objects.create(identifier="test-2", expiring=False, user=get_anonymous_user())
response = self.client.get(reverse(("authentik_api:token-list")))
body = loads(response.content)
self.assertEqual(len(body["results"]), 1)
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
def test_list_admin(self):
"""Test Token List (Test with admin auth)"""
self.client.force_login(self.admin)
token_should: Token = Token.objects.create(
identifier="test", expiring=False, user=self.user
)
token_should_not: Token = Token.objects.create(
identifier="test-2", expiring=False, user=get_anonymous_user()
)
response = self.client.get(reverse(("authentik_api:token-list")))
body = loads(response.content)
self.assertEqual(len(body["results"]), 2)
self.assertEqual(body["results"][0]["identifier"], token_should.identifier)
self.assertEqual(body["results"][1]["identifier"], token_should_not.identifier)

View File

@ -4,7 +4,7 @@ from django.test import TestCase
from authentik.core.auth import TokenBackend from authentik.core.auth import TokenBackend
from authentik.core.models import Token, TokenIntents, User from authentik.core.models import Token, TokenIntents, User
from authentik.flows.planner import FlowPlan from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN from authentik.flows.views import SESSION_KEY_PLAN
from authentik.lib.tests.utils import get_request from authentik.lib.tests.utils import get_request

View File

@ -2,9 +2,8 @@
from django.urls.base import reverse from django.urls.base import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from authentik.core.models import USER_ATTRIBUTE_CHANGE_EMAIL, USER_ATTRIBUTE_CHANGE_USERNAME, User from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_tenant from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.models import FlowDesignation
from authentik.stages.email.models import EmailStage from authentik.stages.email.models import EmailStage
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
@ -13,37 +12,9 @@ class TestUsersAPI(APITestCase):
"""Test Users API""" """Test Users API"""
def setUp(self) -> None: def setUp(self) -> None:
self.admin = create_test_admin_user() self.admin = User.objects.get(username="akadmin")
self.user = User.objects.create(username="test-user") self.user = User.objects.create(username="test-user")
def test_update_self(self):
"""Test update_self"""
self.client.force_login(self.admin)
response = self.client.put(
reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
)
self.assertEqual(response.status_code, 200)
def test_update_self_username_denied(self):
"""Test update_self"""
self.admin.attributes[USER_ATTRIBUTE_CHANGE_USERNAME] = False
self.admin.save()
self.client.force_login(self.admin)
response = self.client.put(
reverse("authentik_api:user-update-self"), data={"username": "foo", "name": "foo"}
)
self.assertEqual(response.status_code, 400)
def test_update_self_email_denied(self):
"""Test update_self"""
self.admin.attributes[USER_ATTRIBUTE_CHANGE_EMAIL] = False
self.admin.save()
self.client.force_login(self.admin)
response = self.client.put(
reverse("authentik_api:user-update-self"), data={"email": "foo", "name": "foo"}
)
self.assertEqual(response.status_code, 400)
def test_metrics(self): def test_metrics(self):
"""Test user's metrics""" """Test user's metrics"""
self.client.force_login(self.admin) self.client.force_login(self.admin)
@ -70,8 +41,10 @@ class TestUsersAPI(APITestCase):
def test_recovery(self): def test_recovery(self):
"""Test user recovery link (no recovery flow set)""" """Test user recovery link (no recovery flow set)"""
flow = create_test_flow(FlowDesignation.RECOVERY) flow = Flow.objects.create(
tenant: Tenant = create_test_tenant() name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
)
tenant: Tenant = Tenant.objects.first()
tenant.flow_recovery = flow tenant.flow_recovery = flow
tenant.save() tenant.save()
self.client.force_login(self.admin) self.client.force_login(self.admin)
@ -98,8 +71,10 @@ class TestUsersAPI(APITestCase):
"""Test user recovery link (no email stage)""" """Test user recovery link (no email stage)"""
self.user.email = "foo@bar.baz" self.user.email = "foo@bar.baz"
self.user.save() self.user.save()
flow = create_test_flow(designation=FlowDesignation.RECOVERY) flow = Flow.objects.create(
tenant: Tenant = create_test_tenant() name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
)
tenant: Tenant = Tenant.objects.first()
tenant.flow_recovery = flow tenant.flow_recovery = flow
tenant.save() tenant.save()
self.client.force_login(self.admin) self.client.force_login(self.admin)
@ -112,8 +87,10 @@ class TestUsersAPI(APITestCase):
"""Test user recovery link""" """Test user recovery link"""
self.user.email = "foo@bar.baz" self.user.email = "foo@bar.baz"
self.user.save() self.user.save()
flow = create_test_flow(FlowDesignation.RECOVERY) flow = Flow.objects.create(
tenant: Tenant = create_test_tenant() name="test", title="test", slug="test", designation=FlowDesignation.RECOVERY
)
tenant: Tenant = Tenant.objects.first()
tenant.flow_recovery = flow tenant.flow_recovery = flow
tenant.save() tenant.save()

View File

@ -1,57 +0,0 @@
"""Test Utils"""
from typing import Optional
from django.utils.text import slugify
from authentik.core.models import Group, User
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow, FlowDesignation
from authentik.lib.generators import generate_id
from authentik.tenants.models import Tenant
def create_test_flow(designation: FlowDesignation = FlowDesignation.STAGE_CONFIGURATION) -> Flow:
"""Generate a flow that can be used for testing"""
uid = generate_id(10)
return Flow.objects.create(
name=uid,
title=uid,
slug=slugify(uid),
designation=designation,
)
def create_test_admin_user(name: Optional[str] = None) -> User:
"""Generate a test-admin user"""
uid = generate_id(20) if not name else name
group = Group.objects.create(name=uid, is_superuser=True)
user: User = User.objects.create(
username=uid,
name=uid,
email=f"{uid}@goauthentik.io",
)
user.set_password(uid)
user.save()
group.users.add(user)
return user
def create_test_tenant() -> Tenant:
"""Generate a test tenant, removing all other tenants to make sure this one
matches."""
uid = generate_id(20)
Tenant.objects.all().delete()
return Tenant.objects.create(domain=uid, default=True)
def create_test_cert() -> CertificateKeyPair:
"""Generate a certificate for testing"""
CertificateKeyPair.objects.filter(name="goauthentik.io").delete()
builder = CertificateBuilder()
builder.common_name = "goauthentik.io"
builder.build(
subject_alt_names=["goauthentik.io"],
validity_days=360,
)
return builder.save()

View File

@ -12,7 +12,7 @@ from authentik.core.views.session import EndSessionView
urlpatterns = [ urlpatterns = [
path( path(
"", "",
login_required(RedirectView.as_view(pattern_name="authentik_core:if-user")), login_required(RedirectView.as_view(pattern_name="authentik_core:if-admin")),
name="root-redirect", name="root-redirect",
), ),
# Impersonation # Impersonation
@ -32,11 +32,6 @@ urlpatterns = [
ensure_csrf_cookie(TemplateView.as_view(template_name="if/admin.html")), ensure_csrf_cookie(TemplateView.as_view(template_name="if/admin.html")),
name="if-admin", name="if-admin",
), ),
path(
"if/user/",
ensure_csrf_cookie(TemplateView.as_view(template_name="if/user.html")),
name="if-user",
),
path( path(
"if/flow/<slug:flow_slug>/", "if/flow/<slug:flow_slug>/",
ensure_csrf_cookie(FlowInterfaceView.as_view()), ensure_csrf_cookie(FlowInterfaceView.as_view()),

View File

@ -28,7 +28,7 @@ class ImpersonateInitView(View):
Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be) Event.new(EventAction.IMPERSONATION_STARTED).from_http(request, user_to_be)
return redirect("authentik_core:if-user") return redirect("authentik_core:if-admin")
class ImpersonateEndView(View): class ImpersonateEndView(View):
@ -41,7 +41,7 @@ class ImpersonateEndView(View):
or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session or SESSION_IMPERSONATE_ORIGINAL_USER not in request.session
): ):
LOGGER.debug("Can't end impersonation", user=request.user) LOGGER.debug("Can't end impersonation", user=request.user)
return redirect("authentik_core:if-user") return redirect("authentik_core:if-admin")
original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER] original_user = request.session[SESSION_IMPERSONATE_ORIGINAL_USER]

View File

@ -14,5 +14,4 @@ class FlowInterfaceView(TemplateView):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug")) kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View File

@ -20,7 +20,6 @@ from authentik.api.decorators import permission_required
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.crypto.builder import CertificateBuilder from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.managed import MANAGED_KEY
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -100,7 +99,6 @@ class CertificateKeyPairSerializer(ModelSerializer):
"private_key_available", "private_key_available",
"certificate_download_url", "certificate_download_url",
"private_key_download_url", "private_key_download_url",
"managed",
] ]
extra_kwargs = { extra_kwargs = {
"key_data": {"write_only": True}, "key_data": {"write_only": True},
@ -136,17 +134,15 @@ class CertificateKeyPairFilter(FilterSet):
class Meta: class Meta:
model = CertificateKeyPair model = CertificateKeyPair
fields = ["name", "managed"] fields = ["name"]
class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
"""CertificateKeyPair Viewset""" """CertificateKeyPair Viewset"""
queryset = CertificateKeyPair.objects.exclude(managed=MANAGED_KEY) queryset = CertificateKeyPair.objects.all()
serializer_class = CertificateKeyPairSerializer serializer_class = CertificateKeyPairSerializer
filterset_class = CertificateKeyPairFilter filterset_class = CertificateKeyPairFilter
ordering = ["name"]
search_fields = ["name"]
@permission_required(None, ["authentik_crypto.add_certificatekeypair"]) @permission_required(None, ["authentik_crypto.add_certificatekeypair"])
@extend_schema( @extend_schema(
@ -192,7 +188,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
secret=certificate, secret=certificate,
type="certificate", type="certificate",
).from_http(request) ).from_http(request)
if "download" in request.query_params: if "download" in request._request.GET:
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
response = HttpResponse( response = HttpResponse(
certificate.certificate_data, content_type="application/x-pem-file" certificate.certificate_data, content_type="application/x-pem-file"
@ -223,7 +219,7 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
secret=certificate, secret=certificate,
type="private_key", type="private_key",
).from_http(request) ).from_http(request)
if "download" in request.query_params: if "download" in request._request.GET:
# Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html
response = HttpResponse(certificate.key_data, content_type="application/x-pem-file") response = HttpResponse(certificate.key_data, content_type="application/x-pem-file")
response[ response[

View File

@ -1,6 +1,4 @@
"""authentik crypto app config""" """authentik crypto app config"""
from importlib import import_module
from django.apps import AppConfig from django.apps import AppConfig
@ -10,7 +8,3 @@ class AuthentikCryptoConfig(AppConfig):
name = "authentik.crypto" name = "authentik.crypto"
label = "authentik_crypto" label = "authentik_crypto"
verbose_name = "authentik Crypto" verbose_name = "authentik Crypto"
def ready(self):
import_module("authentik.crypto.managed")
import_module("authentik.crypto.tasks")

View File

@ -24,17 +24,16 @@ class CertificateBuilder:
self.__builder = None self.__builder = None
self.__certificate = None self.__certificate = None
self.common_name = "authentik Self-signed Certificate" self.common_name = "authentik Self-signed Certificate"
self.cert = CertificateKeyPair()
def save(self) -> Optional[CertificateKeyPair]: def save(self) -> Optional[CertificateKeyPair]:
"""Save generated certificate as model""" """Save generated certificate as model"""
if not self.__certificate: if not self.__certificate:
raise ValueError("Certificated hasn't been built yet") raise ValueError("Certificated hasn't been built yet")
self.cert.name = self.common_name return CertificateKeyPair.objects.create(
self.cert.certificate_data = self.certificate name=self.common_name,
self.cert.key_data = self.private_key certificate_data=self.certificate,
self.cert.save() key_data=self.private_key,
return self.cert )
def build( def build(
self, self,

View File

@ -1,40 +0,0 @@
"""Crypto managed objects"""
from datetime import datetime
from typing import Optional
from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair
from authentik.managed.manager import ObjectManager
MANAGED_KEY = "goauthentik.io/crypto/jwt-managed"
class CryptoManager(ObjectManager):
"""Crypto managed objects"""
def _create(self, cert: Optional[CertificateKeyPair] = None):
builder = CertificateBuilder()
builder.common_name = "goauthentik.io"
builder.build(
subject_alt_names=["goauthentik.io"],
validity_days=360,
)
if not cert:
cert = CertificateKeyPair()
cert.certificate_data = builder.certificate
cert.key_data = builder.private_key
cert.name = "authentik Internal JWT Certificate"
cert.managed = MANAGED_KEY
cert.save()
def reconcile(self):
certs = CertificateKeyPair.objects.filter(managed=MANAGED_KEY)
if not certs.exists():
self._create()
return []
cert: CertificateKeyPair = certs.first()
now = datetime.now()
if now < cert.certificate.not_valid_before or now > cert.certificate.not_valid_after:
self._create(cert)
return []
return []

View File

@ -1,24 +0,0 @@
# Generated by Django 3.2.8 on 2021-10-09 17:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_crypto", "0002_create_self_signed_kp"),
]
operations = [
migrations.AddField(
model_name="certificatekeypair",
name="managed",
field=models.TextField(
default=None,
help_text="Objects which are managed by authentik. These objects are created and updated automatically. This is flag only indicates that an object can be overwritten by migrations. You can still modify the objects via the API, but expect changes to be overwritten in a later update.",
null=True,
unique=True,
verbose_name="Managed by authentik",
),
),
]

View File

@ -11,15 +11,11 @@ from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509 import Certificate, load_pem_x509_certificate from cryptography.x509 import Certificate, load_pem_x509_certificate
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from structlog.stdlib import get_logger
from authentik.lib.models import CreatedUpdatedModel from authentik.lib.models import CreatedUpdatedModel
from authentik.managed.models import ManagedModel
LOGGER = get_logger()
class CertificateKeyPair(ManagedModel, CreatedUpdatedModel): class CertificateKeyPair(CreatedUpdatedModel):
"""CertificateKeyPair that can be used for signing or encrypting if `key_data` """CertificateKeyPair that can be used for signing or encrypting if `key_data`
is set, otherwise it can be used to verify remote data.""" is set, otherwise it can be used to verify remote data."""
@ -58,15 +54,14 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
@property @property
def private_key(self) -> Optional[RSAPrivateKey]: def private_key(self) -> Optional[RSAPrivateKey]:
"""Get python cryptography PrivateKey instance""" """Get python cryptography PrivateKey instance"""
if not self._private_key and self.key_data != "": if not self._private_key and self._private_key != "":
try: try:
self._private_key = load_pem_private_key( self._private_key = load_pem_private_key(
str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])), str.encode("\n".join([x.strip() for x in self.key_data.split("\n")])),
password=None, password=None,
backend=default_backend(), backend=default_backend(),
) )
except ValueError as exc: except ValueError:
LOGGER.warning(exc)
return None return None
return self._private_key return self._private_key
@ -83,7 +78,9 @@ class CertificateKeyPair(ManagedModel, CreatedUpdatedModel):
@property @property
def kid(self): def kid(self):
"""Get Key ID used for JWKS""" """Get Key ID used for JWKS"""
return md5(self.key_data.encode("utf-8")).hexdigest() if self.key_data else "" # nosec return "{0}".format(
md5(self.key_data.encode("utf-8")).hexdigest() if self.key_data else "" # nosec
)
def __str__(self) -> str: def __str__(self) -> str:
return f"Certificate-Key Pair {self.name}" return f"Certificate-Key Pair {self.name}"

View File

@ -1,10 +0,0 @@
"""Crypto task Settings"""
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
"crypto_certificate_discovery": {
"task": "authentik.crypto.tasks.certificate_discovery",
"schedule": crontab(minute="*/5"),
"options": {"queue": "authentik_scheduled"},
},
}

View File

@ -1,92 +0,0 @@
"""Crypto tasks"""
from glob import glob
from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.x509.base import load_pem_x509_certificate
from django.utils.translation import gettext_lazy as _
from structlog.stdlib import get_logger
from authentik.crypto.models import CertificateKeyPair
from authentik.events.monitored_tasks import (
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
MANAGED_DISCOVERED = "goauthentik.io/crypto/discovered/%s"
def ensure_private_key_valid(body: str):
"""Attempt loading of an RSA Private key without password"""
load_pem_private_key(
str.encode("\n".join([x.strip() for x in body.split("\n")])),
password=None,
backend=default_backend(),
)
return body
def ensure_certificate_valid(body: str):
"""Attempt loading of a PEM-encoded certificate"""
load_pem_x509_certificate(body.encode("utf-8"), default_backend())
return body
@CELERY_APP.task(bind=True, base=MonitoredTask)
@prefill_task
def certificate_discovery(self: MonitoredTask):
"""Discover and update certificates form the filesystem"""
certs = {}
private_keys = {}
discovered = 0
for file in glob(CONFIG.y("cert_discovery_dir") + "/**", recursive=True):
path = Path(file)
if not path.exists():
continue
if path.is_dir():
continue
# Support certbot's directory structure
if path.name in ["fullchain.pem", "privkey.pem"]:
cert_name = path.parent.name
else:
cert_name = path.name.replace(path.suffix, "")
try:
with open(path, "r+", encoding="utf-8") as _file:
body = _file.read()
if "BEGIN RSA PRIVATE KEY" in body:
private_keys[cert_name] = ensure_private_key_valid(body)
else:
certs[cert_name] = ensure_certificate_valid(body)
except (OSError, ValueError) as exc:
LOGGER.warning("Failed to open file or invalid format", exc=exc, file=path)
discovered += 1
for name, cert_data in certs.items():
cert = CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % name).first()
if not cert:
cert = CertificateKeyPair(
name=name,
managed=MANAGED_DISCOVERED % name,
)
dirty = False
if cert.certificate_data != cert_data:
cert.certificate_data = cert_data
dirty = True
if name in private_keys:
if cert.key_data == private_keys[name]:
cert.key_data = private_keys[name]
dirty = True
if dirty:
cert.save()
self.set_status(
TaskResult(
TaskResultStatus.SUCCESSFUL,
messages=[_("Successfully imported %(count)d files." % {"count": discovered})],
)
)

View File

@ -1,37 +1,25 @@
"""Crypto tests""" """Crypto tests"""
import datetime import datetime
from os import makedirs
from tempfile import TemporaryDirectory
from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.api.used_by import DeleteAction from authentik.core.api.used_by import DeleteAction
from authentik.core.tests.utils import create_test_admin_user, create_test_cert, create_test_flow from authentik.core.models import User
from authentik.crypto.api import CertificateKeyPairSerializer from authentik.crypto.api import CertificateKeyPairSerializer
from authentik.crypto.builder import CertificateBuilder from authentik.crypto.builder import CertificateBuilder
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_key from authentik.lib.generators import generate_key
from authentik.providers.oauth2.models import OAuth2Provider from authentik.providers.oauth2.models import OAuth2Provider
class TestCrypto(APITestCase): class TestCrypto(TestCase):
"""Test Crypto validation""" """Test Crypto validation"""
def test_model_private(self):
"""Test model private key"""
cert = CertificateKeyPair.objects.create(
name="test",
certificate_data="foo",
key_data="foo",
)
self.assertIsNone(cert.private_key)
def test_serializer(self): def test_serializer(self):
"""Test API Validation""" """Test API Validation"""
keypair = create_test_cert() keypair = CertificateKeyPair.objects.first()
self.assertTrue( self.assertTrue(
CertificateKeyPairSerializer( CertificateKeyPairSerializer(
data={ data={
@ -66,38 +54,10 @@ class TestCrypto(APITestCase):
self.assertEqual(instance.name, "test-cert") self.assertEqual(instance.name, "test-cert")
self.assertEqual((instance.certificate.not_valid_after - now).days, 2) self.assertEqual((instance.certificate.not_valid_after - now).days, 2)
def test_builder_api(self):
"""Test Builder (via API)"""
self.client.force_login(create_test_admin_user())
self.client.post(
reverse("authentik_api:certificatekeypair-generate"),
data={"common_name": "foo", "subject_alt_name": "bar,baz", "validity_days": 3},
)
self.assertTrue(CertificateKeyPair.objects.filter(name="foo").exists())
def test_builder_api_invalid(self):
"""Test Builder (via API) (invalid)"""
self.client.force_login(create_test_admin_user())
response = self.client.post(
reverse("authentik_api:certificatekeypair-generate"),
data={},
)
self.assertEqual(response.status_code, 400)
def test_list(self):
"""Test API List"""
self.client.force_login(create_test_admin_user())
response = self.client.get(
reverse(
"authentik_api:certificatekeypair-list",
)
)
self.assertEqual(200, response.status_code)
def test_certificate_download(self): def test_certificate_download(self):
"""Test certificate export (download)""" """Test certificate export (download)"""
self.client.force_login(create_test_admin_user()) self.client.force_login(User.objects.get(username="akadmin"))
keypair = create_test_cert() keypair = CertificateKeyPair.objects.first()
response = self.client.get( response = self.client.get(
reverse( reverse(
"authentik_api:certificatekeypair-view-certificate", "authentik_api:certificatekeypair-view-certificate",
@ -117,8 +77,8 @@ class TestCrypto(APITestCase):
def test_private_key_download(self): def test_private_key_download(self):
"""Test private_key export (download)""" """Test private_key export (download)"""
self.client.force_login(create_test_admin_user()) self.client.force_login(User.objects.get(username="akadmin"))
keypair = create_test_cert() keypair = CertificateKeyPair.objects.first()
response = self.client.get( response = self.client.get(
reverse( reverse(
"authentik_api:certificatekeypair-view-private-key", "authentik_api:certificatekeypair-view-private-key",
@ -138,15 +98,15 @@ class TestCrypto(APITestCase):
def test_used_by(self): def test_used_by(self):
"""Test used_by endpoint""" """Test used_by endpoint"""
self.client.force_login(create_test_admin_user()) self.client.force_login(User.objects.get(username="akadmin"))
keypair = create_test_cert() keypair = CertificateKeyPair.objects.first()
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="test", name="test",
client_id="test", client_id="test",
client_secret=generate_key(), client_secret=generate_key(),
authorization_flow=create_test_flow(), authorization_flow=Flow.objects.first(),
redirect_uris="http://localhost", redirect_uris="http://localhost",
rsa_key=keypair, rsa_key=CertificateKeyPair.objects.first(),
) )
response = self.client.get( response = self.client.get(
reverse( reverse(
@ -167,33 +127,3 @@ class TestCrypto(APITestCase):
} }
], ],
) )
def test_discovery(self):
"""Test certificate discovery"""
builder = CertificateBuilder()
builder.common_name = "test-cert"
with self.assertRaises(ValueError):
builder.save()
builder.build(
subject_alt_names=[],
validity_days=3,
)
with TemporaryDirectory() as temp_dir:
with open(f"{temp_dir}/foo.pem", "w+", encoding="utf-8") as _cert:
_cert.write(builder.certificate)
with open(f"{temp_dir}/foo.key", "w+", encoding="utf-8") as _key:
_key.write(builder.private_key)
makedirs(f"{temp_dir}/foo.bar", exist_ok=True)
with open(f"{temp_dir}/foo.bar/fullchain.pem", "w+", encoding="utf-8") as _cert:
_cert.write(builder.certificate)
with open(f"{temp_dir}/foo.bar/privkey.pem", "w+", encoding="utf-8") as _key:
_key.write(builder.private_key)
with CONFIG.patch("cert_discovery_dir", temp_dir):
# pyright: reportGeneralTypeIssues=false
certificate_discovery() # pylint: disable=no-value-for-parameter
self.assertTrue(
CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo").exists()
)
self.assertTrue(
CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo.bar").exists()
)

View File

@ -1,6 +1,4 @@
"""Events API Views""" """Events API Views"""
from json import loads
import django_filters import django_filters
from django.db.models.aggregates import Count from django.db.models.aggregates import Count
from django.db.models.fields.json import KeyTextTransform from django.db.models.fields.json import KeyTextTransform
@ -14,7 +12,6 @@ from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer from authentik.core.api.utils import PassiveSerializer, TypeCreateSerializer
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -113,20 +110,13 @@ class EventViewSet(ModelViewSet):
@extend_schema( @extend_schema(
methods=["GET"], methods=["GET"],
responses={200: EventTopPerUserSerializer(many=True)}, responses={200: EventTopPerUserSerializer(many=True)},
filters=[],
parameters=[ parameters=[
OpenApiParameter(
"action",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
),
OpenApiParameter( OpenApiParameter(
"top_n", "top_n",
type=OpenApiTypes.INT, type=OpenApiTypes.INT,
location=OpenApiParameter.QUERY, location=OpenApiParameter.QUERY,
required=False, required=False,
), )
], ],
) )
@action(detail=False, methods=["GET"], pagination_class=None) @action(detail=False, methods=["GET"], pagination_class=None)
@ -147,40 +137,6 @@ class EventViewSet(ModelViewSet):
.order_by("-counted_events")[:top_n] .order_by("-counted_events")[:top_n]
) )
@extend_schema(
methods=["GET"],
responses={200: CoordinateSerializer(many=True)},
filters=[],
parameters=[
OpenApiParameter(
"action",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
),
OpenApiParameter(
"query",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
),
],
)
@action(detail=False, methods=["GET"], pagination_class=None)
def per_month(self, request: Request):
"""Get the count of events per month"""
filtered_action = request.query_params.get("action", EventAction.LOGIN)
try:
query = loads(request.query_params.get("query", "{}"))
except ValueError:
return Response(status=400)
return Response(
get_objects_for_user(request.user, "authentik_events.view_event")
.filter(action=filtered_action)
.filter(**query)
.get_events_per_day()
)
@extend_schema(responses={200: TypeCreateSerializer(many=True)}) @extend_schema(responses={200: TypeCreateSerializer(many=True)})
@action(detail=False, pagination_class=None, filter_backends=[]) @action(detail=False, pagination_class=None, filter_backends=[])
def actions(self, request: Request) -> Response: def actions(self, request: Request) -> Response:

View File

@ -1,28 +0,0 @@
"""NotificationWebhookMapping API Views"""
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.events.models import NotificationWebhookMapping
class NotificationWebhookMappingSerializer(ModelSerializer):
"""NotificationWebhookMapping Serializer"""
class Meta:
model = NotificationWebhookMapping
fields = [
"pk",
"name",
"expression",
]
class NotificationWebhookMappingViewSet(UsedByMixin, ModelViewSet):
"""NotificationWebhookMapping Viewset"""
queryset = NotificationWebhookMapping.objects.all()
serializer_class = NotificationWebhookMappingSerializer
filterset_fields = ["name"]
ordering = ["name"]

View File

@ -1,10 +1,7 @@
"""NotificationTransport API Views""" """NotificationTransport API Views"""
from typing import Any
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, ListField, SerializerMethodField from rest_framework.fields import CharField, ListField, SerializerMethodField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
@ -32,14 +29,6 @@ class NotificationTransportSerializer(ModelSerializer):
"""Return selected mode with a UI Label""" """Return selected mode with a UI Label"""
return TransportMode(instance.mode).label return TransportMode(instance.mode).label
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
"""Ensure the required fields are set."""
mode = attrs.get("mode")
if mode in [TransportMode.WEBHOOK, TransportMode.WEBHOOK_SLACK]:
if "webhook_url" not in attrs or attrs.get("webhook_url", "") == "":
raise ValidationError("Webhook URL may not be empty.")
return attrs
class Meta: class Meta:
model = NotificationTransport model = NotificationTransport
@ -49,7 +38,6 @@ class NotificationTransportSerializer(ModelSerializer):
"mode", "mode",
"mode_verbose", "mode_verbose",
"webhook_url", "webhook_url",
"webhook_mapping",
"send_once", "send_once",
] ]

View File

@ -7,7 +7,6 @@ from typing import Optional, TypedDict
from geoip2.database import Reader from geoip2.database import Reader
from geoip2.errors import GeoIP2Error from geoip2.errors import GeoIP2Error
from geoip2.models import City from geoip2.models import City
from sentry_sdk.hub import Hub
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
@ -63,10 +62,6 @@ class GeoIPReader:
def city(self, ip_address: str) -> Optional[City]: def city(self, ip_address: str) -> Optional[City]:
"""Wrapper for Reader.city""" """Wrapper for Reader.city"""
with Hub.current.start_span(
op="authentik.events.geo.city",
description=ip_address,
):
if not self.enabled: if not self.enabled:
return None return None
self.__check_expired() self.__check_expired()

View File

@ -7,25 +7,16 @@ from django.core.exceptions import SuspiciousOperation
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import post_save, pre_delete from django.db.models.signals import post_save, pre_delete
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django_otp.plugins.otp_static.models import StaticToken
from guardian.models import UserObjectPermission from guardian.models import UserObjectPermission
from authentik.core.middleware import LOCAL from authentik.core.middleware import LOCAL
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import User
from authentik.events.models import Event, EventAction, Notification from authentik.events.models import Event, EventAction, Notification
from authentik.events.signals import EventNewThread from authentik.events.signals import EventNewThread
from authentik.events.utils import model_to_dict from authentik.events.utils import model_to_dict
from authentik.lib.sentry import before_send from authentik.lib.sentry import before_send
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
IGNORED_MODELS = (
Event,
Notification,
UserObjectPermission,
AuthenticatedSession,
StaticToken,
)
class AuditMiddleware: class AuditMiddleware:
"""Register handlers for duration of request-response that log creation/update/deletion """Register handlers for duration of request-response that log creation/update/deletion
@ -91,7 +82,7 @@ class AuditMiddleware:
user: User, request: HttpRequest, sender, instance: Model, created: bool, **_ user: User, request: HttpRequest, sender, instance: Model, created: bool, **_
): ):
"""Signal handler for all object's post_save""" """Signal handler for all object's post_save"""
if isinstance(instance, IGNORED_MODELS): if isinstance(instance, (Event, Notification, UserObjectPermission)):
return return
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
@ -101,7 +92,7 @@ class AuditMiddleware:
# pylint: disable=unused-argument # pylint: disable=unused-argument
def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_): def pre_delete_handler(user: User, request: HttpRequest, sender, instance: Model, **_):
"""Signal handler for all object's pre_delete""" """Signal handler for all object's pre_delete"""
if isinstance(instance, IGNORED_MODELS): # pragma: no cover if isinstance(instance, (Event, Notification, UserObjectPermission)): # pragma: no cover
return return
EventNewThread( EventNewThread(

View File

@ -1,548 +0,0 @@
# Generated by Django 3.2.8 on 2021-10-10 16:01
import uuid
from datetime import timedelta
from typing import Iterable
import django.db.models.deletion
from django.apps.registry import Apps
from django.conf import settings
from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import authentik.events.models
import authentik.lib.models
from authentik.events.models import EventAction, NotificationSeverity, TransportMode
def convert_user_to_json(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Event = apps.get_model("authentik_events", "Event")
db_alias = schema_editor.connection.alias
for event in Event.objects.all():
event.delete()
# Because event objects cannot be updated, we have to re-create them
event.pk = None
event.user_json = authentik.events.models.get_user(event.user) if event.user else {}
event._state.adding = True
event.save()
def notify_configuration_error(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
EventMatcherPolicy = apps.get_model("authentik_policies_event_matcher", "EventMatcherPolicy")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
admin_group = (
Group.objects.using(db_alias).filter(name="authentik Admins", is_superuser=True).first()
)
policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-configuration-error",
defaults={"action": EventAction.CONFIGURATION_ERROR},
)
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
name="default-notify-configuration-error",
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
)
trigger.transports.set(
NotificationTransport.objects.using(db_alias).filter(name="default-email-transport")
)
trigger.save()
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy,
defaults={
"order": 0,
},
)
def notify_update(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
EventMatcherPolicy = apps.get_model("authentik_policies_event_matcher", "EventMatcherPolicy")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
admin_group = (
Group.objects.using(db_alias).filter(name="authentik Admins", is_superuser=True).first()
)
policy, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-update",
defaults={"action": EventAction.UPDATE_AVAILABLE},
)
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
name="default-notify-update",
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
)
trigger.transports.set(
NotificationTransport.objects.using(db_alias).filter(name="default-email-transport")
)
trigger.save()
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy,
defaults={
"order": 0,
},
)
def notify_exception(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Group = apps.get_model("authentik_core", "Group")
PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding")
EventMatcherPolicy = apps.get_model("authentik_policies_event_matcher", "EventMatcherPolicy")
NotificationRule = apps.get_model("authentik_events", "NotificationRule")
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
admin_group = (
Group.objects.using(db_alias).filter(name="authentik Admins", is_superuser=True).first()
)
policy_policy_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-policy-exception",
defaults={"action": EventAction.POLICY_EXCEPTION},
)
policy_pm_exc, _ = EventMatcherPolicy.objects.using(db_alias).update_or_create(
name="default-match-property-mapping-exception",
defaults={"action": EventAction.PROPERTY_MAPPING_EXCEPTION},
)
trigger, _ = NotificationRule.objects.using(db_alias).update_or_create(
name="default-notify-exception",
defaults={"group": admin_group, "severity": NotificationSeverity.ALERT},
)
trigger.transports.set(
NotificationTransport.objects.using(db_alias).filter(name="default-email-transport")
)
trigger.save()
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy_policy_exc,
defaults={
"order": 0,
},
)
PolicyBinding.objects.using(db_alias).update_or_create(
target=trigger,
policy=policy_pm_exc,
defaults={
"order": 1,
},
)
def transport_email_global(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
NotificationTransport = apps.get_model("authentik_events", "NotificationTransport")
NotificationTransport.objects.using(db_alias).update_or_create(
name="default-email-transport",
defaults={"mode": TransportMode.EMAIL},
)
def token_view_to_secret_view(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
from authentik.events.models import EventAction
db_alias = schema_editor.connection.alias
Event = apps.get_model("authentik_events", "Event")
events = Event.objects.using(db_alias).filter(action="token_view")
for event in events:
event.context["secret"] = event.context.pop("token")
event.action = EventAction.SECRET_VIEW
Event.objects.using(db_alias).bulk_update(events, ["context", "action"])
# Taken from https://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console
def progress_bar(
iterable: Iterable,
prefix="Writing: ",
suffix=" finished",
decimals=1,
length=100,
fill="",
print_end="\r",
):
"""
Call in a loop to create terminal progress bar
@params:
iteration - Required : current iteration (Int)
total - Required : total iterations (Int)
prefix - Optional : prefix string (Str)
suffix - Optional : suffix string (Str)
decimals - Optional : positive number of decimals in percent complete (Int)
length - Optional : character length of bar (Int)
fill - Optional : bar fill character (Str)
print_end - Optional : end character (e.g. "\r", "\r\n") (Str)
"""
total = len(iterable)
if total < 1:
return
def print_progress_bar(iteration):
"""Progress Bar Printing Function"""
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
filledLength = int(length * iteration // total)
bar = fill * filledLength + "-" * (length - filledLength)
print(f"\r{prefix} |{bar}| {percent}% {suffix}", end=print_end)
# Initial Call
print_progress_bar(0)
# Update Progress Bar
for i, item in enumerate(iterable):
yield item
print_progress_bar(i + 1)
# Print New Line on Complete
print()
def update_expires(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
db_alias = schema_editor.connection.alias
Event = apps.get_model("authentik_events", "event")
all_events = Event.objects.using(db_alias).all()
if all_events.count() < 1:
return
print("\nAdding expiry to events, this might take a couple of minutes...")
for event in progress_bar(all_events):
event.expires = event.created + timedelta(days=365)
event.save()
class Migration(migrations.Migration):
replaces = [
("authentik_events", "0001_initial"),
("authentik_events", "0002_auto_20200918_2116"),
("authentik_events", "0003_auto_20200917_1155"),
("authentik_events", "0004_auto_20200921_1829"),
("authentik_events", "0005_auto_20201005_2139"),
("authentik_events", "0006_auto_20201017_2024"),
("authentik_events", "0007_auto_20201215_0939"),
("authentik_events", "0008_auto_20201220_1651"),
("authentik_events", "0009_auto_20201227_1210"),
("authentik_events", "0010_notification_notificationtransport_notificationrule"),
("authentik_events", "0011_notification_rules_default_v1"),
("authentik_events", "0012_auto_20210202_1821"),
("authentik_events", "0013_auto_20210209_1657"),
("authentik_events", "0014_expiry"),
("authentik_events", "0015_alter_event_action"),
("authentik_events", "0016_add_tenant"),
("authentik_events", "0017_alter_event_action"),
("authentik_events", "0018_auto_20210911_2217"),
("authentik_events", "0019_alter_notificationtransport_webhook_url"),
]
initial = True
dependencies = [
("authentik_policies", "0004_policy_execution_logging"),
("authentik_core", "0016_auto_20201202_2234"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("authentik_policies_event_matcher", "0003_auto_20210110_1907"),
("authentik_core", "0028_alter_token_intent"),
]
operations = [
migrations.CreateModel(
name="Event",
fields=[
(
"event_uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
(
"action",
models.TextField(
choices=[
("LOGIN", "login"),
("LOGIN_FAILED", "login_failed"),
("LOGOUT", "logout"),
("AUTHORIZE_APPLICATION", "authorize_application"),
("SUSPICIOUS_REQUEST", "suspicious_request"),
("SIGN_UP", "sign_up"),
("PASSWORD_RESET", "password_reset"),
("INVITE_CREATED", "invitation_created"),
("INVITE_USED", "invitation_used"),
("IMPERSONATION_STARTED", "impersonation_started"),
("IMPERSONATION_ENDED", "impersonation_ended"),
("CUSTOM", "custom"),
]
),
),
("date", models.DateTimeField(auto_now_add=True)),
("app", models.TextField()),
("context", models.JSONField(blank=True, default=dict)),
("client_ip", models.GenericIPAddressField(null=True)),
("created", models.DateTimeField(auto_now_add=True)),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
("user_json", models.JSONField(default=dict)),
],
options={
"verbose_name": "Event",
"verbose_name_plural": "Events",
},
),
migrations.RunPython(
code=convert_user_to_json,
),
migrations.RemoveField(
model_name="event",
name="user",
),
migrations.RenameField(
model_name="event",
old_name="user_json",
new_name="user",
),
migrations.RemoveField(
model_name="event",
name="date",
),
migrations.CreateModel(
name="NotificationTransport",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("name", models.TextField(unique=True)),
(
"mode",
models.TextField(
choices=[
("webhook", "Generic Webhook"),
("webhook_slack", "Slack Webhook (Slack/Discord)"),
("email", "Email"),
]
),
),
("webhook_url", models.TextField(blank=True)),
],
options={
"verbose_name": "Notification Transport",
"verbose_name_plural": "Notification Transports",
},
),
migrations.CreateModel(
name="NotificationRule",
fields=[
(
"policybindingmodel_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_policies.policybindingmodel",
),
),
("name", models.TextField(unique=True)),
(
"severity",
models.TextField(
choices=[("notice", "Notice"), ("warning", "Warning"), ("alert", "Alert")],
default="notice",
help_text="Controls which severity level the created notifications will have.",
),
),
(
"group",
models.ForeignKey(
blank=True,
help_text="Define which group of users this notification should be sent and shown to. If left empty, Notification won't ben sent.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_core.group",
),
),
(
"transports",
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",
),
),
],
options={
"verbose_name": "Notification Rule",
"verbose_name_plural": "Notification Rules",
},
bases=("authentik_policies.policybindingmodel",),
),
migrations.CreateModel(
name="Notification",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
(
"severity",
models.TextField(
choices=[("notice", "Notice"), ("warning", "Warning"), ("alert", "Alert")]
),
),
("body", models.TextField()),
("created", models.DateTimeField(auto_now_add=True)),
("seen", models.BooleanField(default=False)),
(
"event",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_events.event",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Notification",
"verbose_name_plural": "Notifications",
},
),
migrations.RunPython(
code=transport_email_global,
),
migrations.RunPython(
code=notify_configuration_error,
),
migrations.RunPython(
code=notify_update,
),
migrations.RunPython(
code=notify_exception,
),
migrations.AddField(
model_name="notificationtransport",
name="send_once",
field=models.BooleanField(
default=False,
help_text="Only send notification once, for example when sending a webhook into a chat channel.",
),
),
migrations.RunPython(
code=token_view_to_secret_view,
),
migrations.AddField(
model_name="event",
name="expires",
field=models.DateTimeField(default=authentik.events.models.default_event_duration),
),
migrations.AddField(
model_name="event",
name="expiring",
field=models.BooleanField(default=True),
),
migrations.RunPython(
code=update_expires,
),
migrations.AddField(
model_name="event",
name="tenant",
field=models.JSONField(blank=True, default=authentik.events.models.default_tenant),
),
migrations.AlterField(
model_name="event",
name="action",
field=models.TextField(
choices=[
("login", "Login"),
("login_failed", "Login Failed"),
("logout", "Logout"),
("user_write", "User Write"),
("suspicious_request", "Suspicious Request"),
("password_set", "Password Set"),
("secret_view", "Secret View"),
("secret_rotate", "Secret Rotate"),
("invitation_used", "Invite Used"),
("authorize_application", "Authorize Application"),
("source_linked", "Source Linked"),
("impersonation_started", "Impersonation Started"),
("impersonation_ended", "Impersonation Ended"),
("flow_execution", "Flow Execution"),
("policy_execution", "Policy Execution"),
("policy_exception", "Policy Exception"),
("property_mapping_exception", "Property Mapping Exception"),
("system_task_execution", "System Task Execution"),
("system_task_exception", "System Task Exception"),
("system_exception", "System Exception"),
("configuration_error", "Configuration Error"),
("model_created", "Model Created"),
("model_updated", "Model Updated"),
("model_deleted", "Model Deleted"),
("email_sent", "Email Sent"),
("update_available", "Update Available"),
("custom_", "Custom Prefix"),
]
),
),
migrations.CreateModel(
name="NotificationWebhookMapping",
fields=[
(
"propertymapping_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Notification Webhook Mapping",
"verbose_name_plural": "Notification Webhook Mappings",
},
bases=("authentik_core.propertymapping",),
),
migrations.AddField(
model_name="notificationtransport",
name="webhook_mapping",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_events.notificationwebhookmapping",
),
),
migrations.AlterField(
model_name="notificationtransport",
name="webhook_url",
field=models.TextField(
blank=True, validators=[authentik.lib.models.DomainlessURLValidator()]
),
),
]

View File

@ -1,46 +0,0 @@
# Generated by Django 3.2.6 on 2021-09-11 22:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0028_alter_token_intent"),
("authentik_events", "0017_alter_event_action"),
]
operations = [
migrations.CreateModel(
name="NotificationWebhookMapping",
fields=[
(
"propertymapping_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_core.propertymapping",
),
),
],
options={
"verbose_name": "Notification Webhook Mapping",
"verbose_name_plural": "Notification Webhook Mappings",
},
bases=("authentik_core.propertymapping",),
),
migrations.AddField(
model_name="notificationtransport",
name="webhook_mapping",
field=models.ForeignKey(
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="authentik_events.notificationwebhookmapping",
),
),
]

View File

@ -1,22 +0,0 @@
# Generated by Django 3.2.7 on 2021-10-04 15:31
from django.db import migrations, models
import authentik.lib.models
class Migration(migrations.Migration):
dependencies = [
("authentik_events", "0018_auto_20210911_2217"),
]
operations = [
migrations.AlterField(
model_name="notificationtransport",
name="webhook_url",
field=models.TextField(
blank=True, validators=[authentik.lib.models.DomainlessURLValidator()]
),
),
]

View File

@ -1,35 +1,25 @@
"""authentik events models""" """authentik events models"""
import time
from collections import Counter
from datetime import timedelta from datetime import timedelta
from inspect import currentframe from inspect import getmodule, stack
from smtplib import SMTPException from smtplib import SMTPException
from typing import TYPE_CHECKING, Optional, Type, Union from typing import Optional, Union
from uuid import uuid4 from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db.models import Count, ExpressionWrapper, F
from django.db.models.fields import DurationField
from django.db.models.functions import ExtractHour
from django.db.models.functions.datetime import ExtractDay
from django.db.models.manager import Manager
from django.db.models.query import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.http.request import QueryDict
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from requests import RequestException from requests import RequestException, post
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik import __version__ from authentik import __version__
from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER from authentik.core.middleware import SESSION_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_USER
from authentik.core.models import ExpiringModel, Group, PropertyMapping, User from authentik.core.models import ExpiringModel, Group, User
from authentik.events.geo import GEOIP_READER from authentik.events.geo import GEOIP_READER
from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict from authentik.events.utils import cleanse_dict, get_user, model_to_dict, sanitize_dict
from authentik.lib.models import DomainlessURLValidator
from authentik.lib.sentry import SentryIgnoredException from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.http import get_client_ip, get_http_session from authentik.lib.utils.http import get_client_ip
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel from authentik.policies.models import PolicyBindingModel
from authentik.stages.email.utils import TemplateEmailMessage from authentik.stages.email.utils import TemplateEmailMessage
@ -37,8 +27,6 @@ from authentik.tenants.models import Tenant
from authentik.tenants.utils import DEFAULT_TENANT from authentik.tenants.utils import DEFAULT_TENANT
LOGGER = get_logger("authentik.events") LOGGER = get_logger("authentik.events")
if TYPE_CHECKING:
from rest_framework.serializers import Serializer
def default_event_duration(): def default_event_duration():
@ -78,7 +66,6 @@ class EventAction(models.TextChoices):
IMPERSONATION_STARTED = "impersonation_started" IMPERSONATION_STARTED = "impersonation_started"
IMPERSONATION_ENDED = "impersonation_ended" IMPERSONATION_ENDED = "impersonation_ended"
FLOW_EXECUTION = "flow_execution"
POLICY_EXECUTION = "policy_execution" POLICY_EXECUTION = "policy_execution"
POLICY_EXCEPTION = "policy_exception" POLICY_EXCEPTION = "policy_exception"
PROPERTY_MAPPING_EXCEPTION = "property_mapping_exception" PROPERTY_MAPPING_EXCEPTION = "property_mapping_exception"
@ -99,72 +86,6 @@ class EventAction(models.TextChoices):
CUSTOM_PREFIX = "custom_" CUSTOM_PREFIX = "custom_"
class EventQuerySet(QuerySet):
"""Custom events query set with helper functions"""
def get_events_per_hour(self) -> list[dict[str, int]]:
"""Get event count by hour in the last day, fill with zeros"""
date_from = now() - timedelta(days=1)
result = (
self.filter(created__gte=date_from)
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
.annotate(age_hours=ExtractHour("age"))
.values("age_hours")
.annotate(count=Count("pk"))
.order_by("age_hours")
)
data = Counter({int(d["age_hours"]): d["count"] for d in result})
results = []
_now = now()
for hour in range(0, -24, -1):
results.append(
{
"x_cord": time.mktime((_now + timedelta(hours=hour)).timetuple()) * 1000,
"y_cord": data[hour * -1],
}
)
return results
def get_events_per_day(self) -> list[dict[str, int]]:
"""Get event count by hour in the last day, fill with zeros"""
date_from = now() - timedelta(weeks=4)
result = (
self.filter(created__gte=date_from)
.annotate(age=ExpressionWrapper(now() - F("created"), output_field=DurationField()))
.annotate(age_days=ExtractDay("age"))
.values("age_days")
.annotate(count=Count("pk"))
.order_by("age_days")
)
data = Counter({int(d["age_days"]): d["count"] for d in result})
results = []
_now = now()
for day in range(0, -30, -1):
results.append(
{
"x_cord": time.mktime((_now + timedelta(days=day)).timetuple()) * 1000,
"y_cord": data[day * -1],
}
)
return results
class EventManager(Manager):
"""Custom helper methods for Events"""
def get_queryset(self) -> QuerySet:
"""use custom queryset"""
return EventQuerySet(self.model, using=self._db)
def get_events_per_hour(self) -> list[dict[str, int]]:
"""Wrap method from queryset"""
return self.get_queryset().get_events_per_hour()
def get_events_per_day(self) -> list[dict[str, int]]:
"""Wrap method from queryset"""
return self.get_queryset().get_events_per_day()
class Event(ExpiringModel): class Event(ExpiringModel):
"""An individual Audit/Metrics/Notification/Error Event""" """An individual Audit/Metrics/Notification/Error Event"""
@ -180,8 +101,6 @@ class Event(ExpiringModel):
# Shadow the expires attribute from ExpiringModel to override the default duration # Shadow the expires attribute from ExpiringModel to override the default duration
expires = models.DateTimeField(default=default_event_duration) expires = models.DateTimeField(default=default_event_duration)
objects = EventManager()
@staticmethod @staticmethod
def _get_app_from_request(request: HttpRequest) -> str: def _get_app_from_request(request: HttpRequest) -> str:
if not isinstance(request, HttpRequest): if not isinstance(request, HttpRequest):
@ -192,15 +111,14 @@ class Event(ExpiringModel):
def new( def new(
action: Union[str, EventAction], action: Union[str, EventAction],
app: Optional[str] = None, app: Optional[str] = None,
_inspect_offset: int = 1,
**kwargs, **kwargs,
) -> "Event": ) -> "Event":
"""Create new Event instance from arguments. Instance is NOT saved.""" """Create new Event instance from arguments. Instance is NOT saved."""
if not isinstance(action, EventAction): if not isinstance(action, EventAction):
action = EventAction.CUSTOM_PREFIX + action action = EventAction.CUSTOM_PREFIX + action
if not app: if not app:
current = currentframe() app = getmodule(stack()[_inspect_offset][0]).__name__
parent = current.f_back
app = parent.f_globals["__name__"]
cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs)) cleaned_kwargs = cleanse_dict(sanitize_dict(kwargs))
event = Event(action=action, app=app, context=cleaned_kwargs) event = Event(action=action, app=app, context=cleaned_kwargs)
return event return event
@ -219,9 +137,8 @@ class Event(ExpiringModel):
`user` arguments optionally overrides user from requests.""" `user` arguments optionally overrides user from requests."""
if request: if request:
self.context["http_request"] = { self.context["http_request"] = {
"path": request.path, "path": request.get_full_path(),
"method": request.method, "method": request.method,
"args": QueryDict(request.META.get("QUERY_STRING", "")),
} }
if hasattr(request, "tenant"): if hasattr(request, "tenant"):
tenant: Tenant = request.tenant tenant: Tenant = request.tenant
@ -302,10 +219,7 @@ class NotificationTransport(models.Model):
name = models.TextField(unique=True) name = models.TextField(unique=True)
mode = models.TextField(choices=TransportMode.choices) mode = models.TextField(choices=TransportMode.choices)
webhook_url = models.TextField(blank=True, validators=[DomainlessURLValidator()]) webhook_url = models.TextField(blank=True)
webhook_mapping = models.ForeignKey(
"NotificationWebhookMapping", on_delete=models.SET_DEFAULT, null=True, default=None
)
send_once = models.BooleanField( send_once = models.BooleanField(
default=False, default=False,
help_text=_( help_text=_(
@ -325,22 +239,15 @@ class NotificationTransport(models.Model):
def send_webhook(self, notification: "Notification") -> list[str]: def send_webhook(self, notification: "Notification") -> list[str]:
"""Send notification to generic webhook""" """Send notification to generic webhook"""
default_body = { try:
response = post(
self.webhook_url,
json={
"body": notification.body, "body": notification.body,
"severity": notification.severity, "severity": notification.severity,
"user_email": notification.user.email, "user_email": notification.user.email,
"user_username": notification.user.username, "user_username": notification.user.username,
} },
if self.webhook_mapping:
default_body = self.webhook_mapping.evaluate(
user=notification.user,
request=None,
notification=notification,
)
try:
response = get_http_session().post(
self.webhook_url,
json=default_body,
) )
response.raise_for_status() response.raise_for_status()
except RequestException as exc: except RequestException as exc:
@ -390,7 +297,7 @@ class NotificationTransport(models.Model):
if notification.event: if notification.event:
body["attachments"][0]["title"] = notification.event.action body["attachments"][0]["title"] = notification.event.action
try: try:
response = get_http_session().post(self.webhook_url, json=body) response = post(self.webhook_url, json=body)
response.raise_for_status() response.raise_for_status()
except RequestException as exc: except RequestException as exc:
text = exc.response.text if exc.response else str(exc) text = exc.response.text if exc.response else str(exc)
@ -507,25 +414,3 @@ class NotificationRule(PolicyBindingModel):
verbose_name = _("Notification Rule") verbose_name = _("Notification Rule")
verbose_name_plural = _("Notification Rules") verbose_name_plural = _("Notification Rules")
class NotificationWebhookMapping(PropertyMapping):
"""Modify the schema and layout of the webhook being sent"""
@property
def component(self) -> str:
return "ak-property-mapping-notification-form"
@property
def serializer(self) -> Type["Serializer"]:
from authentik.events.api.notification_mapping import NotificationWebhookMappingSerializer
return NotificationWebhookMappingSerializer
def __str__(self):
return f"Notification Webhook Mapping {self.name}"
class Meta:
verbose_name = _("Notification Webhook Mapping")
verbose_name_plural = _("Notification Webhook Mappings")

View File

@ -3,13 +3,12 @@ from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from timeit import default_timer from timeit import default_timer
from traceback import format_tb
from typing import Any, Optional from typing import Any, Optional
from celery import Task from celery import Task
from django.core.cache import cache from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from prometheus_client import Gauge from prometheus_client import Gauge
from structlog.stdlib import get_logger
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.lib.utils.errors import exception_to_string from authentik.lib.utils.errors import exception_to_string
@ -20,8 +19,6 @@ GAUGE_TASKS = Gauge(
["task_name", "task_uid", "status"], ["task_name", "task_uid", "status"],
) )
LOGGER = get_logger()
class TaskResultStatus(Enum): class TaskResultStatus(Enum):
"""Possible states of tasks""" """Possible states of tasks"""
@ -29,7 +26,6 @@ class TaskResultStatus(Enum):
SUCCESSFUL = 1 SUCCESSFUL = 1
WARNING = 2 WARNING = 2
ERROR = 4 ERROR = 4
UNKNOWN = 8
@dataclass @dataclass
@ -46,6 +42,7 @@ class TaskResult:
def with_error(self, exc: Exception) -> "TaskResult": def with_error(self, exc: Exception) -> "TaskResult":
"""Since errors might not always be pickle-able, set the traceback""" """Since errors might not always be pickle-able, set the traceback"""
self.messages.extend(format_tb(exc.__traceback__))
self.messages.append(str(exc)) self.messages.append(str(exc))
return self return self
@ -81,7 +78,7 @@ class TaskInfo:
@staticmethod @staticmethod
def by_name(name: str) -> Optional["TaskInfo"]: def by_name(name: str) -> Optional["TaskInfo"]:
"""Get TaskInfo Object by name""" """Get TaskInfo Object by name"""
return cache.get(f"task_{name}", None) return cache.get(f"task_{name}")
def delete(self): def delete(self):
"""Delete task info from cache""" """Delete task info from cache"""
@ -186,21 +183,5 @@ class MonitoredTask(Task):
raise NotImplementedError raise NotImplementedError
def prefill_task(func): for task in TaskInfo.all().values():
"""Ensure a task's details are always in cache, so it can always be triggered via API""" task.set_prom_metrics()
status = TaskInfo.by_name(func.__name__)
if status:
return func
TaskInfo(
task_name=func.__name__,
task_description=func.__doc__,
result=TaskResult(TaskResultStatus.UNKNOWN, messages=[_("Task has not been run yet.")]),
task_call_module=func.__module__,
task_call_func=func.__name__,
# We don't have real values for these attributes but they cannot be null
start_timestamp=default_timer(),
finish_timestamp=default_timer(),
finish_time=datetime.now(),
).save(86400)
LOGGER.debug("prefilled task", task_name=func.__name__)
return func

Some files were not shown because too many files have changed in this diff Show More