Compare commits

..

2 Commits

Author SHA1 Message Date
5a7508d2e0 core: fix token expiration not being updated upon key rotation
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-08-12 17:24:19 +02:00
9c31ea1aa6 core: fix expired tokens not being returned by API
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-08-12 17:24:19 +02:00
1389 changed files with 47245 additions and 167870 deletions

View File

@ -1,5 +1,5 @@
[bumpversion] [bumpversion]
current_version = 2022.6.2 current_version = 2021.7.3
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*) parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
@ -17,16 +17,20 @@ values =
beta beta
stable stable
[bumpversion:file:pyproject.toml] [bumpversion:file:website/docs/installation/docker-compose.md]
[bumpversion:file:docker-compose.yml] [bumpversion:file:docker-compose.yml]
[bumpversion:file:schema.yml] [bumpversion:file:schema.yml]
[bumpversion:file:.github/workflows/release-publish.yml] [bumpversion:file:.github/workflows/release.yml]
[bumpversion:file:authentik/__init__.py] [bumpversion:file:authentik/__init__.py]
[bumpversion:file:internal/constants/constants.go] [bumpversion:file:internal/constants/constants.go]
[bumpversion:file:web/src/constants.ts] [bumpversion:file:web/src/constants.ts]
[bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md]
[bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md]

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,49 +0,0 @@
name: 'Prepare docker environment variables'
description: 'Prepare docker environment variables'
outputs:
shouldBuild:
description: "Whether to build image or not"
value: ${{ steps.ev.outputs.shouldBuild }}
branchName:
description: "Branch name"
value: ${{ steps.ev.outputs.branchName }}
branchNameContainer:
description: "Branch name (for containers)"
value: ${{ steps.ev.outputs.branchNameContainer }}
timestamp:
description: "Timestamp"
value: ${{ steps.ev.outputs.timestamp }}
sha:
description: "sha"
value: ${{ steps.ev.outputs.sha }}
runs:
using: "composite"
steps:
- name: Generate config
id: ev
shell: python
run: |
"""Helper script to get the actual branch name, docker safe"""
import os
from time import time
env_pr_branch = "GITHUB_HEAD_REF"
default_branch = "GITHUB_REF"
sha = "GITHUB_SHA"
branch_name = os.environ[default_branch]
if os.environ.get(env_pr_branch, "") != "":
branch_name = os.environ[env_pr_branch]
should_build = str(os.environ.get("DOCKER_USERNAME", "") != "").lower()
print("##[set-output name=branchName]%s" % branch_name)
print(
"##[set-output name=branchNameContainer]%s"
% branch_name.replace("refs/heads/", "").replace("/", "-")
)
print("##[set-output name=timestamp]%s" % int(time()))
print("##[set-output name=sha]%s" % os.environ[sha])
print("##[set-output name=shouldBuild]%s" % should_build)

View File

@ -1,45 +0,0 @@
name: 'Setup authentik testing environemnt'
description: 'Setup authentik testing environemnt'
runs:
using: "composite"
steps:
- name: Install poetry
shell: bash
run: |
pipx install poetry || true
sudo apt update
sudo apt install -y libxmlsec1-dev pkg-config gettext
- name: Setup python and restore poetry
uses: actions/setup-python@v3
with:
python-version: '3.10'
cache: 'poetry'
- name: Setup node
uses: actions/setup-node@v3.1.0
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Setup dependencies
shell: bash
run: |
docker-compose -f .github/actions/setup/docker-compose.yml up -d
poetry env use python3.10
poetry install
npm install -g pyright@1.1.136
- name: Generate config
shell: poetry run python {0}
run: |
from authentik.lib.generators import generate_id
from yaml import safe_dump
with open("local.env.yml", "w") as _config:
safe_dump(
{
"log_level": "debug",
"secret_key": generate_id(),
},
_config,
default_flow_style=False,
)

View File

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

View File

@ -1,7 +1,7 @@
<!-- <!--
👋 Hello there! Welcome. 👋 Hello there! Welcome.
Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/main/CONTRIBUTING.md#how-can-i-contribute). Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/master/CONTRIBUTING.md#how-can-i-contribute).
--> -->
# Details # Details

4
.github/stale.yml vendored
View File

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

View File

@ -1,234 +0,0 @@
name: authentik-ci-main
on:
push:
branches:
- main
- next
- version-*
paths-ignore:
- website
pull_request:
branches:
- main
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
jobs:
lint:
strategy:
fail-fast: false
matrix:
job:
- pylint
- black
- isort
- bandit
- pyright
- pending-migrations
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run job
run: poetry run make ci-${{ matrix.job }}
test-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run migrations
run: poetry run python -m lifecycle.migrate
test-migrations-from-stable:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup authentik env
uses: ./.github/actions/setup
- name: checkout stable
run: |
# Copy current, latest config to local
cp authentik/lib/default.yml local.env.yml
cp -R .github ..
cp -R scripts ..
git checkout $(git describe --abbrev=0 --match 'version/*')
rm -rf .github/ scripts/
mv ../.github ../scripts .
- name: Setup authentik env (ensure stable deps are installed)
uses: ./.github/actions/setup
- name: run migrations to stable
run: poetry run python -m lifecycle.migrate
- name: checkout current code
run: |
set -x
git fetch
git reset --hard HEAD
git clean -d -fx .
git checkout $GITHUB_SHA
poetry install
- name: Setup authentik env (ensure latest deps are installed)
uses: ./.github/actions/setup
- name: migrate to latest
run: poetry run python -m lifecycle.migrate
test-unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: testspace-com/setup-testspace@v1
with:
domain: ${{github.repository_owner}}
- name: run unittest
run: |
poetry run make test
poetry run coverage xml
- name: run testspace
if: ${{ always() }}
run: |
testspace [unittest]unittest.xml --link=codecov
- if: ${{ always() }}
uses: codecov/codecov-action@v3
test-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: testspace-com/setup-testspace@v1
with:
domain: ${{github.repository_owner}}
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1.2.0
- name: run integration
run: |
poetry run make test-integration
poetry run coverage xml
- name: run testspace
if: ${{ always() }}
run: |
testspace [integration]unittest.xml --link=codecov
- if: ${{ always() }}
uses: codecov/codecov-action@v3
test-e2e-provider:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: testspace-com/setup-testspace@v1
with:
domain: ${{github.repository_owner}}
- name: Setup authentik env
run: |
docker-compose -f tests/e2e/docker-compose.yml up -d
- id: cache-web
uses: actions/cache@v3
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: |
npm ci
npm run build
- name: run e2e
run: |
poetry run make test-e2e-provider
poetry run coverage xml
- name: run testspace
if: ${{ always() }}
run: |
testspace [e2e-provider]unittest.xml --link=codecov
- if: ${{ always() }}
uses: codecov/codecov-action@v3
test-e2e-rest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: testspace-com/setup-testspace@v1
with:
domain: ${{github.repository_owner}}
- name: Setup authentik env
run: |
docker-compose -f tests/e2e/docker-compose.yml up -d
- id: cache-web
uses: actions/cache@v3
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/**') }}
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web/
run: |
npm ci
npm run build
- name: run e2e
run: |
poetry run make test-e2e-rest
poetry run coverage xml
- name: run testspace
if: ${{ always() }}
run: |
testspace [e2e-rest]unittest.xml --link=codecov
- if: ${{ always() }}
uses: codecov/codecov-action@v3
ci-core-mark:
needs:
- lint
- test-migrations
- test-migrations-from-stable
- test-unittest
- test-integration
- test-e2e-rest
- test-e2e-provider
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:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: prepare variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
uses: ./.github/actions/docker-setup
- name: Login to Container Registry
uses: docker/login-action@v2
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Building Docker Image
uses: docker/build-push-action@v3
with:
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
tags: |
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}
ghcr.io/goauthentik/dev-server:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: ${{ matrix.arch }}

View File

@ -1,134 +0,0 @@
name: authentik-ci-outpost
on:
push:
branches:
- main
- next
- version-*
pull_request:
branches:
- main
jobs:
lint-golint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.17"
- name: Prepare and generate API
run: |
# Create folder structure for go embeds
mkdir -p web/dist
mkdir -p website/help
touch web/dist/test website/help/test
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
test-unittest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.17"
- name: Generate API
run: make gen-client-go
- name: Go unittests
run: |
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./...
ci-outpost-mark:
needs:
- lint-golint
- test-unittest
runs-on: ubuntu-latest
steps:
- run: echo mark
build:
timeout-minutes: 120
needs:
- ci-outpost-mark
strategy:
fail-fast: false
matrix:
type:
- proxy
- ldap
arch:
- 'linux/amd64'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: prepare variables
id: ev
uses: ./.github/actions/docker-setup
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- name: Login to Container Registry
uses: docker/login-action@v2
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Generate API
run: make gen-client-go
- name: Building Docker Image
uses: docker/build-push-action@v3
with:
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
tags: |
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}
ghcr.io/goauthentik/dev-${{ matrix.type }}:gh-${{ steps.ev.outputs.sha }}
file: ${{ matrix.type }}.Dockerfile
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: ${{ matrix.arch }}
build-outpost-binary:
timeout-minutes: 120
needs:
- ci-outpost-mark
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
type:
- proxy
- ldap
goos: [linux]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.17"
- uses: actions/setup-node@v3.3.0
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Generate API
run: make gen-client-go
- name: Build web
working-directory: web/
run: |
npm ci
npm run build-proxy
- name: Build outpost
run: |
set -x
export GOOS=${{ matrix.goos }}
export GOARCH=${{ matrix.goarch }}
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
- uses: actions/upload-artifact@v3
with:
name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
path: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}

View File

@ -1,87 +0,0 @@
name: authentik-ci-web
on:
push:
branches:
- main
- next
- version-*
pull_request:
branches:
- main
jobs:
lint-eslint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3.3.0
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-web
- name: Eslint
working-directory: web/
run: npm run lint
lint-prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3.3.0
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-web
- name: prettier
working-directory: web/
run: npm run prettier-check
lint-lit-analyse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3.3.0
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-web
- name: lit-analyse
working-directory: web/
run: npm run lit-analyse
ci-web-mark:
needs:
- lint-eslint
- lint-prettier
- lint-lit-analyse
runs-on: ubuntu-latest
steps:
- run: echo mark
build:
needs:
- ci-web-mark
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3.3.0
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: Generate API
run: make gen-client-web
- name: build
working-directory: web/
run: npm run build

View File

@ -1,33 +0,0 @@
name: authentik-ci-website
on:
push:
branches:
- main
- next
- version-*
pull_request:
branches:
- main
jobs:
lint-prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3.3.0
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: website/package-lock.json
- working-directory: website/
run: npm ci
- name: prettier
working-directory: website/
run: npm run prettier-check
ci-website-mark:
needs:
- lint-prettier
runs-on: ubuntu-latest
steps:
- run: echo mark

View File

@ -1,60 +0,0 @@
name: "CodeQL"
on:
push:
branches: [ main, '*', next, version* ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '30 6 * * 5'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go', 'javascript', 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@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-main

View File

@ -1,158 +0,0 @@
name: authentik-on-release
on:
release:
types: [published, created]
jobs:
# Build
build-server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Docker Login Registry
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Building Docker Image
uses: docker/build-push-action@v3
with:
push: ${{ github.event_name == 'release' }}
tags: |
beryju/authentik:2022.6.2,
beryju/authentik:latest,
ghcr.io/goauthentik/server:2022.6.2,
ghcr.io/goauthentik/server:latest
platforms: linux/amd64,linux/arm64
context: .
build-outpost:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
type:
- proxy
- ldap
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.17"
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Docker Login Registry
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Building Docker Image
uses: docker/build-push-action@v3
with:
push: ${{ github.event_name == 'release' }}
tags: |
beryju/authentik-${{ matrix.type }}:2022.6.2,
beryju/authentik-${{ matrix.type }}:latest,
ghcr.io/goauthentik/${{ matrix.type }}:2022.6.2,
ghcr.io/goauthentik/${{ matrix.type }}:latest
file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
build-outpost-binary:
timeout-minutes: 120
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
type:
- proxy
- ldap
goos: [linux, darwin]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version: "^1.17"
- uses: actions/setup-node@v3.3.0
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Build web
working-directory: web/
run: |
npm ci
npm run build-proxy
- name: Build outpost
run: |
set -x
export GOOS=${{ matrix.goos }}
export GOARCH=${{ matrix.goarch }}
go build -tags=outpost_static_embed -v -o ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }} ./cmd/${{ matrix.type }}
- name: Upload binaries to release
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
tag: ${{ github.ref }}
test-release:
needs:
- build-server
- build-outpost
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run test suite in final docker images
run: |
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test
sentry-release:
needs:
- build-server
- build-outpost
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Get static files from docker image
run: |
docker pull ghcr.io/goauthentik/server:latest
container=$(docker container create ghcr.io/goauthentik/server:latest)
docker cp ${container}:web/ .
- name: Create a Sentry.io release
uses: getsentry/action-release@v1
if: ${{ github.event_name == 'release' }}
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: beryjuorg
SENTRY_PROJECT: authentik
SENTRY_URL: https://sentry.beryju.org
with:
version: authentik@2022.6.2
environment: beryjuorg-prod
sourcemaps: './web/dist'
url_prefix: '~/static/dist'

182
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,182 @@
name: authentik-on-release
on:
release:
types: [published, created]
push:
branches:
- version-*
jobs:
# Build
build-server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- 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:2021.7.3,
beryju/authentik:latest,
ghcr.io/goauthentik/server:2021.7.3,
ghcr.io/goauthentik/server:latest
platforms: linux/amd64,linux/arm64
context: .
- name: Building Docker Image (stable)
if: ${{ github.event_name == 'release' && !contains('2021.7.3', 'rc') }}
run: |
docker pull beryju/authentik:latest
docker tag beryju/authentik:latest beryju/authentik:stable
docker push beryju/authentik:stable
docker pull ghcr.io/goauthentik/server:latest
docker tag ghcr.io/goauthentik/server:latest ghcr.io/goauthentik/server:stable
docker push ghcr.io/goauthentik/server:stable
build-proxy:
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-proxy:2021.7.3,
beryju/authentik-proxy:latest,
ghcr.io/goauthentik/proxy:2021.7.3,
ghcr.io/goauthentik/proxy:latest
file: proxy.Dockerfile
platforms: linux/amd64,linux/arm64
- name: Building Docker Image (stable)
if: ${{ github.event_name == 'release' && !contains('2021.7.3', 'rc') }}
run: |
docker pull beryju/authentik-proxy:latest
docker tag beryju/authentik-proxy:latest beryju/authentik-proxy:stable
docker push beryju/authentik-proxy:stable
docker pull ghcr.io/goauthentik/proxy:latest
docker tag ghcr.io/goauthentik/proxy:latest ghcr.io/goauthentik/proxy: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.7.3,
beryju/authentik-ldap:latest,
ghcr.io/goauthentik/ldap:2021.7.3,
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.7.3', '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:
needs:
- build-server
- build-proxy
- build-ldap
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run test suite in final docker images
run: |
sudo apt-get install -y pwgen
echo "PG_PASS=$(pwgen 40 1)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test
sentry-release:
if: ${{ github.event_name == 'release' }}
needs:
- test-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js environment
uses: actions/setup-node@v2.3.0
with:
node-version: 12.x
- name: Build web api client and web ui
run: |
export NODE_ENV=production
make gen-web
cd web
npm i
npm run build
- name: Create a Sentry.io release
uses: getsentry/action-release@v1
if: ${{ github.event_name == 'release' }}
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: beryjuorg
SENTRY_PROJECT: authentik
SENTRY_URL: https://sentry.beryju.org
with:
version: authentik@2021.7.3
environment: beryjuorg-prod
sourcemaps: './web/dist'
url_prefix: '~/static/dist'

View File

@ -10,24 +10,24 @@ jobs:
name: Create Release from Tag name: Create Release from Tag
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - 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@v6 uses: actions/github-script@v4.0.2
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |

View File

@ -1,36 +0,0 @@
name: authentik-backend-translate-compile
on:
push:
branches: [ main ]
paths:
- '/locale/'
pull_request:
paths:
- '/locale/'
workflow_dispatch:
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
jobs:
compile:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run compile
run: poetry run ./manage.py compilemessages
- name: Create Pull Request
uses: peter-evans/create-pull-request@v4
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

View File

@ -1,42 +0,0 @@
name: authentik-web-api-publish
on:
push:
branches: [ main ]
paths:
- 'schema.yml'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v3.3.0
with:
node-version: '16'
registry-url: 'https://registry.npmjs.org'
- name: Generate API Client
run: make gen-client-web
- name: Publish package
working-directory: gen-ts-api/
run: |
npm ci
npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
- name: Upgrade /web
working-directory: web/
run: |
export VERSION=`node -e 'console.log(require("../gen-ts-api/package.json").version)'`
npm i @goauthentik/api@$VERSION
- name: Create Pull Request
uses: peter-evans/create-pull-request@v4
id: cpr
with:
token: ${{ secrets.GITHUB_TOKEN }}
branch: update-web-api-client
commit-message: "web: Update Web API Client version"
title: "web: Update Web API Client version"
body: "web: Update Web API Client version"
delete-branch: true
signoff: true

6
.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:
@ -202,4 +200,4 @@ media/
*mmdb *mmdb
.idea/ .idea/
/gen-*/ /api/

26
.vscode/settings.json vendored
View File

@ -1,26 +0,0 @@
{
"cSpell.words": [
"akadmin",
"asgi",
"authentik",
"authn",
"goauthentik",
"jwks",
"oidc",
"openid",
"plex",
"saml",
"totp",
"webauthn",
"traefik",
"passwordless",
"kubernetes"
],
"python.linting.pylintEnabled": true,
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"python.formatting.provider": "black",
"files.associations": {
"*.akflow": "json"
}
}

View File

@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
hello@goauthentik.io. hello@beryju.org.
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the

View File

@ -11,8 +11,8 @@ The following is a set of guidelines for contributing to authentik and its compo
[I don't want to read this whole thing, I just have a question!!!](#i-dont-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!!!](#i-dont-want-to-read-this-whole-thing-i-just-have-a-question)
[What should I know before I get started?](#what-should-i-know-before-i-get-started) [What should I know before I get started?](#what-should-i-know-before-i-get-started)
* [The components](#the-components) * [Atom and Packages](#atom-and-packages)
* [authentik's structure](#authentiks-structure) * [Atom Design Decisions](#design-decisions)
[How Can I Contribute?](#how-can-i-contribute) [How Can I Contribute?](#how-can-i-contribute)
* [Reporting Bugs](#reporting-bugs) * [Reporting Bugs](#reporting-bugs)
@ -22,16 +22,21 @@ The following is a set of guidelines for contributing to authentik and its compo
[Styleguides](#styleguides) [Styleguides](#styleguides)
* [Git Commit Messages](#git-commit-messages) * [Git Commit Messages](#git-commit-messages)
* [Python Styleguide](#python-styleguide) * [JavaScript Styleguide](#javascript-styleguide)
* [CoffeeScript Styleguide](#coffeescript-styleguide)
* [Specs Styleguide](#specs-styleguide)
* [Documentation Styleguide](#documentation-styleguide) * [Documentation Styleguide](#documentation-styleguide)
[Additional Notes](#additional-notes)
* [Issue and Pull Request Labels](#issue-and-pull-request-labels)
## Code of Conduct ## Code of Conduct
Basically, don't be a dickhead. This is an open-source non-profit project, that is made in the free time of Volunteers. If there's something you dislike or think can be done better, tell us! We'd love to hear any suggestions for improvement. Basically, don't be a dickhead. This is an open-source non-profit project, that is made in the free time of Volunteers. If there's something you dislike or think can be done better, tell us! We'd love to hear any suggestions for improvement.
## 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 +122,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 +136,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,80 +1,103 @@
# Stage 1: Build website # Stage 1: Lock python dependencies
FROM --platform=${BUILDPLATFORM} docker.io/node:18 as website-builder FROM python:3.9-slim-buster as locker
COPY ./website /work/website/ COPY ./Pipfile /app/
COPY ./Pipfile.lock /app/
WORKDIR /app/
RUN pip install pipenv && \
pipenv lock -r > requirements.txt && \
pipenv lock -r --dev-only > requirements-dev.txt
# Stage 2: Build website
FROM node as website-builder
COPY ./website /static/
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /work/website RUN cd /static && npm i && npm run build-docs-only
RUN npm ci && npm run build-docs-only
# Stage 2: Build webui # Stage 3: Build web API
FROM --platform=${BUILDPLATFORM} docker.io/node:18 as web-builder FROM openapitools/openapi-generator-cli as web-api-builder
COPY ./web /work/web/ COPY ./schema.yml /local/schema.yml
COPY ./website /work/website/
RUN docker-entrypoint.sh generate \
-i /local/schema.yml \
-g typescript-fetch \
-o /local/web/api \
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
# Stage 3: Generate API Client
FROM openapitools/openapi-generator-cli as go-api-builder
COPY ./schema.yml /local/schema.yml
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/
COPY --from=web-api-builder /local/web/api /static/api
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /work/web RUN cd /static && npm i && npm run build
RUN npm ci && npm run build
# Stage 3: Poetry to requirements.txt export # Stage 5: Build go proxy
FROM docker.io/python:3.10.4-slim-bullseye AS poetry-locker FROM golang:1.16.6 AS builder
WORKDIR /work
COPY ./pyproject.toml /work
COPY ./poetry.lock /work
RUN pip install --no-cache-dir poetry && \
poetry export -f requirements.txt --output requirements.txt && \
poetry export -f requirements.txt --dev --output requirements-dev.txt
# Stage 4: Build go proxy
FROM docker.io/golang:1.18.3-bullseye 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.4-slim-bullseye FROM python:3.9-slim-buster
LABEL org.opencontainers.image.url https://goauthentik.io
LABEL org.opencontainers.image.description goauthentik.io Main server image, see https://goauthentik.io for more info.
LABEL org.opencontainers.image.source https://github.com/goauthentik/authentik
WORKDIR / WORKDIR /
COPY --from=locker /app/requirements.txt /
COPY --from=locker /app/requirements-dev.txt /
ARG GIT_BUILD_HASH ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
COPY --from=poetry-locker /work/requirements.txt /
COPY --from=poetry-locker /work/requirements-dev.txt /
RUN apt-get update && \ RUN apt-get update && \
# Required for installing pip packages apt-get install -y --no-install-recommends curl ca-certificates gnupg git runit && \
apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev && \ curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
# Required for runtime echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
apt-get install -y --no-install-recommends libxmlsec1-openssl libmaxminddb0 && \ apt-get update && \
# Required for bootstrap & healtcheck apt-get install -y --no-install-recommends libpq-dev postgresql-client build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
apt-get install -y --no-install-recommends curl runit && \ pip install -r /requirements.txt --no-cache-dir && \
pip install --no-cache-dir -r /requirements.txt && \ apt-get remove --purge -y build-essential git && \
apt-get remove --purge -y build-essential pkg-config libxmlsec1-dev && \
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 /certs /media && \ mkdir /backups && \
mkdir -p /authentik/.ssh && \ chown authentik:authentik /backups
chown authentik:authentik /certs /media /authentik/.ssh
COPY ./authentik/ /authentik COPY ./authentik/ /authentik
COPY ./pyproject.toml / COPY ./pyproject.toml /
@ -83,16 +106,8 @@ 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 PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle" ENTRYPOINT [ "/lifecycle/bootstrap.sh" ]
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak", "healthcheck" ]
ENTRYPOINT [ "/lifecycle/ak" ]

151
Makefile
View File

@ -2,172 +2,67 @@
PWD = $(shell pwd) PWD = $(shell pwd)
UID = $(shell id -u) UID = $(shell id -u)
GID = $(shell id -g) GID = $(shell id -g)
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 k3d cluster create || exit 0
k3d kubeconfig write -o ~/.kube/config --overwrite
coverage run manage.py test -v 3 tests/integration
test-e2e-provider: test-e2e:
coverage run manage.py test tests/e2e/test_provider* coverage run manage.py test --failfast -v 3 tests/e2e
test-e2e-rest:
coverage run manage.py test tests/e2e/test_flows* tests/e2e/test_source*
test-go:
go test -timeout 0 -v -race -cover ./...
test-docker:
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker-compose pull -q
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test
rm -f .env
test: test:
coverage run manage.py test authentik coverage run manage.py test -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
golangci-lint run -v
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:
AUTHENTIK_DEBUG=true ./manage.py spectacular --file schema.yml ./manage.py spectacular --file schema.yml
gen-clean: gen-clean:
rm -rf web/api/src/ rm -rf web/api/src/
rm -rf api/ rm -rf api/
gen-client-web: gen-web:
docker run \ docker run \
--rm -v ${PWD}:/local \ --rm -v ${PWD}:/local \
--user ${UID}:${GID} \ --user ${UID}:${GID} \
openapitools/openapi-generator-cli:v6.0.0 generate \ openapitools/openapi-generator-cli generate \
-i /local/schema.yml \ -i /local/schema.yml \
-g typescript-fetch \ -g typescript-fetch \
-o /local/gen-ts-api \ -o /local/web/api \
--additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=@goauthentik/api,npmVersion=${NPM_VERSION} --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
mkdir -p web/node_modules/@goauthentik/api cd web/api && npx tsc
\cp -fv scripts/web_api_readme.md gen-ts-api/README.md
cd gen-ts-api && npm i
\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api
gen-client-go: 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:v6.0.0 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/gen-go-api \ -o /local/api \
-c /local/config.yaml --additional-properties=packageName=api,enumClassPrefix=true,useOneOfDiscriminatorLookup=true
go mod edit -replace goauthentik.io/api/v3=./gen-go-api rm -f api/go.mod api/go.sum
rm -rf config.yaml ./templates/
gen: gen-build gen-clean gen-client-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
#########################
web-build: web-install
cd web && npm run build
web: web-lint-fix web-lint web-extract
web-install:
cd web && npm ci
web-watch:
cd web && npm run watch
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
#########################
## Website
#########################
website: website-lint-fix
website-install:
cd website && npm ci
website-lint-fix:
cd website && npm run prettier
website-watch:
cd website && npm run watch
# These targets are use by GitHub actions to allow usage of matrix
# which makes the YAML File a lot smaller
ci--meta-debug:
python -V
node --version
ci-pylint: ci--meta-debug
pylint authentik tests lifecycle
ci-black: ci--meta-debug
black --check authentik tests lifecycle
ci-isort: ci--meta-debug
isort --check authentik tests lifecycle
ci-bandit: ci--meta-debug
bandit -r authentik tests lifecycle
ci-pyright: ci--meta-debug
pyright e2e lifecycle
ci-pending-migrations: ci--meta-debug
./manage.py makemigrations --check
install: web-install website-install
poetry install
a: install
tmux \
new-session 'make run' \; \
split-window 'make web-watch'

66
Pipfile Normal file
View File

@ -0,0 +1,66 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[packages]
boto3 = "*"
celery = "*"
channels = "*"
channels-redis = "*"
dacite = "*"
defusedxml = "*"
django = "*"
django-dbbackup = { git = 'https://github.com/django-dbbackup/django-dbbackup.git', ref = '9d1909c30a3271c8c9c8450add30d6e0b996e145' }
django-filter = "*"
django-guardian = "*"
django-model-utils = "*"
django-otp = "*"
django-prometheus = "*"
django-redis = "*"
django-storages = "*"
djangorestframework = "*"
djangorestframework-guardian = "*"
docker = "*"
drf-spectacular = "*"
facebook-sdk = "*"
geoip2 = "*"
gunicorn = "*"
kubernetes = "*"
ldap3 = "*"
lxml = ">=4.6.3"
packaging = "*"
psycopg2-binary = "*"
pycryptodome = "*"
pyjwt = "*"
pyyaml = "*"
requests-oauthlib = "*"
sentry-sdk = "*"
service_identity = "*"
structlog = "*"
swagger-spec-validator = "*"
twisted = "==20.3.0"
urllib3 = {extras = ["secure"],version = "*"}
uvicorn = {extras = ["standard"],version = "*"}
webauthn = "*"
xmlsec = "*"
duo-client = "*"
ua-parser = "*"
deepmerge = "*"
colorama = "*"
[requires]
python_version = "3.9"
[dev-packages]
bandit = "*"
black = "==21.5b1"
bump2version = "*"
colorama = "*"
coverage = "*"
pylint = "*"
pylint-django = "*"
pytest = "*"
pytest-django = "*"
selenium = "*"
requests-mock = "*"

1887
Pipfile.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,14 @@
--- ---
[![Join Discord](https://img.shields.io/discord/809154715984199690?label=Discord&style=for-the-badge)](https://goauthentik.io/discord) [![](https://img.shields.io/discord/809154715984199690?label=Discord&style=flat-square)](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) [![CI Build status](https://img.shields.io/azure-devops/build/beryjuorg/authentik/6?style=flat-square)](https://dev.azure.com/beryjuorg/authentik/_build?definitionId=6)
[![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) [![Tests](https://img.shields.io/azure-devops/tests/beryjuorg/authentik/6?compact_message&style=flat-square)](https://dev.azure.com/beryjuorg/authentik/_build?definitionId=6)
[![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) [![Code Coverage](https://img.shields.io/codecov/c/gh/goauthentik/authentik?style=flat-square)](https://codecov.io/gh/goauthentik/authentik)
[![Code Coverage](https://img.shields.io/codecov/c/gh/goauthentik/authentik?style=for-the-badge)](https://codecov.io/gh/goauthentik/authentik) ![Docker pulls](https://img.shields.io/docker/pulls/beryju/authentik.svg?style=flat-square)
[![Testspace tests](https://img.shields.io/testspace/total/goauthentik/goauthentik:authentik/main?style=for-the-badge)](https://goauthentik.testspace.com/) ![Latest version](https://img.shields.io/docker/v/beryju/authentik?sort=semver&style=flat-square)
![Docker pulls](https://img.shields.io/docker/pulls/beryju/authentik.svg?style=for-the-badge) ![LGTM Grade](https://img.shields.io/lgtm/grade/python/github/goauthentik/authentik?style=flat-square)
![Latest version](https://img.shields.io/docker/v/beryju/authentik?sort=semver&style=for-the-badge) [Transifex](https://www.transifex.com/beryjuorg/authentik/)
[![](https://img.shields.io/badge/Help%20translate-transifex-blue?style=for-the-badge)](https://www.transifex.com/beryjuorg/authentik/)
## What is authentik? ## What is authentik?
@ -20,9 +19,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 +32,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](https://goauthentik.io) site.

View File

@ -6,9 +6,10 @@
| Version | Supported | | Version | Supported |
| ---------- | ------------------ | | ---------- | ------------------ |
| 2022.4.x | :white_check_mark: | | 2021.5.x | :white_check_mark: |
| 2022.5.x | :white_check_mark: | | 2021.6.x | :white_check_mark: |
| 2021.7.x | :white_check_mark: |
## Reporting a Vulnerability ## Reporting a Vulnerability
To report a vulnerability, send an email to [security@goauthentik.io](mailto:security@goauthentik.io) To report a vulnerability, send an email to [security@beryju.org](mailto:security@beryju.org)

View File

@ -1,22 +1,3 @@
"""authentik""" """authentik"""
from os import environ __version__ = "2021.7.3"
from typing import Optional
__version__ = "2022.6.2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
def get_build_hash(fallback: Optional[str] = None) -> str:
"""Get build hash"""
build_hash = environ.get(ENV_GIT_HASH_KEY, fallback if fallback else "")
if build_hash == "" and fallback:
return fallback
return build_hash
def get_full_version() -> str:
"""Get full version, with build hash appended"""
version = __version__
if (build_hash := get_build_hash()) != "":
version += "." + build_hash
return version

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,34 @@ 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 +61,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 +78,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

@ -16,8 +16,6 @@ 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.outposts.managed import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
class RuntimeDict(TypedDict): class RuntimeDict(TypedDict):
@ -34,18 +32,12 @@ class RuntimeDict(TypedDict):
class SystemSerializer(PassiveSerializer): class SystemSerializer(PassiveSerializer):
"""Get system information.""" """Get system information."""
env = SerializerMethodField()
http_headers = SerializerMethodField() http_headers = SerializerMethodField()
http_host = SerializerMethodField() http_host = SerializerMethodField()
http_is_secure = SerializerMethodField() http_is_secure = SerializerMethodField()
runtime = SerializerMethodField() runtime = SerializerMethodField()
tenant = SerializerMethodField() tenant = SerializerMethodField()
server_time = SerializerMethodField() server_time = SerializerMethodField()
embedded_outpost_host = SerializerMethodField()
def get_env(self, request: Request) -> dict[str, str]:
"""Get Environment"""
return os.environ.copy()
def get_http_headers(self, request: Request) -> dict[str, str]: def get_http_headers(self, request: Request) -> dict[str, str]:
"""Get HTTP Request headers""" """Get HTTP Request headers"""
@ -69,7 +61,9 @@ class SystemSerializer(PassiveSerializer):
return { return {
"python_version": python_version, "python_version": python_version,
"gunicorn_version": ".".join(str(x) for x in gunicorn_version), "gunicorn_version": ".".join(str(x) for x in gunicorn_version),
"environment": "kubernetes" if SERVICE_HOST_ENV_NAME in os.environ else "compose", "environment": "kubernetes"
if SERVICE_HOST_ENV_NAME in os.environ
else "compose",
"architecture": platform.machine(), "architecture": platform.machine(),
"platform": platform.platform(), "platform": platform.platform(),
"uname": " ".join(platform.uname()), "uname": " ".join(platform.uname()),
@ -83,13 +77,6 @@ class SystemSerializer(PassiveSerializer):
"""Current server time""" """Current server time"""
return now() return now()
def get_embedded_outpost_host(self, request: Request) -> str:
"""Get the FQDN configured on the embedded outpost"""
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts.exists(): # pragma: no cover
return ""
return outposts.first().config.authentik_host
class SystemView(APIView): class SystemView(APIView):
"""Get system information.""" """Get system information."""

View File

@ -12,13 +12,10 @@ from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ViewSet from rest_framework.viewsets import ViewSet
from structlog.stdlib import get_logger
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
LOGGER = get_logger()
class TaskSerializer(PassiveSerializer): class TaskSerializer(PassiveSerializer):
"""Serialize TaskInfo and TaskResult""" """Serialize TaskInfo and TaskResult"""
@ -39,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()
@ -92,15 +89,16 @@ class TaskViewSet(ViewSet):
try: try:
task_module = import_module(task.task_call_module) task_module = import_module(task.task_call_module)
task_func = getattr(task_module, task.task_call_func) task_func = getattr(task_module, task.task_call_func)
LOGGER.debug("Running task", task=task_func)
task_func.delay(*task.task_call_args, **task.task_call_kwargs) task_func.delay(*task.task_call_args, **task.task_call_kwargs)
messages.success( messages.success(
self.request, self.request,
_("Successfully re-scheduled Task %(name)s!" % {"name": task.task_name}), _(
"Successfully re-scheduled Task %(name)s!"
% {"name": task.task_name}
),
) )
return Response(status=204) return Response(status=204)
except (ImportError, AttributeError): # pragma: no cover except ImportError: # pragma: no cover
LOGGER.warning("Failed to run task, remove state", task=task)
# if we get an import error, the module path has probably changed # if we get an import error, the module path has probably changed
task.delete() task.delete()
return Response(status=500) return Response(status=500)

View File

@ -1,4 +1,6 @@
"""authentik administration overview""" """authentik administration overview"""
from os import environ
from django.core.cache import cache from django.core.cache import cache
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from packaging.version import parse from packaging.version import parse
@ -8,7 +10,7 @@ from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from authentik import __version__, get_build_hash from authentik import ENV_GIT_HASH_KEY, __version__
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
@ -23,7 +25,7 @@ class VersionSerializer(PassiveSerializer):
def get_build_hash(self, _) -> str: def get_build_hash(self, _) -> str:
"""Get build hash, if version is not latest or released""" """Get build hash, if version is not latest or released"""
return get_build_hash() return environ.get(ENV_GIT_HASH_KEY, "")
def get_version_current(self, _) -> str: def get_version_current(self, _) -> str:
"""Get current version""" """Get current version"""
@ -39,7 +41,9 @@ class VersionSerializer(PassiveSerializer):
def get_outdated(self, instance) -> bool: def get_outdated(self, instance) -> bool:
"""Check if we're running the latest version""" """Check if we're running the latest version"""
return parse(self.get_version_current(instance)) < parse(self.get_version_latest(instance)) return parse(self.get_version_current(instance)) < parse(
self.get_version_latest(instance)
)
class VersionView(APIView): class VersionView(APIView):

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
@ -18,11 +17,10 @@ class WorkerView(APIView):
permission_classes = [IsAdminUser] permission_classes = [IsAdminUser]
@extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()})) @extend_schema(
responses=inline_serializer("Workers", fields={"count": IntegerField()})
)
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

@ -1,6 +1,4 @@
"""authentik admin app config""" """authentik admin app config"""
from importlib import import_module
from django.apps import AppConfig from django.apps import AppConfig
@ -10,6 +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):
import_module("authentik.admin.signals")

View File

@ -1,12 +1,10 @@
"""authentik admin settings""" """authentik admin settings"""
from celery.schedules import crontab from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = { CELERY_BEAT_SCHEDULE = {
"admin_latest_version": { "admin_latest_version": {
"task": "authentik.admin.tasks.update_latest_version", "task": "authentik.admin.tasks.update_latest_version",
"schedule": crontab(minute=fqdn_rand("admin_latest_version"), hour="*"), "schedule": crontab(minute="*/60"), # Run every hour
"options": {"queue": "authentik_scheduled"}, "options": {"queue": "authentik_scheduled"},
} }
} }

View File

@ -1,23 +0,0 @@
"""admin signals"""
from django.dispatch import receiver
from authentik.admin.api.tasks import TaskInfo
from authentik.admin.api.workers import GAUGE_WORKERS
from authentik.root.celery import CELERY_APP
from authentik.root.monitoring import monitoring_set
@receiver(monitoring_set)
# pylint: disable=unused-argument
def monitoring_set_workers(sender, **kwargs):
"""Set worker gauge"""
count = len(CELERY_APP.control.ping(timeout=0.5))
GAUGE_WORKERS.set(count)
@receiver(monitoring_set)
# pylint: disable=unused-argument
def monitoring_set_tasks(sender, **kwargs):
"""Set task gauges"""
for task in TaskInfo.all().values():
task.set_prom_metrics()

View File

@ -1,23 +1,17 @@
"""authentik admin tasks""" """authentik admin tasks"""
import re import re
from os import environ
from django.core.cache import cache 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 __version__, get_build_hash 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()
@ -26,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():
@ -35,46 +28,33 @@ def _set_prom_info():
{ {
"version": __version__, "version": __version__,
"latest": cache.get(VERSION_CACHE_KEY, ""), "latest": cache.get(VERSION_CACHE_KEY, ""),
"build_hash": get_build_hash(), "build_hash": environ.get(ENV_GIT_HASH_KEY, ""),
} }
) )
@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://version.goauthentik.io/version.json", "https://api.github.com/repos/goauthentik/authentik/releases/latest"
) )
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"]
)
) )
_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,
@ -82,7 +62,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):
@ -28,7 +27,9 @@ class TestAdminAPI(TestCase):
response = self.client.get(reverse("authentik_api:admin_system_tasks-list")) response = self.client.get(reverse("authentik_api:admin_system_tasks-list"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
body = loads(response.content) body = loads(response.content)
self.assertTrue(any(task["task_name"] == "clean_expired_models" for task in body)) self.assertTrue(
any(task["task_name"] == "clean_expired_models" for task in body)
)
def test_tasks_single(self): def test_tasks_single(self):
"""Test Task API (read single)""" """Test Task API (read single)"""
@ -44,7 +45,9 @@ class TestAdminAPI(TestCase):
self.assertEqual(body["status"], TaskResultStatus.SUCCESSFUL.name) self.assertEqual(body["status"], TaskResultStatus.SUCCESSFUL.name)
self.assertEqual(body["task_name"], "clean_expired_models") self.assertEqual(body["task_name"], "clean_expired_models")
response = self.client.get( response = self.client.get(
reverse("authentik_api:admin_system_tasks-detail", kwargs={"pk": "qwerqwer"}) reverse(
"authentik_api:admin_system_tasks-detail", kwargs={"pk": "qwerqwer"}
)
) )
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
@ -95,7 +98,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,83 +1,81 @@
"""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, CONFIG.patch("disable_update_check", False): update_latest_version.delay().get()
mocker.get("https://version.goauthentik.io/version.json", json=RESPONSE_VALID) self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
update_latest_version.delay().get() self.assertTrue(
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999") Event.objects.filter(
self.assertTrue( action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999",
context__message="Changelog: https://goauthentik.io/test",
).exists()
)
# test that a consecutive check doesn't create a duplicate event
update_latest_version.delay().get()
self.assertEqual(
len(
Event.objects.filter( Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE, action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999", context__new_version="99999999.9999999",
context__message="Changelog: https://goauthentik.io/test", context__message="Changelog: https://goauthentik.io/test",
).exists() )
) ),
# test that a consecutive check doesn't create a duplicate event 1,
update_latest_version.delay().get() )
self.assertEqual(
len(
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999",
context__message="Changelog: https://goauthentik.io/test",
)
),
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: update_latest_version.delay().get()
mocker.get("https://version.goauthentik.io/version.json", status_code=400) self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
update_latest_version.delay().get()
self.assertEqual(cache.get(VERSION_CACHE_KEY), "0.0.0")
self.assertFalse(
Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
).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( self.assertFalse(
Event.objects.filter( Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1" action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
).exists() ).exists()
) )

View File

@ -1,77 +1,57 @@
"""API Authentication""" """API Authentication"""
from typing import Any, Optional from base64 import b64decode
from binascii import Error
from typing import Any, Optional, Union
from django.conf import settings
from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed 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
LOGGER = get_logger() LOGGER = get_logger()
def validate_auth(header: bytes) -> str: # pylint: disable=too-many-return-statements
"""Validate that the header is in a correct format, def token_from_header(raw_header: bytes) -> Optional[Token]:
returns type and credentials""" """raw_header in the Format of `Bearer dGVzdDp0ZXN0`"""
auth_credentials = header.decode().strip() auth_credentials = raw_header.decode()
if auth_credentials == "" or " " not in auth_credentials: if auth_credentials == "" or " " not in auth_credentials:
return None return None
auth_type, _, auth_credentials = auth_credentials.partition(" ") auth_type, _, auth_credentials = auth_credentials.partition(" ")
if auth_type.lower() != "bearer": if auth_type.lower() not in ["basic", "bearer"]:
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower()) LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
raise AuthenticationFailed("Unsupported authentication type") raise AuthenticationFailed("Unsupported authentication type")
if auth_credentials == "": # nosec password = auth_credentials
if auth_type.lower() == "basic":
try:
auth_credentials = b64decode(auth_credentials.encode()).decode()
except (UnicodeDecodeError, Error):
raise AuthenticationFailed("Malformed header")
# Accept credentials with username and without
if ":" in auth_credentials:
_, password = auth_credentials.split(":")
else:
password = auth_credentials
if password == "": # nosec
raise AuthenticationFailed("Malformed header") raise AuthenticationFailed("Malformed header")
return auth_credentials tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
if not tokens.exists():
raise AuthenticationFailed("Token invalid/expired")
def bearer_auth(raw_header: bytes) -> Optional[User]: return tokens.first()
"""raw_header in the Format of `Bearer ....`"""
auth_credentials = validate_auth(raw_header)
if not auth_credentials:
return None
# first, check traditional tokens
token = Token.filter_not_expired(key=auth_credentials, intent=TokenIntents.INTENT_API).first()
if hasattr(LOCAL, "authentik"):
LOCAL.authentik[KEY_AUTH_VIA] = "api_token"
if token:
return token.user
user = token_secret_key(auth_credentials)
if user:
return user
raise AuthenticationFailed("Token invalid/expired")
def token_secret_key(value: str) -> Optional[User]:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
from authentik.outposts.managed import MANAGED_OUTPOST
if value != settings.SECRET_KEY:
return None
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts:
return None
if hasattr(LOCAL, "authentik"):
LOCAL.authentik[KEY_AUTH_VIA] = "secret_key"
outpost = outposts.first()
return outpost.user
class TokenAuthentication(BaseAuthentication): class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication""" """Token-based authentication using HTTP Bearer authentication"""
def authenticate(self, request: Request) -> tuple[User, Any] | None: def authenticate(self, request: Request) -> Union[tuple[User, Any], None]:
"""Token-based authentication using HTTP Bearer authentication""" """Token-based authentication using HTTP Bearer authentication"""
auth = get_authorization_header(request) auth = get_authorization_header(request)
user = bearer_auth(auth) token = token_from_header(auth)
# None is only returned when the header isn't set. # None is only returned when the header isn't set.
if not user: if not token:
return None return None
return (user, None) # pragma: no cover return (token.user, None) # pragma: no cover

View File

@ -12,8 +12,6 @@ class OwnerFilter(BaseFilterBackend):
owner_key = "user" owner_key = "user"
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet: def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
if request.user.is_superuser:
return queryset
return queryset.filter(**{self.owner_key: request.user}) return queryset.filter(**{self.owner_key: request.user})
@ -35,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,12 +5,11 @@ 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
):
"""Check permissions for a single custom action""" """Check permissions for a single custom action"""
def wrapper_outter(func: Callable): def wrapper_outter(func: Callable):
@ -21,12 +20,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={},
) )
@ -63,7 +63,9 @@ def postprocess_schema_responses(result, generator, **kwargs): # noqa: W0613
method["responses"].setdefault("400", validation_error.ref) method["responses"].setdefault("400", validation_error.ref)
method["responses"].setdefault("403", generic_error.ref) method["responses"].setdefault("403", generic_error.ref)
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS) result["components"] = generator.registry.build(
spectacular_settings.APPEND_COMPONENTS
)
# This is a workaround for authentik/stages/prompt/stage.py # This is a workaround for authentik/stages/prompt/stage.py
# since the serializer PromptChallengeResponse # since the serializer PromptChallengeResponse

View File

@ -8,6 +8,9 @@ API Browser - {{ tenant.branding_title }}
{% block head %} {% block head %}
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script> <script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
{% endblock %}
{% block body %}
<script> <script>
function getCookie(name) { function getCookie(name) {
let cookieValue = ""; let cookieValue = "";
@ -27,62 +30,20 @@ function getCookie(name) {
window.addEventListener('DOMContentLoaded', (event) => { window.addEventListener('DOMContentLoaded', (event) => {
const rapidocEl = document.querySelector('rapi-doc'); const rapidocEl = document.querySelector('rapi-doc');
rapidocEl.addEventListener('before-try', (e) => { rapidocEl.addEventListener('before-try', (e) => {
e.detail.request.headers.append('X-authentik-CSRF', getCookie("authentik_csrf")); e.detail.request.headers.append('X-CSRFToken', getCookie("authentik_csrf"));
}); });
}); });
</script> </script>
<style>
img.logo {
width: 100%;
padding: 1rem 0.5rem 1.5rem 0.5rem;
min-height: 48px;
}
</style>
{% endblock %}
{% block body %}
<rapi-doc <rapi-doc
spec-url="{{ path }}" spec-url="{{ path }}"
heading-text="" heading-text="authentik"
theme="light" theme="dark"
render-style="read" render-style="view"
default-schema-tab="schema"
primary-color="#fd4b2d" primary-color="#fd4b2d"
nav-bg-color="#212427"
bg-color="#000000"
text-color="#000000"
nav-text-color="#ffffff"
nav-hover-bg-color="#3c3f42"
nav-accent-color="#4f5255"
nav-hover-text-color="#ffffff"
use-path-in-nav-bar="true"
nav-item-spacing="relaxed"
allow-server-selection="false"
show-header="false"
allow-spec-url-load="false" allow-spec-url-load="false"
allow-spec-file-load="false"> allow-spec-file-load="false">
<div slot="nav-logo"> <div slot="logo">
<img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" /> <img src="{% static 'dist/assets/icons/icon.png' %}" style="width:50px; height:50px" />
</div> </div>
</rapi-doc> </rapi-doc>
<script>
const rapidoc = document.querySelector("rapi-doc");
const matcher = window.matchMedia("(prefers-color-scheme: light)");
const changer = (ev) => {
const style = getComputedStyle(document.documentElement);
let bg, text = "";
if (matcher.matches) {
bg = style.getPropertyValue('--pf-global--BackgroundColor--light-300');
text = style.getPropertyValue('--pf-global--Color--300');
} else {
bg = style.getPropertyValue('--ak-dark-background');
text = style.getPropertyValue('--ak-dark-foreground');
}
rapidoc.attributes.getNamedItem("bg-color").value = bg.trim();
rapidoc.attributes.getNamedItem("text-color").value = text.trim();
rapidoc.requestUpdate();
};
matcher.addEventListener("change", changer);
window.addEventListener("load", changer);
</script>
{% endblock %} {% endblock %}

View File

@ -1,40 +1,49 @@
"""Test API Authentication""" """Test API Authentication"""
from base64 import b64encode from base64 import b64encode
from django.conf import settings
from django.test import TestCase from django.test import TestCase
from guardian.shortcuts import get_anonymous_user from guardian.shortcuts import get_anonymous_user
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import bearer_auth from authentik.api.authentication import token_from_header
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents from authentik.core.models import Token, TokenIntents
from authentik.outposts.managed import OutpostManager
class TestAPIAuth(TestCase): class TestAPIAuth(TestCase):
"""Test API Authentication""" """Test API Authentication"""
def test_valid_basic(self):
"""Test valid token"""
token = Token.objects.create(
intent=TokenIntents.INTENT_API, user=get_anonymous_user()
)
auth = b64encode(f":{token.key}".encode()).decode()
self.assertEqual(token_from_header(f"Basic {auth}".encode()), token)
def test_valid_bearer(self): def test_valid_bearer(self):
"""Test valid token""" """Test valid token"""
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user()) token = Token.objects.create(
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user) intent=TokenIntents.INTENT_API, user=get_anonymous_user()
)
self.assertEqual(token_from_header(f"Bearer {token.key}".encode()), token)
def test_invalid_type(self): def test_invalid_type(self):
"""Test invalid type""" """Test invalid type"""
with self.assertRaises(AuthenticationFailed): with self.assertRaises(AuthenticationFailed):
bearer_auth("foo bar".encode()) token_from_header("foo bar".encode())
def test_invalid_decode(self):
"""Test invalid bas64"""
with self.assertRaises(AuthenticationFailed):
token_from_header("Basic bar".encode())
def test_invalid_empty_password(self):
"""Test invalid with empty password"""
with self.assertRaises(AuthenticationFailed):
token_from_header("Basic :".encode())
def test_invalid_no_token(self): def test_invalid_no_token(self):
"""Test invalid with no token""" """Test invalid with no token"""
with self.assertRaises(AuthenticationFailed): with self.assertRaises(AuthenticationFailed):
auth = b64encode(":abc".encode()).decode() auth = b64encode(":abc".encode()).decode()
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode())) self.assertIsNone(token_from_header(f"Basic :{auth}".encode()))
def test_managed_outpost(self):
"""Test managed outpost"""
with self.assertRaises(AuthenticationFailed):
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
OutpostManager().run()
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True)

View File

@ -1,29 +0,0 @@
"""authentik API Modelviewset tests"""
from typing import Callable
from django.test import TestCase
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from authentik.api.v3.urls import router
class TestModelViewSets(TestCase):
"""Test Viewset"""
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
"""Test Viewset"""
def tester(self: TestModelViewSets):
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
filterset_class = getattr(test_viewset, "filterset_class", None)
if not filterset_class:
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
return tester
for _, viewset, _ in router.registry:
if not issubclass(viewset, (ModelViewSet, ReadOnlyModelViewSet)):
continue
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))

View File

@ -1,8 +1,8 @@
"""authentik api urls""" """authentik api urls"""
from django.urls import include, path from django.urls import include, path
from authentik.api.v3.urls import urlpatterns as v3_urls from authentik.api.v2.urls import urlpatterns as v2_urls
urlpatterns = [ urlpatterns = [
path("v3/", include(v3_urls)), path("v2beta/", include(v2_urls)),
] ]

View File

@ -1,17 +1,11 @@
"""core Configs API""" """core Configs API"""
from os import path from os import environ, path
from django.conf import settings 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 rest_framework.fields import ( from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
BooleanField, from rest_framework.fields import BooleanField, CharField, ChoiceField, ListField
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
@ -27,28 +21,17 @@ class Capabilities(models.TextChoices):
CAN_SAVE_MEDIA = "can_save_media" CAN_SAVE_MEDIA = "can_save_media"
CAN_GEO_IP = "can_geo_ip" CAN_GEO_IP = "can_geo_ip"
CAN_IMPERSONATE = "can_impersonate" 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)
capabilities = ListField(child=ChoiceField(choices=Capabilities.choices)) error_reporting_environment = CharField(read_only=True)
error_reporting_send_pii = BooleanField(read_only=True)
cache_timeout = IntegerField(required=True) capabilities = ListField(child=ChoiceField(choices=Capabilities.choices))
cache_timeout_flows = IntegerField(required=True)
cache_timeout_policies = IntegerField(required=True)
cache_timeout_reputation = IntegerField(required=True)
class ConfigView(APIView): class ConfigView(APIView):
@ -64,26 +47,24 @@ class ConfigView(APIView):
caps.append(Capabilities.CAN_SAVE_MEDIA) caps.append(Capabilities.CAN_SAVE_MEDIA)
if GEOIP_READER.enabled: if GEOIP_READER.enabled:
caps.append(Capabilities.CAN_GEO_IP) caps.append(Capabilities.CAN_GEO_IP)
if CONFIG.y_bool("impersonation"): if SERVICE_HOST_ENV_NAME in environ:
caps.append(Capabilities.CAN_IMPERSONATE) # Running in k8s, only s3 backup is supported
if CONFIG.y_bool("postgresql.s3_backup"):
caps.append(Capabilities.CAN_BACKUP)
else:
# Running in compose, backup is always supported
caps.append(Capabilities.CAN_BACKUP)
return caps return caps
@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") and not settings.DEBUG, "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_flows": int(CONFIG.y("redis.cache_timeout_flows")),
"cache_timeout_policies": int(CONFIG.y("redis.cache_timeout_policies")),
"cache_timeout_reputation": int(CONFIG.y("redis.cache_timeout_reputation")),
} }
) )
return Response(config.data) return Response(config.data)

View File

@ -0,0 +1,38 @@
"""Sentry tunnel"""
from json import loads
from django.conf import settings
from django.http.request import HttpRequest
from django.http.response import HttpResponse
from django.views.generic.base import View
from requests import post
from requests.exceptions import RequestException
from authentik.lib.config import CONFIG
class SentryTunnelView(View):
"""Sentry tunnel, to prevent ad blockers from blocking sentry"""
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

@ -1,6 +1,6 @@
"""api v3 urls""" """api v2 urls"""
from django.urls import path from django.urls import path
from django.views.decorators.cache import cache_page from django.views.decorators.csrf import csrf_exempt
from drf_spectacular.views import SpectacularAPIView from drf_spectacular.views import SpectacularAPIView
from rest_framework import routers from rest_framework import routers
@ -10,28 +10,26 @@ from authentik.admin.api.system import SystemView
from authentik.admin.api.tasks import TaskViewSet 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.v2.config import ConfigView
from authentik.api.v2.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.events import EventViewSet from authentik.events.api.event import EventViewSet
from authentik.events.api.notification_mappings import NotificationWebhookMappingViewSet from authentik.events.api.notification import NotificationViewSet
from authentik.events.api.notification_rules import NotificationRuleViewSet from authentik.events.api.notification_rule import NotificationRuleViewSet
from authentik.events.api.notification_transports import NotificationTransportViewSet from authentik.events.api.notification_transport import NotificationTransportViewSet
from authentik.events.api.notifications import NotificationViewSet
from authentik.flows.api.bindings import FlowStageBindingViewSet from authentik.flows.api.bindings import FlowStageBindingViewSet
from authentik.flows.api.flows import FlowViewSet from authentik.flows.api.flows import FlowViewSet
from authentik.flows.api.stages import StageViewSet from authentik.flows.api.stages import StageViewSet
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,
@ -46,29 +44,35 @@ from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
from authentik.policies.expression.api import ExpressionPolicyViewSet from authentik.policies.expression.api import ExpressionPolicyViewSet
from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
from authentik.policies.password.api import PasswordPolicyViewSet from authentik.policies.password.api import PasswordPolicyViewSet
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet from authentik.policies.reputation.api import (
IPReputationViewSet,
ReputationPolicyViewSet,
UserReputationViewSet,
)
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
from authentik.providers.oauth2.api.scope import ScopeMappingViewSet from authentik.providers.oauth2.api.scope import ScopeMappingViewSet
from authentik.providers.oauth2.api.tokens import AuthorizationCodeViewSet, RefreshTokenViewSet from authentik.providers.oauth2.api.tokens import (
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet AuthorizationCodeViewSet,
RefreshTokenViewSet,
)
from authentik.providers.proxy.api import (
ProxyOutpostConfigViewSet,
ProxyProviderViewSet,
)
from authentik.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet from authentik.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet
from authentik.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet from authentik.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
from authentik.sources.oauth.api.source import OAuthSourceViewSet from authentik.sources.oauth.api.source import OAuthSourceViewSet
from authentik.sources.oauth.api.source_connection import UserOAuthSourceConnectionViewSet from authentik.sources.oauth.api.source_connection import (
from authentik.sources.plex.api.source import PlexSourceViewSet UserOAuthSourceConnectionViewSet,
from authentik.sources.plex.api.source_connection import PlexSourceConnectionViewSet )
from authentik.sources.plex.api import PlexSourceViewSet
from authentik.sources.saml.api import SAMLSourceViewSet from authentik.sources.saml.api import SAMLSourceViewSet
from authentik.stages.authenticator_duo.api import ( from authentik.stages.authenticator_duo.api import (
AuthenticatorDuoStageViewSet, AuthenticatorDuoStageViewSet,
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,
@ -79,7 +83,9 @@ from authentik.stages.authenticator_totp.api import (
TOTPAdminDeviceViewSet, TOTPAdminDeviceViewSet,
TOTPDeviceViewSet, TOTPDeviceViewSet,
) )
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageViewSet from authentik.stages.authenticator_validate.api import (
AuthenticatorValidateStageViewSet,
)
from authentik.stages.authenticator_webauthn.api import ( from authentik.stages.authenticator_webauthn.api import (
AuthenticateWebAuthnStageViewSet, AuthenticateWebAuthnStageViewSet,
WebAuthnAdminDeviceViewSet, WebAuthnAdminDeviceViewSet,
@ -101,7 +107,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")
@ -117,7 +122,9 @@ router.register("core/tenants", TenantViewSet)
router.register("outposts/instances", OutpostViewSet) router.register("outposts/instances", OutpostViewSet)
router.register("outposts/service_connections/all", ServiceConnectionViewSet) router.register("outposts/service_connections/all", ServiceConnectionViewSet)
router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet) router.register("outposts/service_connections/docker", DockerServiceConnectionViewSet)
router.register("outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet) router.register(
"outposts/service_connections/kubernetes", KubernetesServiceConnectionViewSet
)
router.register("outposts/proxy", ProxyOutpostConfigViewSet) router.register("outposts/proxy", ProxyOutpostConfigViewSet)
router.register("outposts/ldap", LDAPOutpostConfigViewSet) router.register("outposts/ldap", LDAPOutpostConfigViewSet)
@ -132,9 +139,7 @@ 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/oauth_user_connections", UserOAuthSourceConnectionViewSet)
router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet)
router.register("sources/user_connections/plex", PlexSourceConnectionViewSet)
router.register("sources/ldap", LDAPSourceViewSet) router.register("sources/ldap", LDAPSourceViewSet)
router.register("sources/saml", SAMLSourceViewSet) router.register("sources/saml", SAMLSourceViewSet)
router.register("sources/oauth", OAuthSourceViewSet) router.register("sources/oauth", OAuthSourceViewSet)
@ -147,7 +152,8 @@ router.register("policies/event_matcher", EventMatcherPolicyViewSet)
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet) router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet) router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
router.register("policies/password", PasswordPolicyViewSet) router.register("policies/password", PasswordPolicyViewSet)
router.register("policies/reputation/scores", ReputationViewSet) router.register("policies/reputation/users", UserReputationViewSet)
router.register("policies/reputation/ips", IPReputationViewSet)
router.register("policies/reputation", ReputationPolicyViewSet) router.register("policies/reputation", ReputationPolicyViewSet)
router.register("providers/all", ProviderViewSet) router.register("providers/all", ProviderViewSet)
@ -163,11 +169,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)
@ -176,17 +179,14 @@ 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,
basename="admin-staticdevice", basename="admin-staticdevice",
) )
router.register("authenticators/admin/totp", TOTPAdminDeviceViewSet, basename="admin-totpdevice") router.register(
"authenticators/admin/totp", TOTPAdminDeviceViewSet, basename="admin-totpdevice"
)
router.register( router.register(
"authenticators/admin/webauthn", "authenticators/admin/webauthn",
WebAuthnAdminDeviceViewSet, WebAuthnAdminDeviceViewSet,
@ -195,7 +195,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)
@ -238,11 +237,7 @@ urlpatterns = (
FlowExecutorView.as_view(), FlowExecutorView.as_view(),
name="flow-executor", name="flow-executor",
), ),
path( path("sentry/", csrf_exempt(SentryTunnelView.as_view()), name="sentry"),
"flows/inspector/<slug:flow_slug>/", path("schema/", SpectacularAPIView.as_view(), name="schema"),
FlowInspectorView.as_view(),
name="flow-inspector",
),
path("schema/", cache_page(86400)(SpectacularAPIView.as_view()), name="schema"),
] ]
) )

View File

@ -1,15 +1,17 @@
"""Application API Views""" """Application API Views"""
from typing import Optional
from django.core.cache import cache from django.core.cache import cache
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http.response import HttpResponseBadRequest from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from drf_spectacular.utils import (
from guardian.shortcuts import get_objects_for_user OpenApiParameter,
OpenApiResponse,
extend_schema,
inline_serializer,
)
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField, SerializerMethodField from rest_framework.fields import BooleanField, CharField, FileField, ReadOnlyField
from rest_framework.parsers import MultiPartParser from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
@ -17,16 +19,13 @@ from rest_framework.serializers import 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
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from structlog.testing import capture_logs
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
from authentik.core.api.utils import FilePathSerializer, FileUploadSerializer
from authentik.core.models import Application, User from authentik.core.models import Application, User
from authentik.events.models import EventAction from authentik.events.models import EventAction
from authentik.events.utils import sanitize_dict
from authentik.policies.api.exec import PolicyTestResultSerializer from authentik.policies.api.exec import PolicyTestResultSerializer
from authentik.policies.engine import PolicyEngine from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyResult from authentik.policies.types import PolicyResult
@ -43,16 +42,11 @@ def user_app_cache_key(user_pk: str) -> str:
class ApplicationSerializer(ModelSerializer): class ApplicationSerializer(ModelSerializer):
"""Application Serializer""" """Application Serializer"""
launch_url = SerializerMethodField() launch_url = ReadOnlyField(source="get_launch_url")
provider_obj = ProviderSerializer(source="get_provider", required=False, read_only=True) provider_obj = ProviderSerializer(source="get_provider", required=False)
meta_icon = ReadOnlyField(source="get_meta_icon") meta_icon = ReadOnlyField(source="get_meta_icon")
def get_launch_url(self, app: Application) -> Optional[str]:
"""Allow formatting of launch URL"""
user = self.context["request"].user
return app.get_launch_url(user)
class Meta: class Meta:
model = Application model = Application
@ -63,13 +57,11 @@ class ApplicationSerializer(ModelSerializer):
"provider", "provider",
"provider_obj", "provider_obj",
"launch_url", "launch_url",
"open_in_new_tab",
"meta_launch_url", "meta_launch_url",
"meta_icon", "meta_icon",
"meta_description", "meta_description",
"meta_publisher", "meta_publisher",
"policy_engine_mode", "policy_engine_mode",
"group",
] ]
extra_kwargs = { extra_kwargs = {
"meta_icon": {"read_only": True}, "meta_icon": {"read_only": True},
@ -79,7 +71,7 @@ class ApplicationSerializer(ModelSerializer):
class ApplicationViewSet(UsedByMixin, ModelViewSet): class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Application Viewset""" """Application Viewset"""
queryset = Application.objects.all().prefetch_related("provider") queryset = Application.objects.all()
serializer_class = ApplicationSerializer serializer_class = ApplicationSerializer
search_fields = [ search_fields = [
"name", "name",
@ -87,10 +79,8 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"meta_launch_url", "meta_launch_url",
"meta_description", "meta_description",
"meta_publisher", "meta_publisher",
"group",
] ]
lookup_field = "slug" lookup_field = "slug"
filterset_fields = ["name", "slug"]
ordering = ["name"] ordering = ["name"]
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
@ -132,25 +122,15 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
# If the current user is superuser, they can set `for_user` # If the current user is superuser, they can set `for_user`
for_user = request.user for_user = request.user
if request.user.is_superuser and "for_user" in request.query_params: if request.user.is_superuser and "for_user" in request.query_params:
try: for_user = get_object_or_404(User, pk=request.query_params.get("for_user"))
for_user = get_object_or_404(User, pk=request.query_params.get("for_user"))
except ValueError:
return HttpResponseBadRequest("for_user must be numerical")
engine = PolicyEngine(application, for_user, request) engine = PolicyEngine(application, for_user, request)
engine.use_cache = False engine.use_cache = False
with capture_logs() as logs: engine.build()
engine.build() result = engine.result
result = engine.result
response = PolicyTestResultSerializer(PolicyResult(False)) response = PolicyTestResultSerializer(PolicyResult(False))
if result.passing: if result.passing:
response = PolicyTestResultSerializer(PolicyResult(True)) response = PolicyTestResultSerializer(PolicyResult(True))
if request.user.is_superuser: if request.user.is_superuser:
log_messages = []
for log in logs:
if log.get("process", "") == "PolicyProcess":
continue
log_messages.append(sanitize_dict(log))
result.log_messages = log_messages
response = PolicyTestResultSerializer(result) response = PolicyTestResultSerializer(result)
return Response(response.data) return Response(response.data)
@ -167,7 +147,9 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Custom list method that checks Policy based access instead of guardian""" """Custom list method that checks Policy based access instead of guardian"""
should_cache = request.GET.get("search", "") == "" should_cache = request.GET.get("search", "") == ""
superuser_full_list = str(request.GET.get("superuser_full_list", "false")).lower() == "true" superuser_full_list = (
str(request.GET.get("superuser_full_list", "false")).lower() == "true"
)
if superuser_full_list and request.user.is_superuser: if superuser_full_list and request.user.is_superuser:
return super().list(request) return super().list(request)
@ -197,7 +179,13 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
@permission_required("authentik_core.change_application") @permission_required("authentik_core.change_application")
@extend_schema( @extend_schema(
request={ request={
"multipart/form-data": FileUploadSerializer, "multipart/form-data": inline_serializer(
"SetIcon",
fields={
"file": FileField(required=False),
"clear": BooleanField(default=False),
},
)
}, },
responses={ responses={
200: OpenApiResponse(description="Success"), 200: OpenApiResponse(description="Success"),
@ -229,7 +217,7 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
@permission_required("authentik_core.change_application") @permission_required("authentik_core.change_application")
@extend_schema( @extend_schema(
request=FilePathSerializer, request=inline_serializer("SetIconURL", fields={"url": CharField()}),
responses={ responses={
200: OpenApiResponse(description="Success"), 200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"), 400: OpenApiResponse(description="Bad request"),
@ -252,7 +240,9 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
app.save() app.save()
return Response({}) return Response({})
@permission_required("authentik_core.view_application", ["authentik_events.view_event"]) @permission_required(
"authentik_core.view_application", ["authentik_events.view_event"]
)
@extend_schema(responses={200: CoordinateSerializer(many=True)}) @extend_schema(responses={200: CoordinateSerializer(many=True)})
@action(detail=True, pagination_class=None, filter_backends=[]) @action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=unused-argument # pylint: disable=unused-argument
@ -260,10 +250,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
@ -69,7 +68,9 @@ class AuthenticatedSessionSerializer(ModelSerializer):
"""Get parsed user agent""" """Get parsed user agent"""
return user_agent_parser.Parse(instance.last_user_agent) return user_agent_parser.Parse(instance.last_user_agent)
def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover def get_geo_ip(
self, instance: AuthenticatedSession
) -> Optional[GeoIPDict]: # pragma: no cover
"""Get parsed user agent""" """Get parsed user agent"""
return GEOIP_READER.city_dict(instance.last_ip) return GEOIP_READER.city_dict(instance.last_ip)
@ -103,8 +104,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, IntegerField, 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,20 +44,15 @@ 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)
num_pk = IntegerField(read_only=True)
class Meta: class Meta:
model = Group model = Group
fields = [ fields = [
"pk", "pk",
"num_pk",
"name", "name",
"is_superuser", "is_superuser",
"parent", "parent",
"parent_name",
"users", "users",
"attributes", "attributes",
"users_obj", "users_obj",
@ -67,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",
@ -84,34 +72,16 @@ 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):
"""Group Viewset""" """Group Viewset"""
queryset = Group.objects.all().select_related("parent").prefetch_related("users") queryset = Group.objects.all()
serializer_class = GroupSerializer serializer_class = GroupSerializer
search_fields = ["name", "is_superuser"] search_fields = ["name", "is_superuser"]
filterset_class = GroupFilter filterset_class = GroupFilter

View File

@ -15,7 +15,11 @@ from rest_framework.viewsets import GenericViewSet
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.utils import MetaNameSerializer, PassiveSerializer, TypeCreateSerializer from authentik.core.api.utils import (
MetaNameSerializer,
PassiveSerializer,
TypeCreateSerializer,
)
from authentik.core.expression import PropertyMappingEvaluator from authentik.core.expression import PropertyMappingEvaluator
from authentik.core.models import PropertyMapping from authentik.core.models import PropertyMapping
from authentik.lib.utils.reflection import all_subclasses from authentik.lib.utils.reflection import all_subclasses
@ -56,7 +60,6 @@ class PropertyMappingSerializer(ManagedSerializer, ModelSerializer, MetaNameSeri
"component", "component",
"verbose_name", "verbose_name",
"verbose_name_plural", "verbose_name_plural",
"meta_model_name",
] ]
@ -138,7 +141,9 @@ class PropertyMappingViewSet(
self.request, self.request,
**test_params.validated_data.get("context", {}), **test_params.validated_data.get("context", {}),
) )
response_data["result"] = dumps(result, indent=(4 if format_result else None)) response_data["result"] = dumps(
result, indent=(4 if format_result else None)
)
except Exception as exc: # pylint: disable=broad-except except Exception as exc: # pylint: disable=broad-except
response_data["result"] = str(exc) response_data["result"] = str(exc)
response_data["successful"] = False response_data["successful"] = False

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, ReadOnlyField, 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, OwnerSuperuserPermissions
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer from authentik.core.api.utils import MetaNameSerializer, TypeCreateSerializer
from authentik.core.models import Source, UserSourceConnection from authentik.core.models import Source
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
@ -26,7 +23,6 @@ LOGGER = get_logger()
class SourceSerializer(ModelSerializer, MetaNameSerializer): class SourceSerializer(ModelSerializer, MetaNameSerializer):
"""Source Serializer""" """Source Serializer"""
managed = ReadOnlyField()
component = SerializerMethodField() component = SerializerMethodField()
def get_component(self, obj: Source) -> str: def get_component(self, obj: Source) -> str:
@ -49,10 +45,8 @@ 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",
"managed",
] ]
@ -68,8 +62,6 @@ class SourceViewSet(
queryset = Source.objects.none() queryset = Source.objects.none()
serializer_class = SourceSerializer serializer_class = SourceSerializer
lookup_field = "slug" lookup_field = "slug"
search_fields = ["slug", "name"]
filterset_fields = ["slug", "name", "managed"]
def get_queryset(self): # pragma: no cover def get_queryset(self): # pragma: no cover
return Source.objects.select_subclasses() return Source.objects.select_subclasses()
@ -82,8 +74,6 @@ class SourceViewSet(
for subclass in all_subclasses(self.queryset.model): for subclass in all_subclasses(self.queryset.model):
subclass: Source subclass: Source
component = "" component = ""
if len(subclass.__subclasses__()) > 0:
continue
if subclass._meta.abstract: if subclass._meta.abstract:
component = subclass.__bases__[0]().component component = subclass.__bases__[0]().component
else: else:
@ -103,57 +93,21 @@ 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(
Source.objects.filter(enabled=True).select_subclasses().order_by("name") enabled=True
) ).select_subclasses()
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 = [OwnerSuperuserPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["pk"]

View File

@ -1,42 +1,26 @@
"""Tokens API Viewset""" """Tokens API Viewset"""
from typing import Any from django.http.response import Http404
from drf_spectacular.utils import OpenApiResponse, extend_schema
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from guardian.shortcuts import assign_perm, get_anonymous_user
from rest_framework.decorators import action from rest_framework.decorators import action
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
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents from authentik.core.models import USER_ATTRIBUTE_TOKEN_EXPIRING, Token, TokenIntents
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.events.utils import model_to_dict
from authentik.managed.api import ManagedSerializer from authentik.managed.api import ManagedSerializer
class TokenSerializer(ManagedSerializer, ModelSerializer): class TokenSerializer(ManagedSerializer, ModelSerializer):
"""Token Serializer""" """Token Serializer"""
user_obj = UserSerializer(required=False, source="user", read_only=True) user = UserSerializer(required=False)
def validate(self, attrs: dict[Any, str]) -> dict[Any, str]:
"""Ensure only API or App password tokens are created."""
request: Request = self.context["request"]
attrs.setdefault("user", request.user)
attrs.setdefault("intent", TokenIntents.INTENT_API)
if attrs.get("intent") not in [TokenIntents.INTENT_API, TokenIntents.INTENT_APP_PASSWORD]:
raise ValidationError(f"Invalid intent {attrs.get('intent')}")
return attrs
class Meta: class Meta:
@ -47,14 +31,11 @@ class TokenSerializer(ManagedSerializer, ModelSerializer):
"identifier", "identifier",
"intent", "intent",
"user", "user",
"user_obj",
"description", "description",
"expires", "expires",
"expiring", "expiring",
] ]
extra_kwargs = { depth = 2
"user": {"required": False},
}
class TokenViewSerializer(PassiveSerializer): class TokenViewSerializer(PassiveSerializer):
@ -82,27 +63,17 @@ 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(
instance = serializer.save( user=self.request.user,
user=self.request.user, intent=TokenIntents.INTENT_API,
expiring=self.request.user.attributes.get(USER_ATTRIBUTE_TOKEN_EXPIRING, True), expiring=self.request.user.attributes.get(
) USER_ATTRIBUTE_TOKEN_EXPIRING, True
assign_perm("authentik_core.view_token_key", self.request.user, instance) ),
return instance )
return super().perform_create(serializer)
@permission_required("authentik_core.view_token_key") @permission_required("authentik_core.view_token_key")
@extend_schema( @extend_schema(
@ -111,39 +82,14 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
404: OpenApiResponse(description="Token not found or expired"), 404: OpenApiResponse(description="Token not found or expired"),
} }
) )
@action(detail=True, pagination_class=None, filter_backends=[], methods=["GET"]) @action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=unused-argument # pylint: disable=unused-argument
def view_key(self, request: Request, identifier: str) -> Response: def view_key(self, request: Request, identifier: str) -> Response:
"""Return token key and log access""" """Return token key and log access"""
token: Token = self.get_object() token: Token = self.get_object()
Event.new(EventAction.SECRET_VIEW, secret=token).from_http(request) # noqa # nosec if token.is_expired:
return Response(TokenViewSerializer({"key": token.key}).data) raise Http404
Event.new(EventAction.SECRET_VIEW, secret=token).from_http( # noqa # nosec
@permission_required("authentik_core.set_token_key")
@extend_schema(
request=inline_serializer(
"TokenSetKey",
{
"key": CharField(),
},
),
responses={
204: OpenApiResponse(description="Successfully changed key"),
400: OpenApiResponse(description="Missing key"),
404: OpenApiResponse(description="Token not found or expired"),
},
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
# pylint: disable=unused-argument
def set_key(self, request: Request, identifier: str) -> Response:
"""Return token key and log access"""
token: Token = self.get_object()
key = request.POST.get("key")
if not key:
return Response(status=400)
token.key = key
token.save()
Event.new(EventAction.MODEL_UPDATED, model=model_to_dict(token)).from_http(
request request
) # noqa # nosec )
return Response(status=204) return Response(TokenViewSerializer({"key": token.key}).data)

View File

@ -79,7 +79,9 @@ class UsedByMixin:
).all(): ).all():
# Only merge shadows on first object # Only merge shadows on first object
if first_object: if first_object:
shadows += getattr(manager.model._meta, "authentik_used_by_shadows", []) shadows += getattr(
manager.model._meta, "authentik_used_by_shadows", []
)
first_object = False first_object = False
serializer = UsedBySerializer( serializer = UsedBySerializer(
data={ data={

View File

@ -1,68 +1,40 @@
"""User API Views""" """User API Views"""
from datetime import timedelta
from json import loads from json import loads
from typing import Any, Optional
from django.contrib.auth import update_session_auth_hash
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.db.transaction import atomic
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_filters.filters import BooleanFilter, CharFilter
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django_filters.filters import BooleanFilter, CharFilter, ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet from django_filters.filterset import FilterSet
from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema, extend_schema_field
from drf_spectacular.utils import ( from guardian.utils import get_anonymous_user
OpenApiParameter,
OpenApiResponse,
extend_schema,
extend_schema_field,
inline_serializer,
)
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, JSONField, SerializerMethodField from rest_framework.fields import CharField, JSONField, SerializerMethodField
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
from rest_framework.serializers import ( from rest_framework.serializers import (
BooleanField, BooleanField,
ListSerializer, ListSerializer,
ModelSerializer, ModelSerializer,
PrimaryKeyRelatedField,
ValidationError, ValidationError,
) )
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
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 ( from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_ORIGINAL_USER, SESSION_IMPERSONATE_ORIGINAL_USER,
SESSION_KEY_IMPERSONATE_USER, SESSION_IMPERSONATE_USER,
)
from authentik.core.models import (
USER_ATTRIBUTE_SA,
USER_ATTRIBUTE_TOKEN_EXPIRING,
Group,
Token,
TokenIntents,
User,
) )
from authentik.core.models import Token, TokenIntents, User
from authentik.events.models import EventAction from authentik.events.models import EventAction
from authentik.stages.email.models import EmailStage
from authentik.stages.email.tasks import send_mails
from authentik.stages.email.utils import TemplateEmailMessage
from authentik.tenants.models import Tenant from authentik.tenants.models import Tenant
LOGGER = get_logger()
class UserSerializer(ModelSerializer): class UserSerializer(ModelSerializer):
"""User Serializer""" """User Serializer"""
@ -70,12 +42,8 @@ class UserSerializer(ModelSerializer):
is_superuser = BooleanField(read_only=True) 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)
groups = PrimaryKeyRelatedField( groups = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
allow_empty=True, many=True, source="ak_groups", queryset=Group.objects.all()
)
groups_obj = ListSerializer(child=GroupSerializer(), read_only=True, source="ak_groups")
uid = CharField(read_only=True) uid = CharField(read_only=True)
username = CharField(max_length=150)
class Meta: class Meta:
@ -88,45 +56,21 @@ class UserSerializer(ModelSerializer):
"last_login", "last_login",
"is_superuser", "is_superuser",
"groups", "groups",
"groups_obj",
"email", "email",
"avatar", "avatar",
"attributes", "attributes",
"uid", "uid",
] ]
extra_kwargs = {
"name": {"allow_blank": True},
}
class UserSelfSerializer(ModelSerializer): class UserSelfSerializer(ModelSerializer):
"""User Serializer for information a user can retrieve about themselves""" """User Serializer for information a user can retrieve about themselves and
update about themselves"""
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 = SerializerMethodField()
@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 get_settings(self, user: User) -> dict[str, Any]:
"""Get user settings with tenant and group settings applied"""
return user.group_attributes(self._context["request"]).get("settings", {})
class Meta: class Meta:
@ -141,11 +85,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},
} }
@ -168,30 +110,22 @@ 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(
get_objects_for_user(user, "authentik_events.view_event") action=EventAction.LOGIN_FAILED, context__username=user.username
.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(
get_objects_for_user(user, "authentik_events.view_event") action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
.filter(action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk)
.get_events_per_hour()
) )
@ -206,17 +140,6 @@ class UsersFilter(FilterSet):
) )
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser") is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
uuid = CharFilter(field_name="uuid")
groups_by_name = ModelMultipleChoiceFilter(
field_name="ak_groups__name",
to_field_name="name",
queryset=Group.objects.all(),
)
groups_by_pk = ModelMultipleChoiceFilter(
field_name="ak_groups",
queryset=Group.objects.all(),
)
# pylint: disable=unused-argument # pylint: disable=unused-argument
def filter_attributes(self, queryset, name, value): def filter_attributes(self, queryset, name, value):
@ -230,11 +153,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: return queryset.filter(**qs)
_ = len(queryset.filter(**qs))
return queryset.filter(**qs)
except ValueError:
return queryset
class Meta: class Meta:
model = User model = User
@ -245,8 +164,6 @@ class UsersFilter(FilterSet):
"is_active", "is_active",
"is_superuser", "is_superuser",
"attributes", "attributes",
"groups_by_name",
"groups_by_pk",
] ]
@ -254,127 +171,51 @@ 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", "uuid"] search_fields = ["username", "name", "is_active", "email"]
filterset_class = UsersFilter filterset_class = UsersFilter
def get_queryset(self): # pragma: no cover def get_queryset(self): # pragma: no cover
return User.objects.all().exclude(pk=get_anonymous_user().pk) return User.objects.all().exclude(pk=get_anonymous_user().pk)
def _create_recovery_link(self) -> tuple[Optional[str], Optional[Token]]:
"""Create a recovery link (when the current tenant has a recovery flow set),
that can either be shown to an admin or sent to the user directly"""
tenant: Tenant = self.request._request.tenant
# Check that there is a recovery flow, if not return an error
flow = tenant.flow_recovery
if not flow:
LOGGER.debug("No recovery flow set")
return None, None
user: User = self.get_object()
token, __ = Token.objects.get_or_create(
identifier=f"{user.uid}-password-reset",
user=user,
intent=TokenIntents.INTENT_RECOVERY,
)
querystring = urlencode({"token": token.key})
link = self.request.build_absolute_uri(
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
+ f"?{querystring}"
)
return link, token
@permission_required(None, ["authentik_core.add_user", "authentik_core.add_token"])
@extend_schema(
request=inline_serializer(
"UserServiceAccountSerializer",
{
"name": CharField(required=True),
"create_group": BooleanField(default=False),
},
),
responses={
200: inline_serializer(
"UserServiceAccountResponse",
{
"username": CharField(required=True),
"token": CharField(required=True),
},
)
},
)
@action(detail=False, methods=["POST"], pagination_class=None, filter_backends=[])
def service_account(self, request: Request) -> Response:
"""Create a new user account that is marked as a service account"""
username = request.data.get("name")
create_group = request.data.get("create_group", False)
with atomic():
try:
user = User.objects.create(
username=username,
name=username,
attributes={USER_ATTRIBUTE_SA: True, USER_ATTRIBUTE_TOKEN_EXPIRING: False},
)
if create_group and self.request.user.has_perm("authentik_core.add_group"):
group = Group.objects.create(
name=username,
)
group.users.add(user)
token = Token.objects.create(
identifier=slugify(f"service-account-{username}-password"),
intent=TokenIntents.INTENT_APP_PASSWORD,
user=user,
expires=now() + timedelta(days=360),
)
return Response({"username": user.username, "token": token.key})
except (IntegrityError) as exc:
return Response(data={"non_field_errors": [str(exc)]}, status=400)
@extend_schema(responses={200: SessionUserSerializer(many=False)}) @extend_schema(responses={200: SessionUserSerializer(many=False)})
@action(detail=False, pagination_class=None, filter_backends=[]) @action(detail=False, pagination_class=None, filter_backends=[])
# 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"""
context = {"request": request}
serializer = SessionUserSerializer( serializer = SessionUserSerializer(
data={"user": UserSelfSerializer(instance=request.user, context=context).data} data={"user": UserSerializer(request.user).data}
) )
if SESSION_KEY_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_KEY_IMPERSONATE_ORIGINAL_USER], request._request.session[SESSION_IMPERSONATE_ORIGINAL_USER]
context=context,
).data ).data
self.request.session.save() serializer.is_valid()
return Response(serializer.initial_data) return Response(serializer.data)
@permission_required("authentik_core.reset_user_password")
@extend_schema( @extend_schema(
request=inline_serializer( request=UserSelfSerializer, responses={200: SessionUserSerializer(many=False)}
"UserPasswordSetSerializer",
{
"password": CharField(required=True),
},
),
responses={
204: OpenApiResponse(description="Successfully changed password"),
400: OpenApiResponse(description="Bad request"),
},
) )
@action(detail=True, methods=["POST"]) @action(
# pylint: disable=invalid-name, unused-argument methods=["PUT"],
def set_password(self, request: Request, pk: int) -> Response: detail=False,
"""Set password for user""" pagination_class=None,
user: User = self.get_object() filter_backends=[],
try: permission_classes=[IsAuthenticated],
user.set_password(request.data.get("password")) )
user.save() def update_self(self, request: Request) -> Response:
except (ValidationError, IntegrityError) as exc: """Allow users to change information on their own profile"""
LOGGER.debug("Failed to set password", exc=exc) data = UserSelfSerializer(
return Response(status=400) instance=User.objects.get(pk=request.user.pk), data=request.data
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session: )
LOGGER.debug("Updating session hash after password change") if not data.is_valid():
update_session_auth_hash(self.request, user) return Response(data.errors)
return Response(status=204) new_user = data.save()
# If we're impersonating, we need to update that user object
# since it caches the full object
if SESSION_IMPERSONATE_USER in request.session:
request.session[SESSION_IMPERSONATE_USER] = new_user
return self.me(request)
@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)})
@ -398,59 +239,23 @@ class UserViewSet(UsedByMixin, ModelViewSet):
# pylint: disable=invalid-name, unused-argument # pylint: disable=invalid-name, unused-argument
def recovery(self, request: Request, pk: int) -> Response: def recovery(self, request: Request, pk: int) -> Response:
"""Create a temporary link that a user can use to recover their accounts""" """Create a temporary link that a user can use to recover their accounts"""
link, _ = self._create_recovery_link() tenant: Tenant = request._request.tenant
if not link: # Check that there is a recovery flow, if not return an error
LOGGER.debug("Couldn't create token") flow = tenant.flow_recovery
if not flow:
return Response({"link": ""}, status=404) return Response({"link": ""}, status=404)
return Response({"link": link}) user: User = self.get_object()
token, __ = Token.objects.get_or_create(
@permission_required("authentik_core.reset_user_password") identifier=f"{user.uid}-password-reset",
@extend_schema( user=user,
parameters=[ intent=TokenIntents.INTENT_RECOVERY,
OpenApiParameter(
name="email_stage",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.STR,
required=True,
)
],
responses={
"204": OpenApiResponse(description="Successfully sent recover email"),
"404": OpenApiResponse(description="Bad request"),
},
)
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=invalid-name, unused-argument
def recovery_email(self, request: Request, pk: int) -> Response:
"""Create a temporary link that a user can use to recover their accounts"""
for_user = self.get_object()
if for_user.email == "":
LOGGER.debug("User doesn't have an email address")
return Response(status=404)
link, token = self._create_recovery_link()
if not link:
LOGGER.debug("Couldn't create token")
return Response(status=404)
# Lookup the email stage to assure the current user can access it
stages = get_objects_for_user(
request.user, "authentik_stages_email.view_emailstage"
).filter(pk=request.query_params.get("email_stage"))
if not stages.exists():
LOGGER.debug("Email stage does not exist/user has no permissions")
return Response(status=404)
email_stage: EmailStage = stages.first()
message = TemplateEmailMessage(
subject=_(email_stage.subject),
template_name=email_stage.template,
to=[for_user.email],
template_context={
"url": link,
"user": for_user,
"expires": token.expires,
},
) )
send_mails(email_stage, message) querystring = urlencode({"token": token.key})
return Response(status=204) link = request.build_absolute_uri(
reverse_lazy("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
+ f"?{querystring}"
)
return Response({"link": link})
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet: def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting""" """Custom filter_queryset method which ignores guardian, but still supports sorting"""

View File

@ -2,15 +2,21 @@
from typing import Any from typing import Any
from django.db.models import Model from django.db.models import Model
from rest_framework.fields import BooleanField, CharField, FileField, IntegerField from rest_framework.fields import CharField, IntegerField
from rest_framework.serializers import Serializer, SerializerMethodField, ValidationError from rest_framework.serializers import (
Serializer,
SerializerMethodField,
ValidationError,
)
def is_dict(value: Any): def is_dict(value: Any):
"""Ensure a value is a dictionary, useful for JSONFields""" """Ensure a value is a dictionary, useful for JSONFields"""
if isinstance(value, dict): if isinstance(value, dict):
return return
raise ValidationError("Value must be a dictionary, and not have any duplicate keys.") raise ValidationError(
"Value must be a dictionary, and not have any duplicate keys."
)
class PassiveSerializer(Serializer): class PassiveSerializer(Serializer):
@ -19,21 +25,13 @@ class PassiveSerializer(Serializer):
def create(self, validated_data: dict) -> Model: # pragma: no cover def create(self, validated_data: dict) -> Model: # pragma: no cover
return Model() return Model()
def update(self, instance: Model, validated_data: dict) -> Model: # pragma: no cover def update(
self, instance: Model, validated_data: dict
) -> Model: # pragma: no cover
return Model() return Model()
class Meta:
class FileUploadSerializer(PassiveSerializer): model = Model
"""Serializer to upload file"""
file = FileField(required=False)
clear = BooleanField(default=False)
class FilePathSerializer(PassiveSerializer):
"""Serializer to upload file"""
url = CharField()
class MetaNameSerializer(PassiveSerializer): class MetaNameSerializer(PassiveSerializer):
@ -41,7 +39,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 +48,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

@ -2,6 +2,10 @@
from importlib import import_module from importlib import import_module
from django.apps import AppConfig from django.apps import AppConfig
from django.db import ProgrammingError
from authentik.core.signals import GAUGE_MODELS
from authentik.lib.utils.reflection import get_apps
class AuthentikCoreConfig(AppConfig): class AuthentikCoreConfig(AppConfig):
@ -15,3 +19,12 @@ class AuthentikCoreConfig(AppConfig):
def ready(self): def ready(self):
import_module("authentik.core.signals") import_module("authentik.core.signals")
import_module("authentik.core.managed") import_module("authentik.core.managed")
try:
for app in get_apps():
for model in app.get_models():
GAUGE_MODELS.labels(
model_name=model._meta.model_name,
app=model._meta.app_label,
).set(model.objects.count())
except ProgrammingError:
pass

View File

@ -1,60 +0,0 @@
"""Authenticate with tokens"""
from typing import Any, Optional
from django.contrib.auth.backends import ModelBackend
from django.http.request import HttpRequest
from authentik.core.models import Token, TokenIntents, User
from authentik.events.utils import cleanse_dict, sanitize_dict
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS
class InbuiltBackend(ModelBackend):
"""Inbuilt backend"""
def authenticate(
self, request: HttpRequest, username: Optional[str], password: Optional[str], **kwargs: Any
) -> Optional[User]:
user = super().authenticate(request, username=username, password=password, **kwargs)
if not user:
return None
self.set_method("password", request)
return user
def set_method(self, method: str, request: Optional[HttpRequest], **kwargs):
"""Set method data on current flow, if possbiel"""
if not request:
return
# Since we can't directly pass other variables to signals, and we want to log the method
# and the token used, we assume we're running in a flow and set a variable in the context
flow_plan: FlowPlan = request.session.get(SESSION_KEY_PLAN, FlowPlan(""))
flow_plan.context[PLAN_CONTEXT_METHOD] = method
flow_plan.context[PLAN_CONTEXT_METHOD_ARGS] = cleanse_dict(sanitize_dict(kwargs))
request.session[SESSION_KEY_PLAN] = flow_plan
class TokenBackend(InbuiltBackend):
"""Authenticate with token"""
def authenticate(
self, request: HttpRequest, username: Optional[str], password: Optional[str], **kwargs: Any
) -> Optional[User]:
try:
user = User._default_manager.get_by_natural_key(username)
except User.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (#20760).
User().set_password(password)
return None
# pylint: disable=no-member
tokens = Token.filter_not_expired(
user=user, key=password, intent=TokenIntents.INTENT_APP_PASSWORD
)
if not tokens.exists():
return None
token = tokens.first()
self.set_method("token", request, token=token)
return token.user

View File

@ -4,7 +4,7 @@ from channels.generic.websocket import JsonWebsocketConsumer
from rest_framework.exceptions import AuthenticationFailed from rest_framework.exceptions import AuthenticationFailed
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.api.authentication import bearer_auth from authentik.api.authentication import token_from_header
from authentik.core.models import User from authentik.core.models import User
LOGGER = get_logger() LOGGER = get_logger()
@ -24,12 +24,12 @@ class AuthJsonConsumer(JsonWebsocketConsumer):
raw_header = headers[b"authorization"] raw_header = headers[b"authorization"]
try: try:
user = bearer_auth(raw_header) token = token_from_header(raw_header)
# user is only None when no header was given, in which case we deny too # token is only None when no header was given, in which case we deny too
if not user: if not token:
raise DenyConnection() raise DenyConnection()
except AuthenticationFailed as exc: except AuthenticationFailed as exc:
LOGGER.warning("Failed to authenticate", exc=exc) LOGGER.warning("Failed to authenticate", exc=exc)
raise DenyConnection() raise DenyConnection()
self.user = user self.user = token.user

View File

@ -12,6 +12,5 @@ class CoreManager(ObjectManager):
Source, Source,
"goauthentik.io/sources/inbuilt", "goauthentik.io/sources/inbuilt",
name="authentik Built-in", name="authentik Built-in",
slug="authentik-built-in",
), ),
] ]

View File

@ -1,106 +0,0 @@
"""authentik shell command"""
import code
import platform
from django.apps import apps
from django.core.management.base import BaseCommand
from django.db.models import Model
from django.db.models.signals import post_save, pre_delete
from authentik import __version__
from authentik.core.models import User
from authentik.events.middleware import IGNORED_MODELS
from authentik.events.models import Event, EventAction
from authentik.events.utils import model_to_dict
BANNER_TEXT = """### authentik shell ({authentik})
### Node {node} | Arch {arch} | Python {python} """.format(
node=platform.node(),
python=platform.python_version(),
arch=platform.machine(),
authentik=__version__,
)
class Command(BaseCommand): # pragma: no cover
"""Start the Django shell with all authentik models already imported"""
django_models = {}
def add_arguments(self, parser):
parser.add_argument(
"-c",
"--command",
help="Python code to execute (instead of starting an interactive shell)",
)
def get_namespace(self):
"""Prepare namespace with all models"""
namespace = {}
# Gather Django models and constants from each app
for app in apps.get_app_configs():
if not app.name.startswith("authentik"):
continue
# Load models from each app
for model in app.get_models():
namespace[model.__name__] = model
return namespace
@staticmethod
# pylint: disable=unused-argument
def post_save_handler(sender, instance: Model, created: bool, **_):
"""Signal handler for all object's post_save"""
if isinstance(instance, IGNORED_MODELS):
return
action = EventAction.MODEL_CREATED if created else EventAction.MODEL_UPDATED
Event.new(action, model=model_to_dict(instance)).set_user(
User(
username="authentik-shell",
pk=0,
email="",
)
).save()
@staticmethod
# pylint: disable=unused-argument
def pre_delete_handler(sender, instance: Model, **_):
"""Signal handler for all object's pre_delete"""
if isinstance(instance, IGNORED_MODELS): # pragma: no cover
return
Event.new(EventAction.MODEL_DELETED, model=model_to_dict(instance)).set_user(
User(
username="authentik-shell",
pk=0,
email="",
)
).save()
def handle(self, **options):
namespace = self.get_namespace()
post_save.connect(Command.post_save_handler)
pre_delete.connect(Command.pre_delete_handler)
# If Python code has been passed, execute it and exit.
if options["command"]:
# pylint: disable=exec-used
exec(options["command"], namespace) # nosec # noqa
return
# Try to enable tab-complete
try:
import readline
import rlcompleter
except ModuleNotFoundError:
pass
else:
readline.set_completer(rlcompleter.Completer(namespace).complete)
readline.parse_and_bind("tab: complete")
# Run interactive shell
code.interact(banner=BANNER_TEXT, local=namespace)

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_KEY_IMPERSONATE_USER = "authentik/impersonate/user" SESSION_IMPERSONATE_USER = "authentik_impersonate_user"
SESSION_KEY_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:
@ -25,10 +22,10 @@ class ImpersonateMiddleware:
def __call__(self, request: HttpRequest) -> HttpResponse: def __call__(self, request: HttpRequest) -> HttpResponse:
# No permission checks are done here, they need to be checked before # No permission checks are done here, they need to be checked before
# SESSION_KEY_IMPERSONATE_USER is set. # SESSION_IMPERSONATE_USER is set.
if SESSION_KEY_IMPERSONATE_USER in request.session: if SESSION_IMPERSONATE_USER in request.session:
request.user = request.session[SESSION_KEY_IMPERSONATE_USER] request.user = request.session[SESSION_IMPERSONATE_USER]
# Ensure that the user is active, otherwise nothing will work # Ensure that the user is active, otherwise nothing will work
request.user.is_active = True request.user.is_active = True
@ -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

@ -38,7 +38,9 @@ class Migration(migrations.Migration):
("password", models.CharField(max_length=128, verbose_name="password")), ("password", models.CharField(max_length=128, verbose_name="password")),
( (
"last_login", "last_login",
models.DateTimeField(blank=True, null=True, verbose_name="last login"), models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
), ),
( (
"is_superuser", "is_superuser",
@ -51,25 +53,35 @@ class Migration(migrations.Migration):
( (
"username", "username",
models.CharField( models.CharField(
error_messages={"unique": "A user with that username already exists."}, error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150, max_length=150,
unique=True, unique=True,
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username", verbose_name="username",
), ),
), ),
( (
"first_name", "first_name",
models.CharField(blank=True, max_length=30, verbose_name="first name"), models.CharField(
blank=True, max_length=30, verbose_name="first name"
),
), ),
( (
"last_name", "last_name",
models.CharField(blank=True, max_length=150, verbose_name="last name"), models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
), ),
( (
"email", "email",
models.EmailField(blank=True, max_length=254, verbose_name="email address"), models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
), ),
( (
"is_staff", "is_staff",
@ -205,7 +217,9 @@ class Migration(migrations.Migration):
), ),
( (
"expires", "expires",
models.DateTimeField(default=authentik.core.models.default_token_duration), models.DateTimeField(
default=authentik.core.models.default_token_duration
),
), ),
("expiring", models.BooleanField(default=True)), ("expiring", models.BooleanField(default=True)),
("description", models.TextField(blank=True, default="")), ("description", models.TextField(blank=True, default="")),
@ -292,7 +306,9 @@ class Migration(migrations.Migration):
("name", models.TextField(help_text="Application's display Name.")), ("name", models.TextField(help_text="Application's display Name.")),
( (
"slug", "slug",
models.SlugField(help_text="Internal application name, used in URLs."), models.SlugField(
help_text="Internal application name, used in URLs."
),
), ),
("skip_authorization", models.BooleanField(default=False)), ("skip_authorization", models.BooleanField(default=False)),
("meta_launch_url", models.URLField(blank=True, default="")), ("meta_launch_url", models.URLField(blank=True, default="")),

View File

@ -1,228 +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"
)
password = None
if "TF_BUILD" in environ or settings.TEST:
password = "akadmin" # noqa # nosec
if "AK_ADMIN_PASS" in environ:
password = environ["AK_ADMIN_PASS"]
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
if password:
akadmin.set_password(password, signal=False)
else:
akadmin.set_unusable_password()
akadmin.save()
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

@ -16,15 +16,10 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
akadmin, _ = User.objects.using(db_alias).get_or_create( akadmin, _ = User.objects.using(db_alias).get_or_create(
username="akadmin", email="root@localhost", name="authentik Default Admin" username="akadmin", email="root@localhost", name="authentik Default Admin"
) )
password = None if "TF_BUILD" in environ or "AK_ADMIN_PASS" in environ or settings.TEST:
if "TF_BUILD" in environ or settings.TEST: akadmin.set_password(
password = "akadmin" # noqa # nosec environ.get("AK_ADMIN_PASS", "akadmin"), signal=False
if "AK_ADMIN_PASS" in environ: ) # noqa # nosec
password = environ["AK_ADMIN_PASS"]
if "AUTHENTIK_BOOTSTRAP_PASSWORD" in environ:
password = environ["AUTHENTIK_BOOTSTRAP_PASSWORD"]
if password:
akadmin.set_password(password, signal=False)
else: else:
akadmin.set_unusable_password() akadmin.set_unusable_password()
akadmin.save() akadmin.save()

View File

@ -13,6 +13,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="source", model_name="source",
name="slug", name="slug",
field=models.SlugField(help_text="Internal source name, used in URLs.", unique=True), field=models.SlugField(
help_text="Internal source name, used in URLs.", unique=True
),
), ),
] ]

View File

@ -13,6 +13,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="user", model_name="user",
name="first_name", name="first_name",
field=models.CharField(blank=True, max_length=150, verbose_name="first name"), field=models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
), ),
] ]

View File

@ -40,7 +40,9 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="user", model_name="user",
name="pb_groups", name="pb_groups",
field=models.ManyToManyField(related_name="users", to="authentik_core.Group"), field=models.ManyToManyField(
related_name="users", to="authentik_core.Group"
),
), ),
migrations.AddField( migrations.AddField(
model_name="group", model_name="group",

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

@ -42,7 +42,9 @@ class Migration(migrations.Migration):
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="token", model_name="token",
index=models.Index(fields=["identifier"], name="authentik_co_identif_1a34a8_idx"), index=models.Index(
fields=["identifier"], name="authentik_co_identif_1a34a8_idx"
),
), ),
migrations.RunPython(set_default_token_key), migrations.RunPython(set_default_token_key),
] ]

View File

@ -17,6 +17,8 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="application", model_name="application",
name="meta_icon", name="meta_icon",
field=models.FileField(blank=True, default="", upload_to="application-icons/"), field=models.FileField(
blank=True, default="", upload_to="application-icons/"
),
), ),
] ]

View File

@ -25,7 +25,9 @@ class Migration(migrations.Migration):
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="token", model_name="token",
index=models.Index(fields=["identifier"], name="authentik_c_identif_d9d032_idx"), index=models.Index(
fields=["identifier"], name="authentik_c_identif_d9d032_idx"
),
), ),
migrations.AddIndex( migrations.AddIndex(
model_name="token", model_name="token",

View File

@ -1,214 +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):
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
key = None
if "AK_ADMIN_TOKEN" in environ:
key = environ["AK_ADMIN_TOKEN"]
if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ:
key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"]
if not key:
return
Token.objects.using(db_alias).create(
identifier="authentik-bootstrap-token",
user=akadmin.first(),
intent=TokenIntents.INTENT_API,
expiring=False,
key=key,
)
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

@ -1,18 +0,0 @@
# Generated by Django 4.0.3 on 2022-04-02 19:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0018_auto_20210330_1345_squashed_0028_alter_token_intent"),
]
operations = [
migrations.AddField(
model_name="application",
name="group",
field=models.TextField(blank=True, default=""),
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 4.0.5 on 2022-06-04 06:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0019_application_group"),
]
operations = [
migrations.AddField(
model_name="application",
name="open_in_new_tab",
field=models.BooleanField(
default=False, help_text="Open launch URL in a new browser tab or window."
),
),
]

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

@ -12,6 +12,7 @@ import authentik.core.models
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): 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.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache from django.core.cache import cache
@ -31,12 +32,16 @@ class Migration(migrations.Migration):
fields=[ fields=[
( (
"expires", "expires",
models.DateTimeField(default=authentik.core.models.default_token_duration), models.DateTimeField(
default=authentik.core.models.default_token_duration
),
), ),
("expiring", models.BooleanField(default=True)), ("expiring", models.BooleanField(default=True)),
( (
"uuid", "uuid",
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False), models.UUIDField(
default=uuid.uuid4, primary_key=True, serialize=False
),
), ),
("session_key", models.CharField(max_length=40)), ("session_key", models.CharField(max_length=40)),
("last_ip", models.TextField()), ("last_ip", models.TextField()),

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

@ -13,6 +13,8 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="application", model_name="application",
name="meta_icon", name="meta_icon",
field=models.FileField(default=None, null=True, upload_to="application-icons/"), field=models.FileField(
default=None, null=True, upload_to="application-icons/"
),
), ),
] ]

View File

@ -17,11 +17,4 @@ class Migration(migrations.Migration):
default=None, max_length=500, null=True, upload_to="application-icons/" 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",
},
),
] ]

View File

@ -1,42 +0,0 @@
# Generated by Django 3.2.5 on 2021-08-11 19:40
from os import environ
from django.apps.registry import Apps
from django.db import migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
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
key = None
if "AK_ADMIN_TOKEN" in environ:
key = environ["AK_ADMIN_TOKEN"]
if "AUTHENTIK_BOOTSTRAP_TOKEN" in environ:
key = environ["AUTHENTIK_BOOTSTRAP_TOKEN"]
if not key:
return
Token.objects.using(db_alias).create(
identifier="authentik-bootstrap-token",
user=akadmin.first(),
intent=TokenIntents.INTENT_API,
expiring=False,
key=key,
)
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0026_alter_application_meta_icon"),
]
operations = [
migrations.RunPython(create_default_user_token),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 3.2.6 on 2021-08-23 14:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0027_bootstrap_token"),
]
operations = [
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

@ -1,20 +1,20 @@
"""authentik core models""" """authentik core models"""
from datetime import timedelta from datetime import timedelta
from hashlib import md5, sha256 from hashlib import md5, sha256
from typing import Any, Optional from typing import Any, Optional, Type
from urllib.parse import urlencode from urllib.parse import urlencode
from uuid import uuid4 from uuid import uuid4
from deepmerge import always_merger from deepmerge import always_merger
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password
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
from django.templatetags.static import static from django.templatetags.static import static
from django.utils.functional import SimpleLazyObject, cached_property from django.utils.functional import cached_property
from django.utils.html import escape from django.utils.html import escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -26,9 +26,9 @@ 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.models import CreatedUpdatedModel, SerializerModel
from authentik.lib.models import CreatedUpdatedModel, DomainlessURLValidator, 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
@ -36,14 +36,8 @@ from authentik.policies.models import PolicyBindingModel
LOGGER = get_logger() LOGGER = get_logger()
USER_ATTRIBUTE_DEBUG = "goauthentik.io/user/debug" 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_GENERATED = "goauthentik.io/user/generated"
USER_ATTRIBUTE_EXPIRES = "goauthentik.io/user/expires"
USER_ATTRIBUTE_DELETE_ON_LOGOUT = "goauthentik.io/user/delete-on-logout"
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_NAME = "goauthentik.io/user/can-change-name"
USER_ATTRIBUTE_CHANGE_EMAIL = "goauthentik.io/user/can-change-email"
USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips" USER_ATTRIBUTE_CAN_OVERRIDE_IP = "goauthentik.io/user/override-ips"
GRAVATAR_URL = "https://secure.gravatar.com" GRAVATAR_URL = "https://secure.gravatar.com"
@ -60,9 +54,7 @@ def default_token_duration():
def default_token_key(): def default_token_key():
"""Default token key""" """Default token key"""
# We use generate_id since the chars in the key should be easy return uuid4().hex
# to use in Emails (for verification) and URLs (for recovery)
return generate_id(int(CONFIG.y("default_token_length")))
class Group(models.Model): class Group(models.Model):
@ -84,34 +76,6 @@ class Group(models.Model):
) )
attributes = models.JSONField(default=dict, blank=True) attributes = models.JSONField(default=dict, blank=True)
@property
def num_pk(self) -> int:
"""Get a numerical, int32 ID for the group"""
# int max is 2147483647 (10 digits) so 9 is the max usable
# in the LDAP Outpost we use the last 5 chars so match here
return int(str(self.pk.int)[:5])
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}"
@ -147,12 +111,10 @@ class User(GuardianUserMixin, AbstractUser):
objects = UserManager() objects = UserManager()
def group_attributes(self, request: Optional[HttpRequest] = None) -> dict[str, Any]: def group_attributes(self) -> dict[str, Any]:
"""Get a dictionary containing the attributes from all groups the user belongs to, """Get a dictionary containing the attributes from all groups the user belongs to,
including the users attributes""" including the users attributes"""
final_attributes = {} final_attributes = {}
if request and hasattr(request, "tenant"):
always_merger.merge(final_attributes, request.tenant.attributes)
for group in self.ak_groups.all().order_by("name"): for group in self.ak_groups.all().order_by("name"):
always_merger.merge(final_attributes, group.attributes) always_merger.merge(final_attributes, group.attributes)
always_merger.merge(final_attributes, self.attributes) always_merger.merge(final_attributes, self.attributes)
@ -168,31 +130,15 @@ class User(GuardianUserMixin, AbstractUser):
"""superuser == staff user""" """superuser == staff user"""
return self.is_superuser # type: ignore return self.is_superuser # type: ignore
def set_password(self, raw_password, signal=True): def set_password(self, password, signal=True):
if self.pk and signal: if self.pk and signal:
password_changed.send(sender=self, user=self, password=raw_password) password_changed.send(sender=self, user=self, password=password)
self.password_change_date = now() self.password_change_date = now()
return super().set_password(raw_password) return super().set_password(password)
def check_password(self, raw_password: str) -> bool:
"""
Return a boolean of whether the raw_password was correct. Handles
hashing formats behind the scenes.
Slightly changed version which doesn't send a signal for such internal hash upgrades
"""
def setter(raw_password):
self.set_password(raw_password, signal=False)
# Password hash upgrades shouldn't be considered password changes.
self._password = None
self.save(update_fields=["password"])
return check_password(raw_password, self.password, setter)
@property @property
def uid(self) -> str: def uid(self) -> str:
"""Generate a globally unique UID, based on the user ID and the hashed secret key""" """Generate a globall unique UID, based on the user ID and the hashed secret key"""
return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest() return sha256(f"{self.id}-{settings.SECRET_KEY}".encode("ascii")).hexdigest()
@property @property
@ -202,13 +148,15 @@ 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"),
("r", "g"), ("r", "g"),
] ]
gravatar_url = f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}" gravatar_url = (
f"{GRAVATAR_URL}/avatar/{mail_hash}?{urlencode(parameters, doseq=True)}"
)
return escape(gravatar_url) return escape(gravatar_url)
return mode % { return mode % {
"username": self.username, "username": self.username,
@ -232,13 +180,15 @@ 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",
) )
property_mappings = models.ManyToManyField("PropertyMapping", default=None, blank=True) property_mappings = models.ManyToManyField(
"PropertyMapping", default=None, blank=True
)
objects = InheritanceManager() objects = InheritanceManager()
@ -254,7 +204,7 @@ class Provider(SerializerModel):
raise NotImplementedError raise NotImplementedError
@property @property
def serializer(self) -> type[Serializer]: def serializer(self) -> Type[Serializer]:
"""Get serializer for this model""" """Get serializer for this model"""
raise NotImplementedError raise NotImplementedError
@ -268,21 +218,16 @@ class Application(PolicyBindingModel):
add custom fields and other properties""" add custom fields and other properties"""
name = models.TextField(help_text=_("Application's display Name.")) name = models.TextField(help_text=_("Application's display Name."))
slug = models.SlugField(help_text=_("Internal application name, used in URLs."), unique=True) slug = models.SlugField(
group = models.TextField(blank=True, default="") help_text=_("Internal application name, used in URLs."), unique=True
)
provider = models.OneToOneField( provider = models.OneToOneField(
"Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT "Provider", null=True, blank=True, default=None, on_delete=models.SET_DEFAULT
) )
meta_launch_url = models.TextField( meta_launch_url = models.TextField(
default="", blank=True, validators=[DomainlessURLValidator()] default="", blank=True, validators=[validators.URLValidator()]
) )
open_in_new_tab = models.BooleanField(
default=False, help_text=_("Open launch URL in a new browser tab or window.")
)
# For template applications, this can be set to /static/authentik/applications/* # For template applications, this can be set to /static/authentik/applications/*
meta_icon = models.FileField( meta_icon = models.FileField(
upload_to="application-icons/", upload_to="application-icons/",
@ -299,40 +244,25 @@ 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
def get_launch_url(self, user: Optional["User"] = None) -> Optional[str]: def get_launch_url(self) -> Optional[str]:
"""Get launch URL if set, otherwise attempt to get launch URL based on provider.""" """Get launch URL if set, otherwise attempt to get launch URL based on provider."""
url = None
if provider := self.get_provider():
url = provider.launch_url
if self.meta_launch_url: if self.meta_launch_url:
url = self.meta_launch_url return self.meta_launch_url
if user and url: if self.provider:
if isinstance(user, SimpleLazyObject): return self.get_provider().launch_url
user._setup() return None
user = user._wrapped
try:
return url % user.__dict__
# pylint: disable=broad-except
except Exception as exc:
LOGGER.warning("Failed to format launch url", exc=exc)
return url
return url
def get_provider(self) -> Optional[Provider]: def get_provider(self) -> Optional[Provider]:
"""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 return Provider.objects.get_subclass(pk=self.provider.pk)
# but doing a direct query lookup will fail.
# In that case, just return None
try:
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
@ -358,7 +288,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."
) )
) )
@ -371,13 +301,17 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server""" """Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
name = models.TextField(help_text=_("Source's display Name.")) name = models.TextField(help_text=_("Source's display Name."))
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True) slug = models.SlugField(
help_text=_("Internal source name, used in URLs."), unique=True
)
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
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,
@ -386,7 +320,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,
@ -413,11 +347,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."""
@ -484,9 +420,6 @@ class TokenIntents(models.TextChoices):
# Recovery use for the recovery app # Recovery use for the recovery app
INTENT_RECOVERY = "recovery" INTENT_RECOVERY = "recovery"
# App-specific passwords
INTENT_APP_PASSWORD = "app_password" # nosec
class Token(ManagedModel, ExpiringModel): class Token(ManagedModel, ExpiringModel):
"""Token used to authenticate the User for API Access or confirm another Stage like Email.""" """Token used to authenticate the User for API Access or confirm another Stage like Email."""
@ -504,14 +437,6 @@ class Token(ManagedModel, ExpiringModel):
"""Handler which is called when this object is expired.""" """Handler which is called when this object is expired."""
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
if self.intent in [
TokenIntents.INTENT_RECOVERY,
TokenIntents.INTENT_VERIFICATION,
TokenIntents.INTENT_APP_PASSWORD,
]:
super().expire_action(*args, **kwargs)
return
self.key = default_token_key() self.key = default_token_key()
self.expires = default_token_duration() self.expires = default_token_duration()
self.save(*args, **kwargs) self.save(*args, **kwargs)
@ -553,11 +478,13 @@ class PropertyMapping(SerializerModel, ManagedModel):
raise NotImplementedError raise NotImplementedError
@property @property
def serializer(self) -> type[Serializer]: def serializer(self) -> Type[Serializer]:
"""Get serializer for this model""" """Get serializer for this model"""
raise NotImplementedError raise NotImplementedError
def evaluate(self, user: Optional[User], request: Optional[HttpRequest], **kwargs) -> Any: def evaluate(
self, user: Optional[User], request: Optional[HttpRequest], **kwargs
) -> Any:
"""Evaluate `self.expression` using `**kwargs` as Context.""" """Evaluate `self.expression` using `**kwargs` as Context."""
from authentik.core.expression import PropertyMappingEvaluator from authentik.core.expression import PropertyMappingEvaluator
@ -596,7 +523,9 @@ class AuthenticatedSession(ExpiringModel):
last_used = models.DateTimeField(auto_now=True) last_used = models.DateTimeField(auto_now=True)
@staticmethod @staticmethod
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]: def from_request(
request: HttpRequest, user: User
) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request""" """Create a new session from a http request"""
if not hasattr(request, "session") or not request.session.session_key: if not hasattr(request, "session") or not request.session.session_key:
return None return None
@ -607,8 +536,3 @@ class AuthenticatedSession(ExpiringModel):
last_user_agent=request.META.get("HTTP_USER_AGENT", ""), last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
expires=request.session.get_expiry_date(), expires=request.session.get_expiry_date(),
) )
class Meta:
verbose_name = _("Authenticated Session")
verbose_name_plural = _("Authenticated Sessions")

View File

@ -1,5 +1,5 @@
"""authentik core signals""" """authentik core signals"""
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Type
from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.cache import KEY_PREFIX from django.contrib.sessions.backends.cache import KEY_PREFIX
@ -9,11 +9,14 @@ 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.dispatch import receiver from django.dispatch import receiver
from django.http.request import HttpRequest from django.http.request import HttpRequest
from prometheus_client import Gauge
# Arguments: user: User, password: str # Arguments: user: User, password: str
password_changed = Signal() password_changed = Signal()
# Arguments: credentials: dict[str, any], request: HttpRequest, stage: Stage
login_failed = Signal() GAUGE_MODELS = Gauge(
"authentik_models", "Count of various objects", ["model_name", "app"]
)
if TYPE_CHECKING: if TYPE_CHECKING:
from authentik.core.models import AuthenticatedSession, User from authentik.core.models import AuthenticatedSession, User
@ -26,6 +29,11 @@ def post_save_application(sender: type[Model], instance, created: bool, **_):
from authentik.core.api.applications import user_app_cache_key from authentik.core.api.applications import user_app_cache_key
from authentik.core.models import Application from authentik.core.models import Application
GAUGE_MODELS.labels(
model_name=sender._meta.model_name,
app=sender._meta.app_label,
).set(sender.objects.count())
if sender != Application: if sender != Application:
return return
if not created: # pragma: no cover if not created: # pragma: no cover
@ -52,11 +60,15 @@ def user_logged_out_session(sender, request: HttpRequest, user: "User", **_):
"""Delete AuthenticatedSession if it exists""" """Delete AuthenticatedSession if it exists"""
from authentik.core.models import AuthenticatedSession from authentik.core.models import AuthenticatedSession
AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete() AuthenticatedSession.objects.filter(
session_key=request.session.session_key
).delete()
@receiver(pre_delete) @receiver(pre_delete)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_): def authenticated_session_delete(
sender: Type[Model], instance: "AuthenticatedSession", **_
):
"""Delete session when authenticated session is deleted""" """Delete session when authenticated session is deleted"""
from authentik.core.models import AuthenticatedSession from authentik.core.models import AuthenticatedSession

View File

@ -1,6 +1,6 @@
"""Source decision helper""" """Source decision helper"""
from enum import Enum from enum import Enum
from typing import Any, Optional from typing import Any, Optional, Type
from django.contrib import messages from django.contrib import messages
from django.db import IntegrityError from django.db import IntegrityError
@ -11,10 +11,17 @@ from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Source, SourceUserMatchingModes, User, UserSourceConnection from authentik.core.models import (
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION, PostUserEnrollmentStage Source,
SourceUserMatchingModes,
User,
UserSourceConnection,
)
from authentik.core.sources.stage import (
PLAN_CONTEXT_SOURCES_CONNECTION,
PostUserEnrollmentStage,
)
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import Flow, Stage, in_memory_stage from authentik.flows.models import Flow, Stage, in_memory_stage
from authentik.flows.planner import ( from authentik.flows.planner import (
PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_PENDING_USER,
@ -23,12 +30,10 @@ 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.denied import AccessDeniedResponse
from authentik.policies.types import PolicyResult
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_DJANGO
from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND from authentik.stages.password.stage import PLAN_CONTEXT_AUTHENTICATION_BACKEND
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
@ -53,10 +58,7 @@ class SourceFlowManager:
identifier: str identifier: str
connection_type: type[UserSourceConnection] = UserSourceConnection connection_type: Type[UserSourceConnection] = UserSourceConnection
enroll_info: dict[str, Any]
policy_context: dict[str, Any]
def __init__( def __init__(
self, self,
@ -70,12 +72,13 @@ class SourceFlowManager:
self.identifier = identifier self.identifier = identifier
self.enroll_info = enroll_info self.enroll_info = enroll_info
self._logger = get_logger().bind(source=source, identifier=identifier) self._logger = get_logger().bind(source=source, identifier=identifier)
self.policy_context = {}
# pylint: disable=too-many-return-statements # pylint: disable=too-many-return-statements
def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]: def get_action(self, **kwargs) -> tuple[Action, Optional[UserSourceConnection]]:
"""decide which action should be taken""" """decide which action should be taken"""
new_connection = self.connection_type(source=self.source, identifier=self.identifier) new_connection = self.connection_type(
source=self.source, identifier=self.identifier
)
# When request is authenticated, always link # When request is authenticated, always link
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
new_connection.user = self.request.user new_connection.user = self.request.user
@ -110,7 +113,9 @@ class SourceFlowManager:
SourceUserMatchingModes.USERNAME_DENY, SourceUserMatchingModes.USERNAME_DENY,
]: ]:
if not self.enroll_info.get("username", None): if not self.enroll_info.get("username", None):
self._logger.warning("Refusing to use none username", source=self.source) self._logger.warning(
"Refusing to use none username", source=self.source
)
return Action.DENY, None return Action.DENY, None
query = Q(username__exact=self.enroll_info.get("username", None)) query = Q(username__exact=self.enroll_info.get("username", None))
self._logger.debug("trying to link with existing user", query=query) self._logger.debug("trying to link with existing user", query=query)
@ -151,23 +156,20 @@ class SourceFlowManager:
except IntegrityError as exc: except IntegrityError as exc:
self._logger.warning("failed to get action", exc=exc) self._logger.warning("failed to get action", exc=exc)
return redirect("/") return redirect("/")
self._logger.debug("get_action", action=action, connection=connection) self._logger.debug("get_action() says", action=action, connection=connection)
try: if connection:
if connection: if action == Action.LINK:
if action == Action.LINK: self._logger.debug("Linking existing user")
self._logger.debug("Linking existing user") return self.handle_existing_user_link(connection)
return self.handle_existing_user_link(connection) if action == Action.AUTH:
if action == Action.AUTH: self._logger.debug("Handling auth user")
self._logger.debug("Handling auth user") return self.handle_auth_user(connection)
return self.handle_auth_user(connection) if action == Action.ENROLL:
if action == Action.ENROLL: self._logger.debug("Handling enrollment of new user")
self._logger.debug("Handling enrollment of new user") return self.handle_enroll(connection)
return self.handle_enroll(connection)
except FlowNonApplicableException as exc:
self._logger.warning("Flow non applicable", exc=exc)
return self.error_handler(exc, exc.policy_result)
# Default case, assume deny # Default case, assume deny
error = ( messages.error(
self.request,
_( _(
( (
"Request to authenticate with %(source)s has been denied. Please authenticate " "Request to authenticate with %(source)s has been denied. Please authenticate "
@ -176,17 +178,7 @@ class SourceFlowManager:
% {"source": self.source.name} % {"source": self.source.name}
), ),
) )
return self.error_handler(error) return redirect(reverse("authentik_core:root-redirect"))
def error_handler(
self, error: Exception, policy_result: Optional[PolicyResult] = None
) -> HttpResponse:
"""Handle any errors by returning an access denied stage"""
response = AccessDeniedResponse(self.request)
response.error_message = str(error)
if policy_result:
response.policy_result = policy_result
return response
# pylint: disable=unused-argument # pylint: disable=unused-argument
def get_stages_to_append(self, flow: Flow) -> list[Stage]: def get_stages_to_append(self, flow: Flow) -> list[Stage]:
@ -199,26 +191,22 @@ class SourceFlowManager:
] ]
return [] return []
def _handle_login_flow( def _handle_login_flow(self, flow: Flow, **kwargs) -> HttpResponse:
self, flow: Flow, connection: UserSourceConnection, **kwargs
) -> HttpResponse:
"""Prepare Authentication Plan, redirect user FlowExecutor""" """Prepare Authentication Plan, redirect user FlowExecutor"""
# 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(
{ {
# Since we authenticate the user by their token, they have no backend set # Since we authenticate the user by their token, they have no backend set
PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_INBUILT, PLAN_CONTEXT_AUTHENTICATION_BACKEND: BACKEND_DJANGO,
PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_SOURCE: self.source, PLAN_CONTEXT_SOURCE: self.source,
PLAN_CONTEXT_REDIRECT: final_redirect, PLAN_CONTEXT_REDIRECT: final_redirect,
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
} }
) )
kwargs.update(self.policy_context)
if not flow: if not flow:
return HttpResponseBadRequest() return HttpResponseBadRequest()
# We run the Flow planner here so we can pass the Pending user in the context # We run the Flow planner here so we can pass the Pending user in the context
@ -241,10 +229,13 @@ class SourceFlowManager:
"""Login user and redirect.""" """Login user and redirect."""
messages.success( messages.success(
self.request, self.request,
_("Successfully authenticated with %(source)s!" % {"source": self.source.name}), _(
"Successfully authenticated with %(source)s!"
% {"source": self.source.name}
),
) )
flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user} flow_kwargs = {PLAN_CONTEXT_PENDING_USER: connection.user}
return self._handle_login_flow(self.source.authentication_flow, connection, **flow_kwargs) return self._handle_login_flow(self.source.authentication_flow, **flow_kwargs)
def handle_existing_user_link( def handle_existing_user_link(
self, self,
@ -267,9 +258,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(
@ -279,7 +270,10 @@ class SourceFlowManager:
"""User was not authenticated and previous request was not authenticated.""" """User was not authenticated and previous request was not authenticated."""
messages.success( messages.success(
self.request, self.request,
_("Successfully authenticated with %(source)s!" % {"source": self.source.name}), _(
"Successfully authenticated with %(source)s!"
% {"source": self.source.name}
),
) )
# We run the Flow planner here so we can pass the Pending user in the context # We run the Flow planner here so we can pass the Pending user in the context
@ -288,8 +282,8 @@ class SourceFlowManager:
return HttpResponseBadRequest() return HttpResponseBadRequest()
return self._handle_login_flow( return self._handle_login_flow(
self.source.enrollment_flow, self.source.enrollment_flow,
connection,
**{ **{
PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info), PLAN_CONTEXT_PROMPT: delete_none_keys(self.enroll_info),
PLAN_CONTEXT_SOURCES_CONNECTION: connection,
}, },
) )

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

@ -1,73 +1,82 @@
"""authentik core tasks""" """authentik core tasks"""
from datetime import datetime, timedelta from datetime import datetime
from io import StringIO
from os import environ
from django.contrib.sessions.backends.cache import KEY_PREFIX from boto3.exceptions import Boto3Error
from django.core.cache import cache from botocore.exceptions import BotoCoreError, ClientError
from dbbackup.db.exceptions import CommandConnectorError
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.core import management
from django.utils.timezone import now from django.utils.timezone import now
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 ( from authentik.core.models import ExpiringModel
USER_ATTRIBUTE_EXPIRES, from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
USER_ATTRIBUTE_GENERATED, from authentik.lib.config import CONFIG
AuthenticatedSession,
ExpiringModel,
User,
)
from authentik.events.monitored_tasks import (
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.root.celery import CELERY_APP from authentik.root.celery import CELERY_APP
LOGGER = get_logger() 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 = []
for cls in ExpiringModel.__subclasses__(): for cls in ExpiringModel.__subclasses__():
cls: ExpiringModel cls: ExpiringModel
objects = ( objects = (
cls.objects.all().exclude(expiring=False).exclude(expiring=True, expires__gt=now()) cls.objects.all()
.exclude(expiring=False)
.exclude(expiring=True, expires__gt=now())
) )
amount = objects.count()
for obj in objects: for obj in objects:
obj.expire_action() obj.expire_action()
amount = objects.count()
LOGGER.debug("Expired models", model=cls, amount=amount) LOGGER.debug("Expired models", model=cls, amount=amount)
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}") messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
# Special case
amount = 0
for session in AuthenticatedSession.objects.all():
cache_key = f"{KEY_PREFIX}{session.session_key}"
value = cache.get(cache_key)
if not value:
session.delete()
amount += 1
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages))
@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 clean_temporary_users(self: MonitoredTask): """Database backup"""
"""Remove temporary users created by SAML Sources""" self.result_timeout_hours = 25
_now = datetime.now() if SERVICE_HOST_ENV_NAME in environ and not CONFIG.y("postgresql.s3_backup"):
messages = [] LOGGER.info("Running in k8s and s3 backups are not configured, skipping")
deleted_users = 0 self.set_status(
for user in User.objects.filter(**{f"attributes__{USER_ATTRIBUTE_GENERATED}": True}): TaskResult(
if not user.attributes.get(USER_ATTRIBUTE_EXPIRES): TaskResultStatus.WARNING,
continue [
delta: timedelta = _now - datetime.fromtimestamp( (
user.attributes.get(USER_ATTRIBUTE_EXPIRES) "Skipping backup as authentik is running in Kubernetes "
"without S3 backups configured."
),
],
)
) )
if delta.total_seconds() > 0: return
LOGGER.debug("User is expired and will be deleted.", user=user, delta=delta) try:
user.delete() start = datetime.now()
deleted_users += 1 out = StringIO()
messages.append(f"Successfully deleted {deleted_users} users.") management.call_command("dbbackup", quiet=True, stdout=out)
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL, messages)) self.set_status(
TaskResult(
TaskResultStatus.SUCCESSFUL,
[
f"Successfully finished database backup {naturaltime(start)} {out.getvalue()}",
],
)
)
LOGGER.info("Successfully backed up database.")
except (
IOError,
BotoCoreError,
ClientError,
Boto3Error,
PermissionError,
CommandConnectorError,
ValueError,
) as exc:
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))

View File

@ -8,19 +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 }}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}"> <script src="{% static 'dist/poly.js' %}?v={{ ak_version }}" type="module"></script>
<script src="{% static 'dist/poly.js' %}" 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,14 +4,12 @@
{% 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>
<meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<ak-message-container data-refresh-on-locale="true"></ak-message-container> <ak-message-container></ak-message-container>
<ak-interface-admin data-refresh-on-locale="true"> <ak-interface-admin>
<section class="ak-static-page pf-c-page__main-section pf-m-no-padding-mobile pf-m-xl"> <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" style="height: 100vh;">
<div class="pf-c-empty-state__content"> <div class="pf-c-empty-state__content">

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>

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