Compare commits

..

3 Commits

Author SHA1 Message Date
18778ce0d9 release: 2021.4.6 2021-05-12 14:13:16 +02:00
14973fb595 ci: run apt update before installing dependencies
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-05-12 13:44:15 +02:00
9171bd6d6f stages/invitation: fix wrong serializer used for user model
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
2021-05-12 13:36:19 +02:00
1912 changed files with 81054 additions and 286553 deletions

View File

@ -1,20 +1,44 @@
[bumpversion]
current_version = 2022.10.2
current_version = 2021.4.6
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)
serialize = {major}.{minor}.{patch}
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\-?(?P<release>.*)
serialize =
{major}.{minor}.{patch}-{release}
{major}.{minor}.{patch}
message = release: {new_version}
tag_name = version/{new_version}
[bumpversion:file:pyproject.toml]
[bumpversion:part:release]
optional_value = stable
first_value = beta
values =
alpha
beta
stable
[bumpversion:file:website/docs/installation/docker-compose.md]
[bumpversion:file:website/docs/installation/kubernetes.md]
[bumpversion:file:docker-compose.yml]
[bumpversion:file:schema.yml]
[bumpversion:file:helm/values.yaml]
[bumpversion:file:helm/README.md]
[bumpversion:file:helm/Chart.yaml]
[bumpversion:file:.github/workflows/release.yml]
[bumpversion:file:authentik/__init__.py]
[bumpversion:file:internal/constants/constants.go]
[bumpversion:file:outpost/pkg/version.go]
[bumpversion:file:web/src/common/constants.ts]
[bumpversion:file:web/src/constants.ts]
[bumpversion:file:web/nginx.conf]
[bumpversion:file:website/docs/outposts/manual-deploy-docker-compose.md]
[bumpversion:file:website/docs/outposts/manual-deploy-kubernetes.md]

View File

@ -1,8 +1,6 @@
env
helm
static
htmlcov
*.env.yml
**/node_modules
dist/**
build/**
build_docs/**

View File

@ -27,7 +27,7 @@ If applicable, add screenshots to help explain your problem.
Output of docker-compose logs or kubectl logs respectively
**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]
**Additional context**

View File

@ -1,27 +0,0 @@
---
name: Question
about: Ask a question about a feature or specific configuration
title: ''
labels: question
assignees: ''
---
**Describe your question/**
A clear and concise description of what you're trying to do.
**Relevant infos**
i.e. Version of other software you're using, specifics of your setup
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
Output of docker-compose logs or kubectl logs respectively
**Version and Deployment (please complete the following information):**
- authentik version: [e.g. 2021.8.5]
- Deployment: [e.g. docker-compose, helm]
**Additional context**
Add any other context about the problem here.

View File

@ -1,59 +0,0 @@
name: 'Comment usage instructions on PRs'
description: 'Comment usage instructions on PRs'
inputs:
tag:
description: "Image tag to pull"
required: true
runs:
using: "composite"
steps:
- name: Generate config
id: ev
uses: ./.github/actions/docker-push-variables
- name: Find Comment
uses: peter-evans/find-comment@v2
id: fc
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: 'github-actions[bot]'
body-includes: authentik PR Installation instructions
- name: Create or update comment
uses: peter-evans/create-or-update-comment@v2
with:
comment-id: ${{ steps.fc.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
body: |
authentik PR Installation instructions
<details>
<summary>Instructions for docker-compose</summary>
Add the following block to your `.env` file:
```shell
AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
AUTHENTIK_TAG=${{ inputs.tag }}
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
```
Afterwards, run the upgrade commands from the latest release notes.
</details>
<details>
<summary>Instructions for Kubernetes</summary>
Add the following block to your `values.yml` file:
```yaml
authentik:
outposts:
container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
image:
repository: ghcr.io/goauthentik/dev-server
tag: ${{ inputs.tag }}
```
Afterwards, run the upgrade commands from the latest release notes.
</details>
edit-mode: replace

View File

@ -1,58 +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 }}
version:
description: "version"
value: ${{ steps.ev.outputs.version }}
versionFamily:
description: "versionFamily"
value: ${{ steps.ev.outputs.versionFamily }}
runs:
using: "composite"
steps:
- name: Generate config
id: ev
shell: python
run: |
"""Helper script to get the actual branch name, docker safe"""
import configparser
import os
from time import time
parser = configparser.ConfigParser()
parser.read(".bumpversion.cfg")
branch_name = os.environ["GITHUB_REF"]
if os.environ.get("GITHUB_HEAD_REF", "") != "":
branch_name = os.environ["GITHUB_HEAD_REF"]
should_build = str(os.environ.get("DOCKER_USERNAME", "") != "").lower()
version = parser.get("bumpversion", "current_version")
version_family = ".".join(version.split(".")[:-1])
safe_branch_name = branch_name.replace("refs/heads/", "").replace("/", "-")
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
print("branchName=%s" % branch_name, file=_output)
print("branchNameContainer=%s" % safe_branch_name, file=_output)
print("timestamp=%s" % int(time()), file=_output)
print("sha=%s" % os.environ["GITHUB_SHA"], file=_output)
print("shouldBuild=%s" % should_build, file=_output)
print("version=%s" % version, file=_output)
print("versionFamily=%s" % version_family, file=_output)

View File

@ -1,45 +0,0 @@
name: 'Setup authentik testing environment'
description: 'Setup authentik testing environment'
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
cd web && npm ci
- 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,4 +0,0 @@
keypair
keypairs
hass
warmup

108
.github/dependabot.yml vendored
View File

@ -1,62 +1,50 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
reviewers:
- "@goauthentik/core"
commit-message:
prefix: "ci:"
- package-ecosystem: gomod
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
reviewers:
- "@goauthentik/core"
commit-message:
prefix: "core:"
- package-ecosystem: npm
directory: "/web"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
reviewers:
- "@goauthentik/core"
commit-message:
prefix: "web:"
- package-ecosystem: npm
directory: "/website"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
reviewers:
- "@goauthentik/core"
commit-message:
prefix: "website:"
- package-ecosystem: pip
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
reviewers:
- "@goauthentik/core"
commit-message:
prefix: "core:"
- package-ecosystem: docker
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
reviewers:
- "@goauthentik/core"
commit-message:
prefix: "core:"
- package-ecosystem: gomod
directory: "/outpost"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
assignees:
- BeryJu
- package-ecosystem: npm
directory: "/web"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
assignees:
- BeryJu
- package-ecosystem: npm
directory: "/website"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
assignees:
- BeryJu
- package-ecosystem: pip
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
assignees:
- BeryJu
- package-ecosystem: docker
directory: "/"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
assignees:
- BeryJu
- package-ecosystem: docker
directory: "/outpost"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
assignees:
- BeryJu

View File

@ -1,19 +0,0 @@
<!--
👋 Hello there! Welcome.
Please check the [Contributing guidelines](https://github.com/goauthentik/authentik/blob/main/CONTRIBUTING.md#how-can-i-contribute).
-->
# Details
* **Does this resolve an issue?**
Resolves #
## Changes
### New Features
* Adds feature which does x, y, and z.
### Breaking Changes
* Adds breaking change which causes \<issue\>.
## Additional
Any further notes or comments you want to make.

18
.github/stale.yml vendored
View File

@ -1,18 +0,0 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- pr_wanted
- enhancement
- bug/confirmed
- enhancement/confirmed
- question
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.

16
.github/transifex.yml vendored
View File

@ -1,16 +0,0 @@
git:
filters:
- filter_type: file
# all supported i18n types: https://docs.transifex.com/formats
file_format: PO
source_language: en
source_file: web/src/locales/en.po
# path expression to translation files, must contain <lang> placeholder
translation_files_expression: 'web/src/locales/<lang>.po'
- filter_type: file
# all supported i18n types: https://docs.transifex.com/formats
file_format: PO
source_language: en
source_file: locale/en/LC_MESSAGES/django.po
# path expression to translation files, must contain <lang> placeholder
translation_files_expression: 'locale/<lang>/LC_MESSAGES/django.po'

View File

@ -1,251 +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
with:
flags: unit
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.4.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
with:
flags: integration
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 e2e env (chrome, etc)
run: |
docker-compose -f tests/e2e/docker-compose.yml up -d
- id: cache-web
uses: actions/cache@v3
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
npm run build
- name: run e2e
run: |
poetry run make test-e2e-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
with:
flags: e2e
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 e2e env (chrome, etc)
run: |
docker-compose -f tests/e2e/docker-compose.yml up -d
- id: cache-web
uses: actions/cache@v3
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**') }}
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web/
run: |
npm ci
make -C .. gen-client-ts
npm run build
- name: run e2e
run: |
poetry run make test-e2e-rest
poetry run coverage xml
- name: run testspace
if: ${{ always() }}
run: |
testspace [e2e-rest]unittest.xml --link=codecov
- if: ${{ always() }}
uses: codecov/codecov-action@v3
with:
flags: e2e
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.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- 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 }}
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
platforms: ${{ matrix.arch }}
- name: Comment on PR
if: github.event_name == 'pull_request'
continue-on-error: true
uses: ./.github/actions/comment-pr-instructions
with:
tag: gh-${{ steps.ev.outputs.branchNameContainer }}-${{ steps.ev.outputs.timestamp }}-${{ steps.ev.outputs.sha }}

View File

@ -1,135 +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.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
- 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 }}
VERSION_FAMILY=${{ steps.ev.outputs.versionFamily }}
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.5.1
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,92 +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.5.1
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-ts
- 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.5.1
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-ts
- 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.5.1
with:
node-version: '16'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: |
npm ci
# lit-analyse doesn't understand path rewrites, so make it
# belive it's an actual module
cd node_modules/@goauthentik
ln -s ../../src/ web
- name: Generate API
run: make gen-client-ts
- 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.5.1
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-ts
- 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.5.1
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,171 +0,0 @@
name: authentik-on-release
on:
release:
types: [published, created]
jobs:
build-server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
- name: Docker Login Registry
uses: docker/login-action@v2
with:
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:${{ steps.ev.outputs.version }},
beryju/authentik:${{ steps.ev.outputs.versionFamily }},
beryju/authentik:latest,
ghcr.io/goauthentik/server:${{ steps.ev.outputs.version }},
ghcr.io/goauthentik/server:${{ steps.ev.outputs.versionFamily }},
ghcr.io/goauthentik/server:latest
platforms: linux/amd64,linux/arm64
context: .
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.1.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
- name: Docker Login Registry
uses: docker/login-action@v2
with:
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 }}:${{ steps.ev.outputs.version }},
beryju/authentik-${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }},
beryju/authentik-${{ matrix.type }}:latest,
ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.version }},
ghcr.io/goauthentik/${{ matrix.type }}:${{ steps.ev.outputs.versionFamily }},
ghcr.io/goauthentik/${{ matrix.type }}:latest
file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
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.5.1
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-all
sentry-release:
needs:
- build-server
- build-outpost
- build-outpost-binary
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
- name: Get static files from docker image
run: |
docker pull ghcr.io/goauthentik/server:latest
container=$(docker container create ghcr.io/goauthentik/server:latest)
docker cp ${container}:web/ .
- name: Create a Sentry.io release
uses: getsentry/action-release@v1
continue-on-error: true
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@${{ steps.ev.outputs.version }}
environment: beryjuorg-prod
sourcemaps: './web/dist'
url_prefix: '~/static/dist'

View File

@ -1,41 +0,0 @@
name: authentik-on-tag
on:
push:
tags:
- 'version/*'
jobs:
build:
name: Create Release from Tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Pre-release test
run: |
echo "PG_PASS=$(openssl rand -base64 32)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand -base64 32)" >> .env
docker buildx install
docker build -t testing:latest .
echo "AUTHENTIK_IMAGE=testing" >> .env
echo "AUTHENTIK_TAG=latest" >> .env
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root server test-all
- name: Extract version number
id: get_version
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
- name: Create Release
id: create_release
uses: actions/create-release@v1.1.4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ steps.get_version.outputs.result }}
draft: true
prerelease: false

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

@ -0,0 +1,114 @@
name: authentik-on-release
on:
release:
types: [published, created]
jobs:
# Build
build-server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Docker Login Registry
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
- name: Building Docker Image
run: docker build
--no-cache
-t beryju/authentik:2021.4.6
-t beryju/authentik:latest
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/authentik:2021.4.6
- name: Push Docker Container to Registry (latest)
run: docker push beryju/authentik:latest
build-proxy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- uses: actions/setup-go@v2
with:
go-version: "^1.15"
- name: prepare go api client
run: |
cd outpost
go get -u github.com/go-swagger/go-swagger/cmd/swagger
swagger generate client -f ../swagger.yaml -A authentik -t pkg/
go build -v .
- name: Docker Login Registry
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
- name: Building Docker Image
run: |
cd outpost/
docker build \
--no-cache \
-t beryju/authentik-proxy:2021.4.6 \
-t beryju/authentik-proxy:latest \
-f proxy.Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/authentik-proxy:2021.4.6
- name: Push Docker Container to Registry (latest)
run: docker push beryju/authentik-proxy:latest
build-static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: prepare ts api client
run: |
docker run --rm -v $(pwd):/local openapitools/openapi-generator-cli generate -i /local/swagger.yaml -g typescript-fetch -o /local/web/api --additional-properties=typescriptThreePlus=true,supportsES6=true,npmName=authentik-api,npmVersion=1.0.0
- name: Docker Login Registry
env:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
- name: Building Docker Image
run: |
cd web/
docker build \
--no-cache \
-t beryju/authentik-static:2021.4.6 \
-t beryju/authentik-static:latest \
-f Dockerfile .
- name: Push Docker Container to Registry (versioned)
run: docker push beryju/authentik-static:2021.4.6
- name: Push Docker Container to Registry (latest)
run: docker push beryju/authentik-static:latest
test-release:
needs:
- build-server
- build-static
- build-proxy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- 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 --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
sentry-release:
needs:
- test-release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Create a Sentry.io release
uses: tclindner/sentry-releases-action@v1.2.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: beryjuorg
SENTRY_PROJECT: authentik
SENTRY_URL: https://sentry.beryju.org
with:
tagName: 2021.4.6
environment: beryjuorg-prod

63
.github/workflows/tag.yml vendored Normal file
View File

@ -0,0 +1,63 @@
name: authentik-on-tag
on:
push:
tags:
- 'version/*'
jobs:
build:
name: Create Release from Tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- name: Pre-release test
run: |
sudo apt-get install -y pwgen
echo "AUTHENTIK_TAG=latest" >> .env
echo "PG_PASS=$(pwgen 40 1)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(pwgen 50 1)" >> .env
docker-compose pull -q
docker build \
--no-cache \
-t beryju/authentik:latest \
-f Dockerfile .
docker-compose up --no-start
docker-compose start postgresql redis
docker-compose run -u root --entrypoint /bin/bash server -c "pip install --no-cache -r requirements-dev.txt && ./manage.py test authentik"
- name: Install Helm
run: |
apt update && apt install -y curl
curl https://raw.githubusercontent.com/helm/helm/master/scripts/get-helm-3 | bash
- name: Helm package
run: |
helm dependency update helm/
helm package helm/
mv authentik-*.tgz authentik-chart.tgz
- name: Extract version number
id: get_version
uses: actions/github-script@0.2.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
return context.payload.ref.replace(/\/refs\/tags\/version\//, '');
- name: Create Release
id: create_release
uses: actions/create-release@v1.0.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ steps.get_version.outputs.result }}
draft: true
prerelease: false
- name: Upload packaged Helm Chart
id: upload-release-asset
uses: actions/upload-release-asset@v1.0.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./authentik-chart.tgz
asset_name: authentik-chart.tgz
asset_content_type: application/gzip

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,41 +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
- uses: actions/setup-node@v3.5.1
with:
node-version: '16'
registry-url: 'https://registry.npmjs.org'
- name: Generate API Client
run: make gen-client-ts
- 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: bump API Client version"
title: "web: bump API Client version"
body: "web: bump API Client version"
delete-branch: true
signoff: true

12
.gitignore vendored
View File

@ -66,9 +66,7 @@ coverage.xml
unittest.xml
# Translations
# Have to include binary mo files as they are annoying to compile at build time
# since a full postgres and redis instance are required
# *.mo
*.mo
# Django stuff:
@ -193,12 +191,14 @@ pip-selfcheck.json
# End of https://www.gitignore.io/api/python,django
/static/
local.env.yml
.vscode/
### Helm ###
# Chart dependencies
**/charts/*.tgz
# Selenium Screenshots
selenium_screenshots/
backups/
media/
*mmdb
.idea/
/gen-*/

34
.vscode/settings.json vendored
View File

@ -1,34 +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",
"yaml.customTags": [
"!Find sequence",
"!KeyOf scalar"
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index",
"typescript.tsdk": "./web/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"yaml.schemas": {
"./blueprints/schema.json": "blueprints/**/*.yaml"
}
}

86
.vscode/tasks.json vendored
View File

@ -1,86 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "authentik[core]: format & test",
"command": "poetry",
"args": [
"run",
"make"
],
"group": "build",
},
{
"label": "authentik[core]: run",
"command": "poetry",
"args": [
"run",
"make",
"run",
],
"group": "build",
"presentation": {
"panel": "dedicated",
"group": "running"
},
},
{
"label": "authentik[web]: format",
"command": "make",
"args": ["web"],
"group": "build",
},
{
"label": "authentik[web]: watch",
"command": "make",
"args": ["web-watch"],
"group": "build",
"presentation": {
"panel": "dedicated",
"group": "running"
},
},
{
"label": "authentik: install",
"command": "make",
"args": ["install"],
"group": "build",
},
{
"label": "authentik: i18n-extract",
"command": "poetry",
"args": [
"run",
"make",
"i18n-extract"
],
"group": "build",
},
{
"label": "authentik[website]: format",
"command": "make",
"args": ["website"],
"group": "build",
},
{
"label": "authentik[website]: watch",
"command": "make",
"args": ["website-watch"],
"group": "build",
"presentation": {
"panel": "dedicated",
"group": "running"
},
},
{
"label": "authentik[api]: generate",
"command": "poetry",
"args": [
"run",
"make",
"gen"
],
"group": "build"
},
]
}

View File

@ -1,128 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
hello@goauthentik.io.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@ -1,178 +0,0 @@
# Contributing to authentik
:+1::tada: Thanks for taking the time to contribute! :tada::+1:
The following is a set of guidelines for contributing to authentik and its components, which are hosted in the [goauthentik Organization](https://github.com/goauthentik) on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request.
#### Table Of Contents
[Code of Conduct](#code-of-conduct)
[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)
- [The components](#the-components)
- [authentik's structure](#authentiks-structure)
[How Can I Contribute?](#how-can-i-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
- [Your First Code Contribution](#your-first-code-contribution)
- [Pull Requests](#pull-requests)
[Styleguides](#styleguides)
- [Git Commit Messages](#git-commit-messages)
- [Python Styleguide](#python-styleguide)
- [Documentation Styleguide](#documentation-styleguide)
## 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.
## 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)
## What should I know before I get started?
### The components
authentik consists of a few larger components:
- _authentik_ the actual application server, is described below.
- _outpost-proxy_ is a Go application based on a forked version of oauth2_proxy, which does identity-aware reverse proxying.
- _outpost-ldap_ is a Go LDAP server that uses the _authentik_ application server as its backend
- _web_ is the web frontend, both for administrating and using authentik. It is written in TypeScript using lit-html and the PatternFly CSS Library.
- _website_ is the Website/documentation, which uses docusaurus.
### authentik's structure
authentik is at it's very core a Django project. It consists of many individual django applications. These applications are intended to separate concerns, and they may share code between each other.
These are the current packages:
<a id="authentik-packages"/>
```
authentik
├── admin - Administrative tasks and APIs, no models (Version updates, Metrics, system tasks)
├── api - General API Configuration (Routes, Schema and general API utilities)
├── core - Core authentik functionality, central routes, core Models
├── crypto - Cryptography, currently used to generate and hold Certificates and Private Keys
├── events - Event Log, middleware and signals to generate signals
├── flows - Flows, the FlowPlanner and the FlowExecutor, used for all flows for authentication, authorization, etc
├── lib - Generic library of functions, few dependencies on other packages.
├── managed - Handle managed models and their state.
├── outposts - Configure and deploy outposts on kubernetes and docker.
├── policies - General PolicyEngine
│   ├── dummy - A Dummy policy used for testing
│   ├── event_matcher - Match events based on different criteria
│   ├── expiry - Check when a user's password was last set
│   ├── expression - Execute any arbitrary python code
│   ├── hibp - Check a password against HaveIBeenPwned
│   ├── password - Check a password against several rules
│   └── reputation - Check the user's/client's reputation
├── providers
│   ├── ldap - Provide LDAP access to authentik users/groups using an outpost
│   ├── oauth2 - OIDC-compliant OAuth2 provider
│   ├── proxy - Provides an identity-aware proxy using an outpost
│   └── saml - SAML2 Provider
├── recovery - Generate keys to use in case you lock yourself out
├── root - Root django application, contains global settings and routes
├── sources
│   ├── ldap - Sync LDAP users from OpenLDAP or Active Directory into authentik
│   ├── oauth - OAuth1 and OAuth2 Source
│   ├── plex - Plex source
│   └── saml - SAML2 Source
├── stages
│   ├── authenticator_duo - Configure a DUO authenticator
│   ├── authenticator_static - Configure TOTP backup keys
│   ├── authenticator_totp - Configure a TOTP authenticator
│   ├── authenticator_validate - Validate any authenticator
│   ├── authenticator_webauthn - Configure a WebAuthn authenticator
│   ├── captcha - Make the user pass a captcha
│   ├── consent - Let the user decide if they want to consent to an action
│   ├── deny - Static deny, can be used with policies
│   ├── dummy - Dummy stage to test
│   ├── email - Send the user an email and block execution until they click the link
│   ├── identification - Identify a user with any combination of fields
│   ├── invitation - Invitation system to limit flows to certain users
│   ├── password - Password authentication
│   ├── prompt - Arbitrary prompts
│   ├── user_delete - Delete the currently pending user
│   ├── user_login - Login the currently pending user
│   ├── user_logout - Logout the currently pending user
│   └── user_write - Write any currenetly pending data to the user.
└── tenants - Soft tennancy, configure defaults and branding per domain
```
This django project is running in gunicorn, which spawns multiple workers and threads. Gunicorn is run from a lightweight Go application which reverse-proxies it, handles static files and will eventually gain more functionality as more code is migrated to go.
There are also several background tasks which run in Celery, the root celery application is defined in `authentik.root.celery`.
## How Can I Contribute?
### Reporting Bugs
This section guides you through submitting a bug report for authentik. Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports.
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.
### Suggesting Enhancements
This section guides you through submitting an enhancement suggestion for authentik, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion and find related suggestions.
When you are creating an enhancement suggestion, please fill in [the template](https://github.com/goauthentik/authentik/issues/new?assignees=&labels=enhancement&template=feature_request.md&title=), including the steps that you imagine you would take if the feature you're requesting existed.
### Your First Code Contribution
#### Local development
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)
### Pull Requests
The process described here has several goals:
- Maintain authentik's quality
- Fix problems that are important to users
- Engage the community in working toward the best possible authentik
- Enable a sustainable system for authentik's maintainers to review contributions
Please follow these steps to have your contribution considered by the maintainers:
1. Follow the [styleguides](#styleguides)
2. After you submit your pull request, verify that all [status checks](https://help.github.com/articles/about-status-checks/) are passing <details><summary>What if the status checks are failing?</summary>If a status check is failing, and you believe that the failure is unrelated to your change, please leave a comment on the pull request explaining why you believe the failure is unrelated. A maintainer will re-run the status check for you. If we conclude that the failure was a false positive, then we will open an issue to track that problem with our status check suite.</details>
3. Ensure your Code has tests. While it is not always possible to test every single case, the majority of the code should be tested.
While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted.
## Styleguides
### Git Commit Messages
- Use the format of `<package>: <verb> <description>`
- See [here](#authentik-packages) for `package`
- Example: `providers/saml2: fix parsing of requests`
- Reference issues and pull requests liberally after the first line
### Python Styleguide
All Python code is linted with [black](https://black.readthedocs.io/en/stable/), [PyLint](https://www.pylint.org/) and [isort](https://pycqa.github.io/isort/).
authentik runs on Python 3.9 at the time of writing this.
- Use native type-annotations wherever possible.
- Add meaningful docstrings when possible.
- Ensure any database migrations work properly from the last stable version (this is checked via CI)
- If your code changes central functions, make sure nothing else is broken.
### Documentation Styleguide
- Use [MDX](https://mdxjs.com/) whenever appropriate.

View File

@ -1,101 +1,52 @@
# Stage 1: Build website
FROM --platform=${BUILDPLATFORM} docker.io/node:18 as website-builder
FROM python:3.9-slim-buster as locker
COPY ./website /work/website/
COPY ./blueprints /work/blueprints/
COPY ./SECURITY.md /work/
COPY ./Pipfile /app/
COPY ./Pipfile.lock /app/
ENV NODE_ENV=production
WORKDIR /work/website
RUN npm ci && npm run build-docs-only
WORKDIR /app/
# Stage 2: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/node:18 as web-builder
RUN pip install pipenv && \
pipenv lock -r > requirements.txt && \
pipenv lock -rd > requirements-dev.txt
COPY ./web /work/web/
COPY ./website /work/website/
ENV NODE_ENV=production
WORKDIR /work/web
RUN npm ci && npm run build
# Stage 3: Poetry to requirements.txt export
FROM docker.io/python:3.10.7-slim-bullseye AS poetry-locker
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.19.2-bullseye AS go-builder
WORKDIR /work
COPY --from=web-builder /work/web/robots.txt /work/web/robots.txt
COPY --from=web-builder /work/web/security.txt /work/web/security.txt
COPY ./cmd /work/cmd
COPY ./web/static.go /work/web/static.go
COPY ./internal /work/internal
COPY ./go.mod /work/go.mod
COPY ./go.sum /work/go.sum
RUN go build -o /work/authentik ./cmd/server/
# Stage 5: Run
FROM docker.io/python:3.10.7-slim-bullseye AS final-image
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
FROM python:3.9-slim-buster
WORKDIR /
COPY --from=locker /app/requirements.txt /
COPY --from=locker /app/requirements-dev.txt /
ARG 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 && \
# Required for installing pip packages
apt-get install -y --no-install-recommends build-essential pkg-config libxmlsec1-dev && \
# Required for runtime
apt-get install -y --no-install-recommends libxmlsec1-openssl libmaxminddb0 && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends curl runit && \
pip install --no-cache-dir -r /requirements.txt && \
apt-get remove --purge -y build-essential pkg-config libxmlsec1-dev && \
apt-get autoremove --purge -y && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg && \
curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
echo "deb http://apt.postgresql.org/pub/repos/apt buster-pgdg main" > /etc/apt/sources.list.d/pgdg.list && \
apt-get update && \
apt-get install -y --no-install-recommends postgresql-client-12 postgresql-client-11 build-essential libxmlsec1-dev pkg-config libmaxminddb0 && \
apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
pip install -r /requirements.txt --no-cache-dir && \
apt-get remove --purge -y build-essential && \
apt-get autoremove --purge -y && \
# This is quite hacky, but docker has no guaranteed Group ID
# we could instead check for the GID of the socket and add the user dynamically,
# but then we have to drop permmissions later
groupadd -g 998 docker_998 && \
groupadd -g 999 docker_999 && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
mkdir -p /certs /media /blueprints && \
mkdir -p /authentik/.ssh && \
chown authentik:authentik /certs /media /authentik/.ssh
usermod -a -G docker_998 authentik && \
usermod -a -G docker_999 authentik && \
mkdir /backups && \
chown authentik:authentik /backups
COPY ./authentik/ /authentik
COPY ./pyproject.toml /
COPY ./xml /xml
COPY ./tests /tests
COPY ./manage.py /
COPY ./blueprints /blueprints
COPY ./lifecycle/ /lifecycle
COPY --from=go-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 1000
USER authentik
STOPSIGNAL SIGINT
ENV TMPDIR /dev/shm/
ENV PYTHONUNBUFFERED 1
ENV PATH "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/lifecycle"
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "/lifecycle/ak", "healthcheck" ]
ENTRYPOINT [ "/usr/local/bin/dumb-init", "--", "/lifecycle/ak" ]
ENV PYTHONUBUFFERED 1
ENTRYPOINT [ "/lifecycle/bootstrap.sh" ]

212
Makefile
View File

@ -1,203 +1,37 @@
.SHELLFLAGS += -x -e
PWD = $(shell pwd)
UID = $(shell id -u)
GID = $(shell id -g)
NPM_VERSION = $(shell python -m scripts.npm_version)
all: lint-fix lint test gen web
all: lint-fix lint coverage gen
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:
coverage run manage.py test tests/e2e/test_provider*
test-e2e:
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:
coverage run manage.py test --keepdb authentik
coverage:
coverage run manage.py test -v 3 authentik
coverage html
coverage report
lint-fix:
isort authentik tests scripts lifecycle
black authentik tests scripts lifecycle
codespell -I .github/codespell-words.txt -S 'web/src/locales/**' -w \
authentik \
internal \
cmd \
web/src \
website/src \
website/docs \
website/developer-docs
isort authentik tests lifecycle
black authentik tests lifecycle
lint:
pylint authentik tests lifecycle
pyright authentik tests lifecycle
bandit -r authentik tests lifecycle -x node_modules
golangci-lint run -v
pylint authentik tests lifecycle
migrate:
python -m lifecycle.migrate
gen: coverage
./manage.py generate_swagger -o swagger.yaml -f yaml
run:
go run -v ./cmd/server/
local-stack:
export AUTHENTIK_TAG=testing
docker build -t beryju/authentik:testng .
docker-compose up -d
docker-compose run --rm server migrate
i18n-extract: i18n-extract-core web-extract
i18n-extract-core:
ak makemessages --ignore web --ignore internal --ignore web --ignore web-api --ignore website -l en
#########################
## API Schema
#########################
gen-build:
AUTHENTIK_DEBUG=true ak make_blueprint_schema > blueprints/schema.json
AUTHENTIK_DEBUG=true ak spectacular --file schema.yml
gen-diff:
git show $(shell git tag -l | tail -n 1):schema.yml > old_schema.yml
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-diff:2.1.0-beta.3 \
--markdown /local/diff.md \
/local/old_schema.yml /local/schema.yml
rm old_schema.yml
gen-clean:
rm -rf web/api/src/
rm -rf api/
gen-client-ts:
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v6.0.0 generate \
-i /local/schema.yml \
-g typescript-fetch \
-o /local/gen-ts-api \
-c /local/scripts/api-ts-config.yaml \
--additional-properties=npmVersion=${NPM_VERSION} \
--git-repo-id authentik \
--git-user-id goauthentik
mkdir -p web/node_modules/@goauthentik/api
cd gen-ts-api && npm i
\cp -rfv gen-ts-api/* web/node_modules/@goauthentik/api
gen-client-go:
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 \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v6.0.0 generate \
-i /local/schema.yml \
-g go \
-o /local/gen-go-api \
-c /local/config.yaml
go mod edit -replace goauthentik.io/api/v3=./gen-go-api
rm -rf config.yaml ./templates/
gen-dev-config:
python -m scripts.generate_config
gen: gen-build gen-clean gen-client-ts
#########################
## Web
#########################
web-build: web-install
cd web && npm run build
web: web-lint-fix web-lint
web-install:
cd web && npm ci
web-watch:
rm -rf web/dist/
mkdir web/dist/
touch web/dist/.gitkeep
cd web && npm run watch
web-lint-fix:
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-build
website-install:
cd website && npm ci
website-lint-fix:
cd website && npm run prettier
website-build:
cd website && npm run build
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
PY_SOURCES=authentik tests lifecycle
ci--meta-debug:
python -V
node --version
ci-pylint: ci--meta-debug
pylint $(PY_SOURCES)
ci-black: ci--meta-debug
black --check $(PY_SOURCES)
ci-isort: ci--meta-debug
isort --check $(PY_SOURCES)
ci-bandit: ci--meta-debug
bandit -r $(PY_SOURCES)
ci-pyright: ci--meta-debug
./web/node_modules/.bin/pyright $(PY_SOURCES)
ci-pending-migrations: ci--meta-debug
ak makemigrations --check
install: web-install website-install
poetry install
dev-reset:
dropdb -U postgres -h localhost authentik
createdb -U postgres -h localhost authentik
redis-cli -n 0 flushall
redis-cli -n 1 flushall
redis-cli -n 2 flushall
redis-cli -n 3 flushall
make migrate
build-static:
docker-compose -f scripts/ci.docker-compose.yml up -d
docker build -t beryju/authentik-static -f static.Dockerfile --network=scripts_default .
docker-compose -f scripts/ci.docker-compose.yml down -v

61
Pipfile Normal file
View File

@ -0,0 +1,61 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[packages]
boto3 = "*"
celery = "*"
channels = "*"
channels-redis = "*"
dacite = "*"
defusedxml = "*"
django = "*"
django-dbbackup = "*"
django-filter = "*"
django-guardian = "*"
django-model-utils = "*"
django-otp = "*"
django-prometheus = "*"
django-redis = "*"
django-storages = "*"
djangorestframework = "*"
djangorestframework-guardian = "*"
docker = "*"
drf_yasg = "*"
facebook-sdk = "*"
geoip2 = "*"
gunicorn = "*"
kubernetes = "*"
ldap3 = "*"
lxml = ">=4.6.3"
packaging = "*"
psycopg2-binary = "*"
pycryptodome = "*"
pyjwkest = "*"
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 = "*"
[requires]
python_version = "3.9"
[dev-packages]
bandit = "*"
black = "==20.8b1"
bump2version = "*"
colorama = "*"
coverage = "*"
pylint = "*"
pylint-django = "*"
pytest = "*"
pytest-django = "*"
selenium = "*"

1879
Pipfile.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,15 +4,13 @@
---
[![Join Discord](https://img.shields.io/discord/809154715984199690?label=Discord&style=for-the-badge)](https://goauthentik.io/discord)
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/goauthentik/authentik/authentik-ci-main?label=core%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-main.yml)
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/goauthentik/authentik/authentik-ci-outpost?label=outpost%20build&style=for-the-badge)](https://github.com/goauthentik/authentik/actions/workflows/ci-outpost.yml)
[![GitHub Workflow Status](https://img.shields.io/github/workflow/status/goauthentik/authentik/authentik-ci-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=for-the-badge)](https://codecov.io/gh/goauthentik/authentik)
[![Testspace tests](https://img.shields.io/testspace/total/goauthentik/goauthentik:authentik/main?style=for-the-badge)](https://goauthentik.testspace.com/)
![Docker pulls](https://img.shields.io/docker/pulls/beryju/authentik.svg?style=for-the-badge)
![Latest version](https://img.shields.io/docker/v/beryju/authentik?sort=semver&style=for-the-badge)
[![](https://img.shields.io/badge/Help%20translate-transifex-blue?style=for-the-badge)](https://www.transifex.com/beryjuorg/authentik/)
[![](https://img.shields.io/discord/809154715984199690?label=Discord&style=flat-square)](https://discord.gg/jg33eMhnj6)
[![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)
[![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)
[![Code Coverage](https://img.shields.io/codecov/c/gh/goauthentik/authentik?style=flat-square)](https://codecov.io/gh/goauthentik/authentik)
![Docker pulls](https://img.shields.io/docker/pulls/beryju/authentik.svg?style=flat-square)
![Latest version](https://img.shields.io/docker/v/beryju/authentik?sort=semver&style=flat-square)
![LGTM Grade](https://img.shields.io/lgtm/grade/python/github/goauthentik/authentik?style=flat-square)
## What is authentik?
@ -20,41 +18,21 @@ authentik is an open-source Identity Provider focused on flexibility and versati
## 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 in the `helm/` directory. This is documented [here](https://goauthentik.io/docs/installation/kubernetes/)
## Screenshots
| Light | Dark |
| ------------------------------------------------------ | ----------------------------------------------------- |
| ![](https://goauthentik.io/img/screen_apps_light.jpg) | ![](https://goauthentik.io/img/screen_apps_dark.jpg) |
| ![](https://goauthentik.io/img/screen_admin_light.jpg) | ![](https://goauthentik.io/img/screen_admin_dark.jpg) |
Light | Dark
--- | ---
![](https://goauthentik.io/img/screen_apps_light.jpg) | ![](https://goauthentik.io/img/screen_apps_dark.jpg)
![](https://goauthentik.io/img/screen_admin_light.jpg) | ![](https://goauthentik.io/img/screen_admin_dark.jpg)
## Development
See [Development Documentation](https://goauthentik.io/developer-docs/?utm_source=github)
See [Development Documentation](https://goauthentik.io/developer-docs/)
## Security
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

@ -1,44 +1,12 @@
Authentik takes security very seriously. We follow the rules of [responsible disclosure](https://en.wikipedia.org/wiki/Responsible_disclosure), and we urge our community to do so as well, instead of reporting vulnerabilities publicly. This allows us to patch the issue quickly, announce it's existence and release the fixed version.
# Security Policy
## Supported Versions
(.x being the latest patch release for each version)
| Version | Supported |
| --------- | ------------------ |
| 2022.10.x | :white_check_mark: |
| 2022.11.x | :white_check_mark: |
| Version | Supported |
| ---------- | ------------------ |
| 2021.3.x | :white_check_mark: |
| 2021.4.x | :white_check_mark: |
## Reporting a Vulnerability
To report a vulnerability, send an email to [security@goauthentik.io](mailto:security@goauthentik.io). Be sure to include relevant information like which version you've found the issue in, instructions on how to reproduce the issue, and anything else that might make it easier for us to find the bug.
## Criticality levels
### High
- Authorization bypass
- Circumvention of policies
### Moderate
- Denial-of-Service attacks
### Low
- Unvalidated redirects
- Issues requiring uncommon setups
## Disclosure process
1. Issue is reported via Email as listed above.
2. The authentik Security team will try to reproduce the issue and ask for more information if required.
3. A criticality level is assigned.
4. A fix is created, and if possible tested by the issue reporter.
5. The fix is backported to other supported versions, and if possible a workaround for other versions is created.
6. An announcement is sent out with a fixed release date and criticality level of the issue. The announcement will be sent at least 24 hours before the release of the fix
7. The fixed version is released for the supported versions.
## Getting security notifications
To get security notifications, join the [discord](https://goauthentik.io/discord) server. In the future there will be a mailing list too.
To report a vulnerability, send an email to [security@beryju.org](mailto:security@beryju.org)

View File

@ -1,22 +1,3 @@
"""authentik"""
from os import environ
from typing import Optional
__version__ = "2022.10.2"
__version__ = "2021.4.6"
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,5 +1,5 @@
"""Meta API"""
from drf_spectacular.utils import extend_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.fields import CharField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
@ -18,13 +18,13 @@ class AppSerializer(PassiveSerializer):
class AppsViewSet(ViewSet):
"""Read-only view list all installed apps"""
"""Read-only view set list all installed apps"""
permission_classes = [IsAdminUser]
@extend_schema(responses={200: AppSerializer(many=True)})
@swagger_auto_schema(responses={200: AppSerializer(many=True)})
def list(self, request: Request) -> Response:
"""Read-only view list all installed apps"""
"""List current messages and pass into Serializer"""
data = []
for app in sorted(get_apps(), key=lambda app: app.name):
data.append({"name": app.name, "label": app.verbose_name})

View File

@ -1,14 +1,48 @@
"""authentik administration metrics"""
from drf_spectacular.utils import extend_schema, extend_schema_field
from guardian.shortcuts import get_objects_for_user
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_yasg.utils import swagger_auto_schema, swagger_serializer_method
from rest_framework.fields import IntegerField, SerializerMethodField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSet
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):
@ -23,47 +57,25 @@ class LoginMetricsSerializer(PassiveSerializer):
logins_per_1h = SerializerMethodField()
logins_failed_per_1h = SerializerMethodField()
authorizations_per_1h = SerializerMethodField()
@extend_schema_field(CoordinateSerializer(many=True))
@swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
def get_logins_per_1h(self, _):
"""Get successful logins per hour for the last 24 hours"""
user = self.context["user"]
return (
get_objects_for_user(user, "authentik_events.view_event")
.filter(action=EventAction.LOGIN)
.get_events_per_hour()
)
return get_events_per_1h(action=EventAction.LOGIN)
@extend_schema_field(CoordinateSerializer(many=True))
@swagger_serializer_method(serializer_or_field=CoordinateSerializer(many=True))
def get_logins_failed_per_1h(self, _):
"""Get failed logins per hour for the last 24 hours"""
user = self.context["user"]
return (
get_objects_for_user(user, "authentik_events.view_event")
.filter(action=EventAction.LOGIN_FAILED)
.get_events_per_hour()
)
@extend_schema_field(CoordinateSerializer(many=True))
def get_authorizations_per_1h(self, _):
"""Get successful authorizations per hour for the last 24 hours"""
user = self.context["user"]
return (
get_objects_for_user(user, "authentik_events.view_event")
.filter(action=EventAction.AUTHORIZE_APPLICATION)
.get_events_per_hour()
)
return get_events_per_1h(action=EventAction.LOGIN_FAILED)
class AdministrationMetricsViewSet(APIView):
class AdministrationMetricsViewSet(ViewSet):
"""Login Metrics per 1h"""
permission_classes = [IsAdminUser]
@extend_schema(responses={200: LoginMetricsSerializer(many=False)})
def get(self, request: Request) -> Response:
@swagger_auto_schema(responses={200: LoginMetricsSerializer(many=False)})
def list(self, request: Request) -> Response:
"""Login Metrics per 1h"""
serializer = LoginMetricsSerializer(True)
serializer.context["user"] = request.user
return Response(serializer.data)

View File

@ -1,104 +0,0 @@
"""authentik administration overview"""
import os
import platform
from datetime import datetime
from sys import version as python_version
from typing import TypedDict
from django.utils.timezone import now
from drf_spectacular.utils import extend_schema
from gunicorn import version_info as gunicorn_version
from kubernetes.config.incluster_config import SERVICE_HOST_ENV_NAME
from rest_framework.fields import SerializerMethodField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
class RuntimeDict(TypedDict):
"""Runtime information"""
python_version: str
gunicorn_version: str
environment: str
architecture: str
platform: str
uname: str
class SystemSerializer(PassiveSerializer):
"""Get system information."""
env = SerializerMethodField()
http_headers = SerializerMethodField()
http_host = SerializerMethodField()
http_is_secure = SerializerMethodField()
runtime = SerializerMethodField()
tenant = 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]:
"""Get HTTP Request headers"""
headers = {}
for key, value in request.META.items():
if not isinstance(value, str):
continue
headers[key] = value
return headers
def get_http_host(self, request: Request) -> str:
"""Get HTTP host"""
return request._request.get_host()
def get_http_is_secure(self, request: Request) -> bool:
"""Get HTTP Secure flag"""
return request._request.is_secure()
def get_runtime(self, request: Request) -> RuntimeDict:
"""Get versions"""
return {
"python_version": python_version,
"gunicorn_version": ".".join(str(x) for x in gunicorn_version),
"environment": "kubernetes" if SERVICE_HOST_ENV_NAME in os.environ else "compose",
"architecture": platform.machine(),
"platform": platform.platform(),
"uname": " ".join(platform.uname()),
}
def get_tenant(self, request: Request) -> str:
"""Currently active tenant"""
return str(request._request.tenant)
def get_server_time(self, request: Request) -> datetime:
"""Current server time"""
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):
"""Get system information."""
permission_classes = [IsAdminUser]
pagination_class = None
filter_backends = []
@extend_schema(responses={200: SystemSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Get system information."""
return Response(SystemSerializer(request).data)

View File

@ -4,28 +4,24 @@ from importlib import import_module
from django.contrib import messages
from django.http.response import Http404
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import CharField, ChoiceField, DateTimeField, ListField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from structlog.stdlib import get_logger
from authentik.core.api.utils import PassiveSerializer
from authentik.events.monitored_tasks import TaskInfo, TaskResultStatus
LOGGER = get_logger()
class TaskSerializer(PassiveSerializer):
"""Serialize TaskInfo and TaskResult"""
task_name = CharField()
task_description = CharField()
task_finish_timestamp = DateTimeField(source="finish_time")
task_finish_timestamp = DateTimeField(source="finish_timestamp")
status = ChoiceField(
source="result.status.name",
@ -33,40 +29,14 @@ class TaskSerializer(PassiveSerializer):
)
messages = ListField(source="result.messages")
def to_representation(self, instance):
"""When a new version of authentik adds fields to TaskInfo,
the API will fail with an AttributeError, as the classes
are pickled in cache. In that case, just delete the info"""
try:
return super().to_representation(instance)
except AttributeError: # pragma: no cover
if isinstance(self.instance, list):
for inst in self.instance:
inst.delete()
else:
self.instance.delete()
return {}
class TaskViewSet(ViewSet):
"""Read-only view set that returns all background tasks"""
permission_classes = [IsAdminUser]
serializer_class = TaskSerializer
@extend_schema(
responses={
200: TaskSerializer(many=False),
404: OpenApiResponse(description="Task not found"),
},
parameters=[
OpenApiParameter(
"id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
required=True,
),
],
@swagger_auto_schema(
responses={200: TaskSerializer(many=False), 404: "Task not found"}
)
# pylint: disable=invalid-name
def retrieve(self, request: Request, pk=None) -> Response:
@ -76,27 +46,18 @@ class TaskViewSet(ViewSet):
raise Http404
return Response(TaskSerializer(task, many=False).data)
@extend_schema(responses={200: TaskSerializer(many=True)})
@swagger_auto_schema(responses={200: TaskSerializer(many=True)})
def list(self, request: Request) -> Response:
"""List system tasks"""
tasks = sorted(TaskInfo.all().values(), key=lambda task: task.task_name)
return Response(TaskSerializer(tasks, many=True).data)
@extend_schema(
request=OpenApiTypes.NONE,
@swagger_auto_schema(
responses={
204: OpenApiResponse(description="Task retried successfully"),
404: OpenApiResponse(description="Task not found"),
500: OpenApiResponse(description="Failed to retry task"),
},
parameters=[
OpenApiParameter(
"id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
required=True,
),
],
204: "Task retried successfully",
404: "Task not found",
500: "Failed to retry task",
}
)
@action(detail=True, methods=["post"])
# pylint: disable=invalid-name
@ -108,15 +69,16 @@ class TaskViewSet(ViewSet):
try:
task_module = import_module(task.task_call_module)
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)
messages.success(
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)
except (ImportError, AttributeError): # pragma: no cover
LOGGER.warning("Failed to run task, remove state", task=task)
except ImportError: # pragma: no cover
# if we get an import error, the module path has probably changed
task.delete()
return Response(status=500)

View File

@ -1,14 +1,17 @@
"""authentik administration overview"""
from os import environ
from django.core.cache import cache
from drf_spectacular.utils import extend_schema
from drf_yasg.utils import swagger_auto_schema
from packaging.version import parse
from rest_framework.fields import SerializerMethodField
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
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.core.api.utils import PassiveSerializer
@ -23,7 +26,7 @@ class VersionSerializer(PassiveSerializer):
def get_build_hash(self, _) -> str:
"""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:
"""Get current version"""
@ -39,17 +42,22 @@ class VersionSerializer(PassiveSerializer):
def get_outdated(self, instance) -> bool:
"""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 VersionViewSet(ListModelMixin, GenericViewSet):
"""Get running and latest version."""
permission_classes = [IsAuthenticated]
pagination_class = None
filter_backends = []
@extend_schema(responses={200: VersionSerializer(many=False)})
def get(self, request: Request) -> Response:
def get_queryset(self): # pragma: no cover
return None
@swagger_auto_schema(responses={200: VersionSerializer(many=False)})
def list(self, request: Request) -> Response:
"""Get running and latest version."""
return Response(VersionSerializer(True).data)

View File

@ -1,25 +1,25 @@
"""authentik administration overview"""
from django.conf import settings
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.fields import IntegerField
from rest_framework.mixins import ListModelMixin
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.serializers import Serializer
from rest_framework.viewsets import GenericViewSet
from authentik.root.celery import CELERY_APP
class WorkerView(APIView):
class WorkerViewSet(ListModelMixin, GenericViewSet):
"""Get currently connected worker count."""
serializer_class = Serializer
permission_classes = [IsAdminUser]
@extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()}))
def get(self, request: Request) -> Response:
def get_queryset(self): # pragma: no cover
return None
def list(self, request: Request) -> Response:
"""Get currently connected worker count."""
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(
{"pagination": {"count": len(CELERY_APP.control.ping(timeout=0.5))}}
)

View File

@ -1,20 +1,10 @@
"""authentik admin app config"""
from prometheus_client import Gauge, Info
from authentik.blueprints.apps import ManagedAppConfig
PROM_INFO = Info("authentik_version", "Currently running authentik version")
GAUGE_WORKERS = Gauge("authentik_admin_workers", "Currently connected workers")
from django.apps import AppConfig
class AuthentikAdminConfig(ManagedAppConfig):
class AuthentikAdminConfig(AppConfig):
"""authentik admin app config"""
name = "authentik.admin"
label = "authentik_admin"
verbose_name = "authentik Admin"
default = True
def reconcile_load_admin_signals(self):
"""Load admin signals"""
self.import_module("authentik.admin.signals")

View File

@ -1,12 +1,10 @@
"""authentik admin settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"admin_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"},
}
}

View File

@ -1,23 +0,0 @@
"""admin signals"""
from django.dispatch import receiver
from authentik.admin.api.tasks import TaskInfo
from authentik.admin.apps 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

@ -3,22 +3,13 @@ import re
from django.core.cache import cache
from django.core.validators import URLValidator
from django.db import DatabaseError, InternalError, ProgrammingError
from packaging.version import parse
from requests import RequestException
from requests import RequestException, get
from structlog.stdlib import get_logger
from authentik import __version__, get_build_hash
from authentik.admin.apps import PROM_INFO
from authentik.events.models import Event, EventAction, Notification
from authentik.events.monitored_tasks import (
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_http_session
from authentik import __version__
from authentik.events.models import Event, EventAction
from authentik.events.monitored_tasks import MonitoredTask, TaskResult, TaskResultStatus
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@ -26,57 +17,29 @@ VERSION_CACHE_KEY = "authentik_latest_version"
VERSION_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours
# Chop of the first ^ because we want to search the entire string
URL_FINDER = URLValidator.regex.pattern[1:]
LOCAL_VERSION = parse(__version__)
def _set_prom_info():
"""Set prometheus info for version"""
PROM_INFO.info(
{
"version": __version__,
"latest": cache.get(VERSION_CACHE_KEY, ""),
"build_hash": get_build_hash(),
}
)
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError),
)
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)
@prefill_task
def update_latest_version(self: MonitoredTask):
"""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:
response = get_http_session().get(
"https://version.goauthentik.io/version.json",
response = get(
"https://api.github.com/repos/goauthentik/authentik/releases/latest"
)
response.raise_for_status()
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)
self.set_status(
TaskResult(TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"])
TaskResult(
TaskResultStatus.SUCCESSFUL, ["Successfully updated latest Version"]
)
)
_set_prom_info()
# Check if upstream version is newer than what we're running,
# 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
if Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE,
@ -84,12 +47,9 @@ def update_latest_version(self: MonitoredTask):
).exists():
return
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.new(EventAction.UPDATE_AVAILABLE, **event_dict).save()
except (RequestException, IndexError) as exc:
cache.set(VERSION_CACHE_KEY, "0.0.0", VERSION_CACHE_TIMEOUT)
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
_set_prom_info()

View File

@ -5,10 +5,8 @@ from django.test import TestCase
from django.urls import reverse
from authentik import __version__
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Group, User
from authentik.core.tasks import clean_expired_models
from authentik.events.monitored_tasks import TaskResultStatus
class TestAdminAPI(TestCase):
@ -28,25 +26,9 @@ class TestAdminAPI(TestCase):
response = self.client.get(reverse("authentik_api:admin_system_tasks-list"))
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertTrue(any(task["task_name"] == "clean_expired_models" for task in body))
def test_tasks_single(self):
"""Test Task API (read single)"""
clean_expired_models.delay()
response = self.client.get(
reverse(
"authentik_api:admin_system_tasks-detail",
kwargs={"pk": "clean_expired_models"},
)
self.assertTrue(
any(task["task_name"] == "clean_expired_models" for task in body)
)
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(body["status"], TaskResultStatus.SUCCESSFUL.name)
self.assertEqual(body["task_name"], "clean_expired_models")
response = self.client.get(
reverse("authentik_api:admin_system_tasks-detail", kwargs={"pk": "qwerqwer"})
)
self.assertEqual(response.status_code, 404)
def test_tasks_retry(self):
"""Test Task API (retry)"""
@ -71,30 +53,24 @@ class TestAdminAPI(TestCase):
def test_version(self):
"""Test Version API"""
response = self.client.get(reverse("authentik_api:admin_version"))
response = self.client.get(reverse("authentik_api:admin_version-list"))
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(body["version_current"], __version__)
def test_workers(self):
"""Test Workers API"""
response = self.client.get(reverse("authentik_api:admin_workers"))
response = self.client.get(reverse("authentik_api:admin_workers-list"))
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(body["count"], 0)
self.assertEqual(body["pagination"]["count"], 0)
def test_metrics(self):
"""Test metrics API"""
response = self.client.get(reverse("authentik_api:admin_metrics"))
response = self.client.get(reverse("authentik_api:admin_metrics-list"))
self.assertEqual(response.status_code, 200)
def test_apps(self):
"""Test apps API"""
response = self.client.get(reverse("authentik_api:apps-list"))
self.assertEqual(response.status_code, 200)
@reconcile_app("authentik_outposts")
def test_system(self):
"""Test system API"""
response = self.client.get(reverse("authentik_api:admin_system"))
self.assertEqual(response.status_code, 200)

View File

@ -1,83 +1,81 @@
"""test admin tasks"""
import json
from dataclasses import dataclass
from unittest.mock import Mock, patch
from django.core.cache import cache
from django.test import TestCase
from requests_mock import Mocker
from requests.exceptions import RequestException
from authentik.admin.tasks import (
VERSION_CACHE_KEY,
clear_update_notifications,
update_latest_version,
)
from authentik.admin.tasks import VERSION_CACHE_KEY, update_latest_version
from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG
RESPONSE_VALID = {
"$schema": "https://version.goauthentik.io/schema.json",
"stable": {
"version": "99999999.9999999",
"changelog": "See https://goauthentik.io/test",
"reason": "bugfix",
},
}
@dataclass
class MockResponse:
"""Mock class to emulate the methods of requests's Response we need"""
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):
"""test admin tasks"""
@patch("authentik.admin.tasks.get", REQUEST_MOCK_VALID)
def test_version_valid_response(self):
"""Test Update checker with valid response"""
with Mocker() as mocker, CONFIG.patch("disable_update_check", False):
mocker.get("https://version.goauthentik.io/version.json", json=RESPONSE_VALID)
update_latest_version.delay().get()
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
self.assertTrue(
update_latest_version.delay().get()
self.assertEqual(cache.get(VERSION_CACHE_KEY), "99999999.9999999")
self.assertTrue(
Event.objects.filter(
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(
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(
action=EventAction.UPDATE_AVAILABLE,
context__new_version="99999999.9999999",
context__message="Changelog: https://goauthentik.io/test",
)
),
1,
)
)
),
1,
)
@patch("authentik.admin.tasks.get", REQUEST_MOCK_INVALID)
def test_version_error(self):
"""Test Update checker with invalid response"""
with Mocker() as mocker:
mocker.get("https://version.goauthentik.io/version.json", status_code=400)
update_latest_version.delay().get()
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()
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="1.1"
action=EventAction.UPDATE_AVAILABLE, context__new_version="0.0.0"
).exists()
)

View File

@ -10,25 +10,3 @@ class AuthentikAPIConfig(AppConfig):
label = "authentik_api"
mountpoint = "api/"
verbose_name = "authentik API"
def ready(self) -> None:
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from authentik.api.authentication import TokenAuthentication
# Class is defined here as it needs to be created early enough that drf-spectacular will
# find it, but also won't cause any import issues
# pylint: disable=unused-variable
class TokenSchema(OpenApiAuthenticationExtension):
"""Auth schema"""
target_class = TokenAuthentication
name = "authentik"
def get_security_definition(self, auto_schema):
"""Auth schema"""
return {
"type": "apiKey",
"in": "header",
"name": "Authorization",
}

65
authentik/api/auth.py Normal file
View File

@ -0,0 +1,65 @@
"""API Authentication"""
from base64 import b64decode, b64encode
from binascii import Error
from typing import Any, Optional, Union
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.core.models import Token, TokenIntents, User
LOGGER = get_logger()
# pylint: disable=too-many-return-statements
def token_from_header(raw_header: bytes) -> Optional[Token]:
"""raw_header in the Format of `Bearer dGVzdDp0ZXN0`"""
auth_credentials = raw_header.decode()
if auth_credentials == "":
return None
# Legacy, accept basic auth thats fully encoded (2021.3 outposts)
if " " not in auth_credentials:
try:
plain = b64decode(auth_credentials.encode()).decode()
auth_type, body = plain.split()
auth_credentials = f"{auth_type} {b64encode(body.encode()).decode()}"
except (UnicodeDecodeError, Error):
raise AuthenticationFailed("Malformed header")
auth_type, auth_credentials = auth_credentials.split()
if auth_type.lower() not in ["basic", "bearer"]:
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
raise AuthenticationFailed("Unsupported authentication type")
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")
tokens = Token.filter_not_expired(key=password, intent=TokenIntents.INTENT_API)
if not tokens.exists():
raise AuthenticationFailed("Token invalid/expired")
return tokens.first()
class AuthentikTokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""
def authenticate(self, request: Request) -> Union[tuple[User, Any], None]:
"""Token-based authentication using HTTP Bearer authentication"""
auth = get_authorization_header(request)
token = token_from_header(auth)
# None is only returned when the header isn't set.
if not token:
return None
return (token.user, None)

View File

@ -1,91 +0,0 @@
"""API Authentication"""
from typing import Any, Optional
from django.conf import settings
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import Token, TokenIntents, User
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
from authentik.providers.oauth2.models import RefreshToken
LOGGER = get_logger()
def validate_auth(header: bytes) -> Optional[str]:
"""Validate that the header is in a correct format,
returns type and credentials"""
auth_credentials = header.decode().strip()
if auth_credentials == "" or " " not in auth_credentials:
return None
auth_type, _, auth_credentials = auth_credentials.partition(" ")
if auth_type.lower() != "bearer":
LOGGER.debug("Unsupported authentication type, denying", type=auth_type.lower())
raise AuthenticationFailed("Unsupported authentication type")
if auth_credentials == "": # nosec # noqa
raise AuthenticationFailed("Malformed header")
return auth_credentials
def bearer_auth(raw_header: bytes) -> Optional[User]:
"""raw_header in the Format of `Bearer ....`"""
auth_credentials = validate_auth(raw_header)
if not auth_credentials:
return None
# first, check traditional tokens
key_token = Token.filter_not_expired(
key=auth_credentials, intent=TokenIntents.INTENT_API
).first()
if key_token:
CTX_AUTH_VIA.set("api_token")
return key_token.user
# then try to auth via JWT
jwt_token = RefreshToken.filter_not_expired(
refresh_token=auth_credentials, _scope__icontains=SCOPE_AUTHENTIK_API
).first()
if jwt_token:
# Double-check scopes, since they are saved in a single string
# we want to check the parsed version too
if SCOPE_AUTHENTIK_API not in jwt_token.scope:
raise AuthenticationFailed("Token invalid/expired")
CTX_AUTH_VIA.set("jwt")
return jwt_token.user
# then try to auth via secret key (for embedded outpost/etc)
user = token_secret_key(auth_credentials)
if user:
CTX_AUTH_VIA.set("secret_key")
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.apps import MANAGED_OUTPOST
if value != settings.SECRET_KEY:
return None
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts:
return None
outpost = outposts.first()
return outpost.user
class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""
def authenticate(self, request: Request) -> tuple[User, Any] | None:
"""Token-based authentication using HTTP Bearer authentication"""
auth = get_authorization_header(request)
user = bearer_auth(auth)
# None is only returned when the header isn't set.
if not user:
return None
return (user, None) # pragma: no cover

View File

@ -1,46 +0,0 @@
"""API Authorization"""
from django.db.models import Model
from django.db.models.query import QuerySet
from rest_framework.filters import BaseFilterBackend
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
class OwnerFilter(BaseFilterBackend):
"""Filter objects by their owner"""
owner_key = "user"
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})
class OwnerPermissions(BasePermission):
"""Authorize requests by an object's owner matching the requesting user"""
owner_key = "user"
def has_permission(self, request: Request, view) -> bool:
"""If the user is authenticated, we allow all requests here. For listing, the
object-level permissions are done by the filter backend"""
return request.user.is_authenticated
def has_object_permission(self, request: Request, view, obj: Model) -> bool:
"""Check if the object's owner matches the currently logged in user"""
if not hasattr(obj, self.owner_key):
return False
owner = getattr(obj, self.owner_key)
if owner != request.user:
return False
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.response import Response
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"""
def wrapper_outter(func: Callable):
@ -21,12 +20,10 @@ def permission_required(perm: Optional[str] = None, other_perms: Optional[list[s
if perm:
obj = self.get_object()
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)
if other_perms:
for other_perm in other_perms:
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 func(self, request, *args, **kwargs)

View File

@ -30,47 +30,3 @@ class Pagination(pagination.PageNumberPagination):
"results": data,
}
)
def get_paginated_response_schema(self, schema):
return {
"type": "object",
"properties": {
"pagination": {
"type": "object",
"properties": {
"next": {
"type": "number",
},
"previous": {
"type": "number",
},
"count": {
"type": "number",
},
"current": {
"type": "number",
},
"total_pages": {
"type": "number",
},
"start_index": {
"type": "number",
},
"end_index": {
"type": "number",
},
},
"required": [
"next",
"previous",
"count",
"current",
"total_pages",
"start_index",
"end_index",
],
},
"results": schema,
},
"required": ["pagination", "results"],
}

View File

@ -0,0 +1,97 @@
"""Swagger Pagination Schema class"""
from typing import OrderedDict
from drf_yasg import openapi
from drf_yasg.inspectors import PaginatorInspector
class PaginationInspector(PaginatorInspector):
"""Swagger Pagination Schema class"""
def get_paginated_response(self, paginator, response_schema):
"""
:param BasePagination paginator: the paginator
:param openapi.Schema response_schema: the response schema that must be paged.
:rtype: openapi.Schema
"""
return openapi.Schema(
type=openapi.TYPE_OBJECT,
properties=OrderedDict(
(
(
"pagination",
openapi.Schema(
type=openapi.TYPE_OBJECT,
properties=OrderedDict(
(
("next", openapi.Schema(type=openapi.TYPE_NUMBER)),
(
"previous",
openapi.Schema(type=openapi.TYPE_NUMBER),
),
("count", openapi.Schema(type=openapi.TYPE_NUMBER)),
(
"current",
openapi.Schema(type=openapi.TYPE_NUMBER),
),
(
"total_pages",
openapi.Schema(type=openapi.TYPE_NUMBER),
),
(
"start_index",
openapi.Schema(type=openapi.TYPE_NUMBER),
),
(
"end_index",
openapi.Schema(type=openapi.TYPE_NUMBER),
),
)
),
required=[
"next",
"previous",
"count",
"current",
"total_pages",
"start_index",
"end_index",
],
),
),
("results", response_schema),
)
),
required=["results", "pagination"],
)
def get_paginator_parameters(self, paginator):
"""
Get the pagination parameters for a single paginator **instance**.
Should return :data:`.NotHandled` if this inspector
does not know how to handle the given `paginator`.
:param BasePagination paginator: the paginator
:rtype: list[openapi.Parameter]
"""
return [
openapi.Parameter(
"page",
openapi.IN_QUERY,
"Page Index",
False,
None,
openapi.TYPE_INTEGER,
),
openapi.Parameter(
"page_size",
openapi.IN_QUERY,
"Page Size",
False,
None,
openapi.TYPE_INTEGER,
),
]

View File

@ -1,95 +1,102 @@
"""Error Response schema, from https://github.com/axnsan12/drf-yasg/issues/224"""
from django.utils.translation import gettext_lazy as _
from drf_spectacular.plumbing import (
ResolvedComponent,
build_array_type,
build_basic_type,
build_object_type,
)
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from drf_yasg import openapi
from drf_yasg.inspectors.view import SwaggerAutoSchema
from drf_yasg.utils import force_real_str, is_list_view
from rest_framework import exceptions, status
from rest_framework.settings import api_settings
def build_standard_type(obj, **kwargs):
"""Build a basic type with optional add owns."""
schema = build_basic_type(obj)
schema.update(kwargs)
return schema
class ErrorResponseAutoSchema(SwaggerAutoSchema):
"""Inspector which includes an error schema"""
GENERIC_ERROR = build_object_type(
description=_("Generic API Error"),
properties={
"detail": build_standard_type(OpenApiTypes.STR),
"code": build_standard_type(OpenApiTypes.STR),
},
required=["detail"],
)
VALIDATION_ERROR = build_object_type(
description=_("Validation Error"),
properties={
"non_field_errors": build_array_type(build_standard_type(OpenApiTypes.STR)),
"code": build_standard_type(OpenApiTypes.STR),
},
required=[],
additionalProperties={},
)
def postprocess_schema_responses(result, generator, **kwargs): # noqa: W0613
"""Workaround to set a default response for endpoints.
Workaround suggested at
<https://github.com/tfranzel/drf-spectacular/issues/119#issuecomment-656970357>
for the missing drf-spectacular feature discussed in
<https://github.com/tfranzel/drf-spectacular/issues/101>.
"""
def create_component(name, schema, type_=ResolvedComponent.SCHEMA):
"""Register a component and return a reference to it."""
component = ResolvedComponent(
name=name,
type=type_,
schema=schema,
object=name,
def get_generic_error_schema(self):
"""Get a generic error schema"""
return openapi.Schema(
"Generic API Error",
type=openapi.TYPE_OBJECT,
properties={
"detail": openapi.Schema(
type=openapi.TYPE_STRING, description="Error details"
),
"code": openapi.Schema(
type=openapi.TYPE_STRING, description="Error code"
),
},
required=["detail"],
)
generator.registry.register_on_missing(component)
return component
generic_error = create_component("GenericError", GENERIC_ERROR)
validation_error = create_component("ValidationError", VALIDATION_ERROR)
def get_validation_error_schema(self):
"""Get a generic validation error schema"""
return openapi.Schema(
"Validation Error",
type=openapi.TYPE_OBJECT,
properties={
api_settings.NON_FIELD_ERRORS_KEY: openapi.Schema(
description="List of validation errors not related to any field",
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_STRING),
),
},
additional_properties=openapi.Schema(
description=(
"A list of error messages for each "
"field that triggered a validation error"
),
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_STRING),
),
)
for path in result["paths"].values():
for method in path.values():
method["responses"].setdefault(
"400",
{
"content": {
"application/json": {
"schema": validation_error.ref,
}
},
"description": "",
},
)
method["responses"].setdefault(
"403",
{
"content": {
"application/json": {
"schema": generic_error.ref,
}
},
"description": "",
},
def get_response_serializers(self):
responses = super().get_response_serializers()
definitions = self.components.with_scope(
openapi.SCHEMA_DEFINITIONS
) # type: openapi.ReferenceResolver
definitions.setdefault("GenericError", self.get_generic_error_schema)
definitions.setdefault("ValidationError", self.get_validation_error_schema)
definitions.setdefault("APIException", self.get_generic_error_schema)
if self.get_request_serializer() or self.get_query_serializer():
responses.setdefault(
exceptions.ValidationError.status_code,
openapi.Response(
description=force_real_str(
exceptions.ValidationError.default_detail
),
schema=openapi.SchemaRef(definitions, "ValidationError"),
),
)
result["components"] = generator.registry.build(spectacular_settings.APPEND_COMPONENTS)
security = self.get_security()
if security is None or len(security) > 0:
# Note: 401 error codes are coerced into 403 see
# rest_framework/views.py:433:handle_exception
# This is b/c the API uses token auth which doesn't have WWW-Authenticate header
responses.setdefault(
status.HTTP_403_FORBIDDEN,
openapi.Response(
description="Authentication credentials were invalid, absent or insufficient.",
schema=openapi.SchemaRef(definitions, "GenericError"),
),
)
if not is_list_view(self.path, self.method, self.view):
responses.setdefault(
exceptions.PermissionDenied.status_code,
openapi.Response(
description="Permission denied.",
schema=openapi.SchemaRef(definitions, "APIException"),
),
)
responses.setdefault(
exceptions.NotFound.status_code,
openapi.Response(
description=(
"Object does not exist or caller "
"has insufficient permissions to access it."
),
schema=openapi.SchemaRef(definitions, "APIException"),
),
)
# This is a workaround for authentik/stages/prompt/stage.py
# since the serializer PromptChallengeResponse
# accepts dynamic keys
for component in result["components"]["schemas"]:
if component == "PromptChallengeResponseRequest":
comp = result["components"]["schemas"][component]
comp["additionalProperties"] = {}
return result
return responses

View File

@ -1,88 +0,0 @@
{% extends "base/skeleton.html" %}
{% load static %}
{% block title %}
API Browser - {{ tenant.branding_title }}
{% endblock %}
{% block head %}
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
<script>
function getCookie(name) {
let cookieValue = "";
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
window.addEventListener('DOMContentLoaded', (event) => {
const rapidocEl = document.querySelector('rapi-doc');
rapidocEl.addEventListener('before-try', (e) => {
e.detail.request.headers.append('X-authentik-CSRF', getCookie("authentik_csrf"));
});
});
</script>
<style>
img.logo {
width: 100%;
padding: 1rem 0.5rem 1.5rem 0.5rem;
min-height: 48px;
}
</style>
{% endblock %}
{% block body %}
<rapi-doc
spec-url="{{ path }}"
heading-text=""
theme="light"
render-style="read"
default-schema-tab="schema"
primary-color="#fd4b2d"
nav-bg-color="#212427"
bg-color="#000000"
text-color="#000000"
nav-text-color="#ffffff"
nav-hover-bg-color="#3c3f42"
nav-accent-color="#4f5255"
nav-hover-text-color="#ffffff"
use-path-in-nav-bar="true"
nav-item-spacing="relaxed"
allow-server-selection="false"
show-header="false"
allow-spec-url-load="false"
allow-spec-file-load="false">
<div slot="nav-logo">
<img class="logo" src="{% static 'dist/assets/icons/icon_left_brand.png' %}" />
</div>
</rapi-doc>
<script>
const rapidoc = document.querySelector("rapi-doc");
const matcher = window.matchMedia("(prefers-color-scheme: light)");
const changer = (ev) => {
const style = getComputedStyle(document.documentElement);
let bg, text = "";
if (matcher.matches) {
bg = style.getPropertyValue('--pf-global--BackgroundColor--light-300');
text = style.getPropertyValue('--pf-global--Color--300');
} else {
bg = style.getPropertyValue('--ak-dark-background');
text = style.getPropertyValue('--ak-dark-foreground');
}
rapidoc.attributes.getNamedItem("bg-color").value = bg.trim();
rapidoc.attributes.getNamedItem("text-color").value = text.trim();
rapidoc.requestUpdate();
};
matcher.addEventListener("change", changer);
window.addEventListener("load", changer);
</script>
{% endblock %}

View File

@ -0,0 +1,49 @@
{% extends "base/skeleton.html" %}
{% load static %}
{% block title %}
authentik API Browser
{% endblock %}
{% block head %}
<script type="module" src="{% static 'dist/rapidoc-min.js' %}"></script>
{% endblock %}
{% block body %}
<script>
function getCookie(name) {
let cookieValue = "";
if (document.cookie && document.cookie !== "") {
const cookies = document.cookie.split(";");
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === name + "=") {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
window.addEventListener('DOMContentLoaded', (event) => {
const rapidocEl = document.querySelector('rapi-doc');
rapidocEl.addEventListener('before-try', (e) => {
e.detail.request.headers.append('X-CSRFToken', getCookie("authentik_csrf"));
});
});
</script>
<rapi-doc
spec-url="{{ path }}"
heading-text="authentik"
theme="dark"
render-style="view"
primary-color="#fd4b2d"
allow-spec-url-load="false"
allow-spec-file-load="false">
<div slot="logo">
<img src="{% static 'dist/assets/icons/icon.png' %}" style="width:50px; height:50px" />
</div>
</rapi-doc>
{% endblock %}

View File

@ -1,78 +1,49 @@
"""Test API Authentication"""
from base64 import b64encode
from django.conf import settings
from django.test import TestCase
from guardian.shortcuts import get_anonymous_user
from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import bearer_auth
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import USER_ATTRIBUTE_SA, Token, TokenIntents
from authentik.core.tests.utils import create_test_flow
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
from authentik.providers.oauth2.models import OAuth2Provider, RefreshToken
from authentik.api.auth import token_from_header
from authentik.core.models import Token, TokenIntents
class TestAPIAuth(TestCase):
"""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):
"""Test valid token"""
token = Token.objects.create(
intent=TokenIntents.INTENT_API, user=get_anonymous_user()
)
self.assertEqual(token_from_header(f"Bearer {token.key}".encode()), token)
def test_invalid_type(self):
"""Test invalid type"""
with self.assertRaises(AuthenticationFailed):
bearer_auth("foo bar".encode())
token_from_header("foo bar".encode())
def test_invalid_empty(self):
"""Test invalid type"""
self.assertIsNone(bearer_auth("Bearer ".encode()))
self.assertIsNone(bearer_auth("".encode()))
def test_invalid_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):
"""Test invalid with no token"""
with self.assertRaises(AuthenticationFailed):
auth = b64encode(":abc".encode()).decode()
self.assertIsNone(bearer_auth(f"Basic :{auth}".encode()))
def test_bearer_valid(self):
"""Test valid token"""
token = Token.objects.create(intent=TokenIntents.INTENT_API, user=get_anonymous_user())
self.assertEqual(bearer_auth(f"Bearer {token.key}".encode()), token.user)
def test_managed_outpost(self):
"""Test managed outpost"""
with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
@reconcile_app("authentik_outposts")
def test_managed_outpost_success(self):
"""Test managed outpost"""
user = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
self.assertEqual(user.attributes[USER_ATTRIBUTE_SA], True)
def test_jwt_valid(self):
"""Test valid JWT"""
provider = OAuth2Provider.objects.create(
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
)
refresh = RefreshToken.objects.create(
user=get_anonymous_user(),
provider=provider,
refresh_token=generate_id(),
_scope=SCOPE_AUTHENTIK_API,
)
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
def test_jwt_missing_scope(self):
"""Test valid JWT"""
provider = OAuth2Provider.objects.create(
name=generate_id(), client_id=generate_id(), authorization_flow=create_test_flow()
)
refresh = RefreshToken.objects.create(
user=get_anonymous_user(),
provider=provider,
refresh_token=generate_id(),
_scope="",
)
with self.assertRaises(AuthenticationFailed):
self.assertEqual(bearer_auth(f"Bearer {refresh.refresh_token}".encode()), refresh.user)
self.assertIsNone(token_from_header(f"Basic :{auth}".encode()))

View File

@ -1,16 +0,0 @@
"""Test config API"""
from json import loads
from django.urls import reverse
from rest_framework.test import APITestCase
class TestConfig(APITestCase):
"""Test config API"""
def test_config(self):
"""Test YAML generation"""
response = self.client.get(
reverse("authentik_api:config"),
)
self.assertTrue(loads(response.content.decode()))

View File

@ -1,33 +0,0 @@
"""test decorators api"""
from django.urls import reverse
from guardian.shortcuts import assign_perm
from rest_framework.test import APITestCase
from authentik.core.models import Application, User
class TestAPIDecorators(APITestCase):
"""test decorators api"""
def setUp(self) -> None:
super().setUp()
self.user = User.objects.create(username="test-user")
def test_obj_perm_denied(self):
"""Test object perm denied"""
self.client.force_login(self.user)
app = Application.objects.create(name="denied", slug="denied")
response = self.client.get(
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
)
self.assertEqual(response.status_code, 403)
def test_other_perm_denied(self):
"""Test other perm denied"""
self.client.force_login(self.user)
app = Application.objects.create(name="denied", slug="denied")
assign_perm("authentik_core.view_application", self.user, app)
response = self.client.get(
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
)
self.assertEqual(response.status_code, 403)

View File

@ -1,22 +0,0 @@
"""Schema generation tests"""
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import safe_load
class TestSchemaGeneration(APITestCase):
"""Generic admin tests"""
def test_schema(self):
"""Test generation"""
response = self.client.get(
reverse("authentik_api:schema"),
)
self.assertTrue(safe_load(response.content.decode()))
def test_browser(self):
"""Test API Browser"""
response = self.client.get(
reverse("authentik_api:schema-browser"),
)
self.assertEqual(response.status_code, 200)

View File

@ -0,0 +1,24 @@
"""Swagger generation tests"""
from json import loads
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import safe_load
class TestSwaggerGeneration(APITestCase):
"""Generic admin tests"""
def test_yaml(self):
"""Test YAML generation"""
response = self.client.get(
reverse("authentik_api:schema-json", kwargs={"format": ".yaml"}),
)
self.assertTrue(safe_load(response.content.decode()))
def test_json(self):
"""Test JSON generation"""
response = self.client.get(
reverse("authentik_api:schema-json", kwargs={"format": ".json"}),
)
self.assertTrue(loads(response.content.decode()))

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"""
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 = [
path("v3/", include(v3_urls)),
path("v2beta/", include(v2_urls)),
]

View File

@ -0,0 +1,50 @@
"""core Configs API"""
from drf_yasg.utils import swagger_auto_schema
from rest_framework.fields import BooleanField, CharField, ListField
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import PassiveSerializer
from authentik.lib.config import CONFIG
class FooterLinkSerializer(PassiveSerializer):
"""Links returned in Config API"""
href = CharField(read_only=True)
name = CharField(read_only=True)
class ConfigSerializer(PassiveSerializer):
"""Serialize authentik Config into DRF Object"""
branding_logo = CharField(read_only=True)
branding_title = CharField(read_only=True)
ui_footer_links = ListField(child=FooterLinkSerializer(), read_only=True)
error_reporting_enabled = BooleanField(read_only=True)
error_reporting_environment = CharField(read_only=True)
error_reporting_send_pii = BooleanField(read_only=True)
class ConfigsViewSet(ViewSet):
"""Read-only view set that returns the current session's Configs"""
permission_classes = [AllowAny]
@swagger_auto_schema(responses={200: ConfigSerializer(many=False)})
def list(self, request: Request) -> Response:
"""Retrive public configuration options"""
config = ConfigSerializer(
{
"branding_logo": CONFIG.y("authentik.branding.logo"),
"branding_title": CONFIG.y("authentik.branding.title"),
"error_reporting_enabled": CONFIG.y("error_reporting.enabled"),
"error_reporting_environment": CONFIG.y("error_reporting.environment"),
"error_reporting_send_pii": CONFIG.y("error_reporting.send_pii"),
"ui_footer_links": CONFIG.y("authentik.footer_links"),
}
)
return Response(config.data)

View File

@ -1,44 +1,39 @@
"""api v3 urls"""
from django.urls import path
from django.views.decorators.cache import cache_page
from drf_spectacular.views import SpectacularAPIView
"""api v2 urls"""
from django.urls import path, re_path
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
from rest_framework import routers
from rest_framework.permissions import AllowAny
from authentik.admin.api.meta import AppsViewSet
from authentik.admin.api.metrics import AdministrationMetricsViewSet
from authentik.admin.api.system import SystemView
from authentik.admin.api.tasks import TaskViewSet
from authentik.admin.api.version import VersionView
from authentik.admin.api.workers import WorkerView
from authentik.api.v3.config import ConfigView
from authentik.api.views import APIBrowserView
from authentik.blueprints.api import BlueprintInstanceViewSet
from authentik.admin.api.version import VersionViewSet
from authentik.admin.api.workers import WorkerViewSet
from authentik.api.v2.config import ConfigsViewSet
from authentik.api.views import SwaggerView
from authentik.core.api.applications import ApplicationViewSet
from authentik.core.api.authenticated_sessions import AuthenticatedSessionViewSet
from authentik.core.api.devices import AdminDeviceViewSet, DeviceViewSet
from authentik.core.api.groups import GroupViewSet
from authentik.core.api.propertymappings import PropertyMappingViewSet
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.users import UserViewSet
from authentik.crypto.api import CertificateKeyPairViewSet
from authentik.events.api.events import EventViewSet
from authentik.events.api.notification_mappings import NotificationWebhookMappingViewSet
from authentik.events.api.notification_rules import NotificationRuleViewSet
from authentik.events.api.notification_transports import NotificationTransportViewSet
from authentik.events.api.notifications import NotificationViewSet
from authentik.events.api.event import EventViewSet
from authentik.events.api.notification import NotificationViewSet
from authentik.events.api.notification_rule import NotificationRuleViewSet
from authentik.events.api.notification_transport import NotificationTransportViewSet
from authentik.flows.api.bindings import FlowStageBindingViewSet
from authentik.flows.api.flows import FlowViewSet
from authentik.flows.api.stages import StageViewSet
from authentik.flows.views.executor import FlowExecutorView
from authentik.flows.views.inspector import FlowInspectorView
from authentik.outposts.api.outposts import OutpostViewSet
from authentik.outposts.api.service_connections import (
from authentik.flows.views import FlowExecutorView
from authentik.outposts.api.outpost_service_connections import (
DockerServiceConnectionViewSet,
KubernetesServiceConnectionViewSet,
ServiceConnectionViewSet,
)
from authentik.outposts.api.outposts import OutpostViewSet
from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.dummy.api import DummyPolicyViewSet
@ -47,30 +42,28 @@ from authentik.policies.expiry.api import PasswordExpiryPolicyViewSet
from authentik.policies.expression.api import ExpressionPolicyViewSet
from authentik.policies.hibp.api import HaveIBeenPwendPolicyViewSet
from authentik.policies.password.api import PasswordPolicyViewSet
from authentik.policies.reputation.api import ReputationPolicyViewSet, ReputationViewSet
from authentik.providers.ldap.api import LDAPOutpostConfigViewSet, LDAPProviderViewSet
from authentik.policies.reputation.api import (
IPReputationViewSet,
ReputationPolicyViewSet,
UserReputationViewSet,
)
from authentik.providers.oauth2.api.provider import OAuth2ProviderViewSet
from authentik.providers.oauth2.api.scope import ScopeMappingViewSet
from authentik.providers.oauth2.api.tokens import AuthorizationCodeViewSet, RefreshTokenViewSet
from authentik.providers.proxy.api import ProxyOutpostConfigViewSet, ProxyProviderViewSet
from authentik.providers.oauth2.api.tokens import (
AuthorizationCodeViewSet,
RefreshTokenViewSet,
)
from authentik.providers.proxy.api import (
ProxyOutpostConfigViewSet,
ProxyProviderViewSet,
)
from authentik.providers.saml.api import SAMLPropertyMappingViewSet, SAMLProviderViewSet
from authentik.sources.ldap.api import LDAPPropertyMappingViewSet, LDAPSourceViewSet
from authentik.sources.oauth.api.source import OAuthSourceViewSet
from authentik.sources.oauth.api.source_connection import UserOAuthSourceConnectionViewSet
from authentik.sources.plex.api.source import PlexSourceViewSet
from authentik.sources.plex.api.source_connection import PlexSourceConnectionViewSet
from authentik.sources.saml.api.source import SAMLSourceViewSet
from authentik.sources.saml.api.source_connection import UserSAMLSourceConnectionViewSet
from authentik.stages.authenticator_duo.api import (
AuthenticatorDuoStageViewSet,
DuoAdminDeviceViewSet,
DuoDeviceViewSet,
)
from authentik.stages.authenticator_sms.api import (
AuthenticatorSMSStageViewSet,
SMSAdminDeviceViewSet,
SMSDeviceViewSet,
from authentik.sources.oauth.api.source_connection import (
UserOAuthSourceConnectionViewSet,
)
from authentik.sources.saml.api import SAMLSourceViewSet
from authentik.stages.authenticator_static.api import (
AuthenticatorStaticStageViewSet,
StaticAdminDeviceViewSet,
@ -81,7 +74,9 @@ from authentik.stages.authenticator_totp.api import (
TOTPAdminDeviceViewSet,
TOTPDeviceViewSet,
)
from authentik.stages.authenticator_validate.api import AuthenticatorValidateStageViewSet
from authentik.stages.authenticator_validate.api import (
AuthenticatorValidateStageViewSet,
)
from authentik.stages.authenticator_webauthn.api import (
AuthenticateWebAuthnStageViewSet,
WebAuthnAdminDeviceViewSet,
@ -100,28 +95,31 @@ from authentik.stages.user_delete.api import UserDeleteStageViewSet
from authentik.stages.user_login.api import UserLoginStageViewSet
from authentik.stages.user_logout.api import UserLogoutStageViewSet
from authentik.stages.user_write.api import UserWriteStageViewSet
from authentik.tenants.api import TenantViewSet
router = routers.DefaultRouter()
router.include_format_suffixes = False
router.register("root/config", ConfigsViewSet, basename="configs")
router.register("admin/version", VersionViewSet, basename="admin_version")
router.register("admin/workers", WorkerViewSet, basename="admin_workers")
router.register("admin/metrics", AdministrationMetricsViewSet, basename="admin_metrics")
router.register("admin/system_tasks", TaskViewSet, basename="admin_system_tasks")
router.register("admin/apps", AppsViewSet, basename="apps")
router.register("core/authenticated_sessions", AuthenticatedSessionViewSet)
router.register("core/applications", ApplicationViewSet)
router.register("core/groups", GroupViewSet)
router.register("core/users", UserViewSet)
router.register("core/user_consent", UserConsentViewSet)
router.register("core/tokens", TokenViewSet)
router.register("core/tenants", TenantViewSet)
router.register("outposts/outposts", OutpostViewSet)
router.register("outposts/instances", OutpostViewSet)
router.register("outposts/service_connections/all", ServiceConnectionViewSet)
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/ldap", LDAPOutpostConfigViewSet)
router.register("flows/instances", FlowViewSet)
router.register("flows/bindings", FlowStageBindingViewSet)
@ -133,17 +131,11 @@ router.register("events/notifications", NotificationViewSet)
router.register("events/transports", NotificationTransportViewSet)
router.register("events/rules", NotificationRuleViewSet)
router.register("managed/blueprints", BlueprintInstanceViewSet)
router.register("sources/all", SourceViewSet)
router.register("sources/user_connections/all", UserSourceConnectionViewSet)
router.register("sources/user_connections/oauth", UserOAuthSourceConnectionViewSet)
router.register("sources/user_connections/plex", PlexSourceConnectionViewSet)
router.register("sources/user_connections/saml", UserSAMLSourceConnectionViewSet)
router.register("sources/oauth_user_connections", UserOAuthSourceConnectionViewSet)
router.register("sources/ldap", LDAPSourceViewSet)
router.register("sources/saml", SAMLSourceViewSet)
router.register("sources/oauth", OAuthSourceViewSet)
router.register("sources/plex", PlexSourceViewSet)
router.register("policies/all", PolicyViewSet)
router.register("policies/bindings", PolicyBindingViewSet)
@ -152,11 +144,11 @@ router.register("policies/event_matcher", EventMatcherPolicyViewSet)
router.register("policies/haveibeenpwned", HaveIBeenPwendPolicyViewSet)
router.register("policies/password_expiry", PasswordExpiryPolicyViewSet)
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("providers/all", ProviderViewSet)
router.register("providers/ldap", LDAPProviderViewSet)
router.register("providers/proxy", ProxyProviderViewSet)
router.register("providers/oauth2", OAuth2ProviderViewSet)
router.register("providers/saml", SAMLProviderViewSet)
@ -168,44 +160,15 @@ router.register("propertymappings/all", PropertyMappingViewSet)
router.register("propertymappings/ldap", LDAPPropertyMappingViewSet)
router.register("propertymappings/saml", SAMLPropertyMappingViewSet)
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/sms", SMSDeviceViewSet)
router.register("authenticators/static", StaticDeviceViewSet)
router.register("authenticators/totp", TOTPDeviceViewSet)
router.register("authenticators/webauthn", WebAuthnDeviceViewSet)
router.register(
"authenticators/admin/all",
AdminDeviceViewSet,
basename="admin-device",
)
router.register(
"authenticators/admin/duo",
DuoAdminDeviceViewSet,
basename="admin-duodevice",
)
router.register(
"authenticators/admin/sms",
SMSAdminDeviceViewSet,
basename="admin-smsdevice",
)
router.register(
"authenticators/admin/static",
StaticAdminDeviceViewSet,
basename="admin-staticdevice",
)
router.register("authenticators/admin/totp", TOTPAdminDeviceViewSet, basename="admin-totpdevice")
router.register(
"authenticators/admin/webauthn",
WebAuthnAdminDeviceViewSet,
basename="admin-webauthndevice",
)
router.register("authenticators/admin/static", StaticAdminDeviceViewSet)
router.register("authenticators/admin/totp", TOTPAdminDeviceViewSet)
router.register("authenticators/admin/webauthn", WebAuthnAdminDeviceViewSet)
router.register("stages/all", StageViewSet)
router.register("stages/authenticator/duo", AuthenticatorDuoStageViewSet)
router.register("stages/authenticator/sms", AuthenticatorSMSStageViewSet)
router.register("stages/authenticator/static", AuthenticatorStaticStageViewSet)
router.register("stages/authenticator/totp", AuthenticatorTOTPStageViewSet)
router.register("stages/authenticator/validate", AuthenticatorValidateStageViewSet)
@ -228,31 +191,32 @@ router.register("stages/user_write", UserWriteStageViewSet)
router.register("stages/dummy", DummyStageViewSet)
router.register("policies/dummy", DummyPolicyViewSet)
info = openapi.Info(
title="authentik API",
default_version="v2beta",
contact=openapi.Contact(email="hello@beryju.org"),
license=openapi.License(
name="GNU GPLv3",
url="https://github.com/goauthentik/authentik/blob/master/LICENSE",
),
)
SchemaView = get_schema_view(info, public=True, permission_classes=(AllowAny,))
urlpatterns = (
[
path("", APIBrowserView.as_view(), name="schema-browser"),
path("", SwaggerView.as_view(), name="swagger"),
]
+ router.urls
+ [
path(
"admin/metrics/",
AdministrationMetricsViewSet.as_view(),
name="admin_metrics",
),
path("admin/version/", VersionView.as_view(), name="admin_version"),
path("admin/workers/", WorkerView.as_view(), name="admin_workers"),
path("admin/system/", SystemView.as_view(), name="admin_system"),
path("root/config/", ConfigView.as_view(), name="config"),
path(
"flows/executor/<slug:flow_slug>/",
FlowExecutorView.as_view(),
name="flow-executor",
),
path(
"flows/inspector/<slug:flow_slug>/",
FlowInspectorView.as_view(),
name="flow-inspector",
re_path(
r"^swagger(?P<format>\.json|\.yaml)$",
SchemaView.without_ui(cache_timeout=0),
name="schema-json",
),
path("schema/", cache_page(86400)(SpectacularAPIView.as_view()), name="schema"),
]
)

View File

@ -1,95 +0,0 @@
"""core Configs API"""
from os import path
from django.conf import settings
from django.db import models
from drf_spectacular.utils import extend_schema
from rest_framework.fields import (
BooleanField,
CharField,
ChoiceField,
FloatField,
IntegerField,
ListField,
)
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer
from authentik.events.geo import GEOIP_READER
from authentik.lib.config import CONFIG
class Capabilities(models.TextChoices):
"""Define capabilities which influence which APIs can/should be used"""
CAN_SAVE_MEDIA = "can_save_media"
CAN_GEO_IP = "can_geo_ip"
CAN_IMPERSONATE = "can_impersonate"
CAN_DEBUG = "can_debug"
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):
"""Serialize authentik Config into DRF Object"""
error_reporting = ErrorReportingConfigSerializer(required=True)
capabilities = ListField(child=ChoiceField(choices=Capabilities.choices))
cache_timeout = IntegerField(required=True)
cache_timeout_flows = IntegerField(required=True)
cache_timeout_policies = IntegerField(required=True)
cache_timeout_reputation = IntegerField(required=True)
class ConfigView(APIView):
"""Read-only view set that returns the current session's Configs"""
permission_classes = [AllowAny]
def get_capabilities(self) -> list[Capabilities]:
"""Get all capabilities this server instance supports"""
caps = []
deb_test = settings.DEBUG or settings.TEST
if path.ismount(settings.MEDIA_ROOT) or deb_test:
caps.append(Capabilities.CAN_SAVE_MEDIA)
if GEOIP_READER.enabled:
caps.append(Capabilities.CAN_GEO_IP)
if CONFIG.y_bool("impersonation"):
caps.append(Capabilities.CAN_IMPERSONATE)
if settings.DEBUG:
caps.append(Capabilities.CAN_DEBUG)
return caps
def get_config(self) -> ConfigSerializer:
"""Get Config"""
return ConfigSerializer(
{
"error_reporting": {
"enabled": CONFIG.y("error_reporting.enabled"),
"environment": CONFIG.y("error_reporting.environment"),
"send_pii": CONFIG.y("error_reporting.send_pii"),
"traces_sample_rate": float(CONFIG.y("error_reporting.sample_rate", 0.4)),
},
"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")),
}
)
@extend_schema(responses={200: ConfigSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Retrieve public configuration options"""
return Response(self.get_config().data)

View File

@ -5,15 +5,18 @@ from django.urls import reverse
from django.views.generic import TemplateView
class APIBrowserView(TemplateView):
"""Show browser view based on rapi-doc"""
class SwaggerView(TemplateView):
"""Show swagger view based on rapi-doc"""
template_name = "api/browser.html"
template_name = "api/swagger.html"
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
path = self.request.build_absolute_uri(
reverse(
"authentik_api:schema",
"authentik_api:schema-json",
kwargs={
"format": ".json",
},
)
)
return super().get_context_data(path=path, **kwargs)

View File

@ -1,109 +0,0 @@
"""Serializer mixin for managed models"""
from drf_spectacular.utils import extend_schema, inline_serializer
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, DateTimeField, JSONField
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.api.decorators import permission_required
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import PassiveSerializer
class ManagedSerializer:
"""Managed Serializer"""
managed = CharField(read_only=True, allow_null=True)
class MetadataSerializer(PassiveSerializer):
"""Serializer for blueprint metadata"""
name = CharField()
labels = JSONField()
class BlueprintInstanceSerializer(ModelSerializer):
"""Info about a single blueprint instance file"""
def validate_path(self, path: str) -> str:
"""Ensure the path specified is retrievable"""
try:
BlueprintInstance(path=path).retrieve()
except BlueprintRetrievalFailed as exc:
raise ValidationError(exc) from exc
return path
class Meta:
model = BlueprintInstance
fields = [
"pk",
"name",
"path",
"context",
"last_applied",
"last_applied_hash",
"status",
"enabled",
"managed_models",
"metadata",
]
extra_kwargs = {
"status": {"read_only": True},
"last_applied": {"read_only": True},
"last_applied_hash": {"read_only": True},
"managed_models": {"read_only": True},
"metadata": {"read_only": True},
}
class BlueprintInstanceViewSet(UsedByMixin, ModelViewSet):
"""Blueprint instances"""
permission_classes = [IsAdminUser]
serializer_class = BlueprintInstanceSerializer
queryset = BlueprintInstance.objects.all()
search_fields = ["name", "path"]
filterset_fields = ["name", "path"]
@extend_schema(
responses={
200: ListSerializer(
child=inline_serializer(
"BlueprintFile",
fields={
"path": CharField(),
"last_m": DateTimeField(),
"hash": CharField(),
"meta": MetadataSerializer(required=False, read_only=True),
},
)
)
}
)
@action(detail=False, pagination_class=None, filter_backends=[])
def available(self, request: Request) -> Response:
"""Get blueprints"""
files: list[dict] = blueprints_find_dict.delay().get()
return Response(files)
@permission_required("authentik_blueprints.view_blueprintinstance")
@extend_schema(
request=None,
responses={
200: BlueprintInstanceSerializer(),
},
)
@action(detail=True, pagination_class=None, filter_backends=[], methods=["POST"])
def apply(self, request: Request, *args, **kwargs) -> Response:
"""Apply a blueprint"""
blueprint = self.get_object()
apply_blueprint.delay(str(blueprint.pk)).get()
return self.retrieve(request, *args, **kwargs)

View File

@ -1,66 +0,0 @@
"""authentik Blueprints app"""
from importlib import import_module
from inspect import ismethod
from django.apps import AppConfig
from django.db import DatabaseError, InternalError, ProgrammingError
from structlog.stdlib import BoundLogger, get_logger
class ManagedAppConfig(AppConfig):
"""Basic reconciliation logic for apps"""
_logger: BoundLogger
def __init__(self, app_name: str, *args, **kwargs) -> None:
super().__init__(app_name, *args, **kwargs)
self._logger = get_logger().bind(app_name=app_name)
def ready(self) -> None:
self.reconcile()
return super().ready()
def import_module(self, path: str):
"""Load module"""
import_module(path)
def reconcile(self) -> None:
"""reconcile ourselves"""
prefix = "reconcile_"
for meth_name in dir(self):
meth = getattr(self, meth_name)
if not ismethod(meth):
continue
if not meth_name.startswith(prefix):
continue
name = meth_name.replace(prefix, "")
try:
self._logger.debug("Starting reconciler", name=name)
meth()
self._logger.debug("Successfully reconciled", name=name)
except (DatabaseError, ProgrammingError, InternalError) as exc:
self._logger.debug("Failed to run reconcile", name=name, exc=exc)
class AuthentikBlueprintsConfig(ManagedAppConfig):
"""authentik Blueprints app"""
name = "authentik.blueprints"
label = "authentik_blueprints"
verbose_name = "authentik Blueprints"
default = True
def reconcile_load_blueprints_v1_tasks(self):
"""Load v1 tasks"""
self.import_module("authentik.blueprints.v1.tasks")
def reconcile_blueprints_discover(self):
"""Run blueprint discovery"""
from authentik.blueprints.v1.tasks import blueprints_discover
blueprints_discover.delay()
def import_models(self):
super().import_models()
self.import_module("authentik.blueprints.v1.meta.apply_blueprint")

View File

@ -1,31 +0,0 @@
"""Apply blueprint from commandline"""
from sys import exit as sys_exit
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.v1.importer import Importer
LOGGER = get_logger()
class Command(BaseCommand):
"""Apply blueprint from commandline"""
@no_translations
def handle(self, *args, **options):
"""Apply all blueprints in order, abort when one fails to import"""
for blueprint_path in options.get("blueprints", []):
content = BlueprintInstance(path=blueprint_path).retrieve()
importer = Importer(content)
valid, logs = importer.validate()
if not valid:
for log in logs:
getattr(LOGGER, log.pop("log_level"))(**log)
self.stderr.write("blueprint invalid")
sys_exit(1)
importer.apply()
def add_arguments(self, parser):
parser.add_argument("blueprints", nargs="+", type=str)

View File

@ -1,17 +0,0 @@
"""Export blueprint of current authentik install"""
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
from authentik.blueprints.v1.exporter import Exporter
LOGGER = get_logger()
class Command(BaseCommand):
"""Export blueprint of current authentik install"""
@no_translations
def handle(self, *args, **options):
"""Export blueprint of current authentik install"""
exporter = Exporter()
self.stdout.write(exporter.export_to_string())

View File

@ -1,36 +0,0 @@
"""Generate JSON Schema for blueprints"""
from json import dumps, loads
from pathlib import Path
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.blueprints.v1.meta.registry import registry
LOGGER = get_logger()
class Command(BaseCommand):
"""Generate JSON Schema for blueprints"""
schema: dict
@no_translations
def handle(self, *args, **options):
"""Generate JSON Schema for blueprints"""
path = Path(__file__).parent.joinpath("./schema_template.json")
with open(path, "r", encoding="utf-8") as _template_file:
self.schema = loads(_template_file.read())
self.set_model_allowed()
self.stdout.write(dumps(self.schema, indent=4))
def set_model_allowed(self):
"""Set model enum"""
model_names = []
for model in registry.get_models():
if not is_model_allowed(model):
continue
model_names.append(f"{model._meta.app_label}.{model._meta.model_name}")
model_names.sort()
self.schema["properties"]["entries"]["items"]["properties"]["model"]["enum"] = model_names

View File

@ -1,90 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "http://example.com/example.json",
"type": "object",
"title": "authentik Blueprint schema",
"default": {},
"required": [
"version",
"entries"
],
"properties": {
"version": {
"$id": "#/properties/version",
"type": "integer",
"title": "Blueprint version",
"default": 1
},
"metadata": {
"$id": "#/properties/metadata",
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"type": "string"
},
"labels": {
"type": "object"
}
}
},
"context": {
"$id": "#/properties/context",
"type": "object",
"additionalProperties": true
},
"entries": {
"type": "array",
"items": {
"$id": "#entry",
"type": "object",
"required": [
"model"
],
"properties": {
"model": {
"type": "string",
"enum": [
"placeholder"
]
},
"id": {
"type": "string"
},
"attrs": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Commonly available field, may not exist on all models"
}
},
"default": {},
"additionalProperties": true
},
"identifiers": {
"type": "object",
"default": {},
"properties": {
"pk": {
"description": "Commonly available field, may not exist on all models",
"anyOf": [
{
"type": "number"
},
{
"type": "string",
"format": "uuid"
}
]
}
},
"additionalProperties": true
}
}
}
}
}
}

View File

@ -1,135 +0,0 @@
# Generated by Django 4.0.6 on 2022-07-31 17:35
import uuid
from glob import glob
from pathlib import Path
import django.contrib.postgres.fields
from dacite.core import from_dict
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 yaml import load
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_SYSTEM
from authentik.lib.config import CONFIG
def check_blueprint_v1_file(BlueprintInstance: type["BlueprintInstance"], path: Path):
"""Check if blueprint should be imported"""
from authentik.blueprints.models import BlueprintInstanceStatus
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE
with open(path, "r", encoding="utf-8") as blueprint_file:
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
if not raw_blueprint:
return
metadata = raw_blueprint.get("metadata", None)
version = raw_blueprint.get("version", 1)
if version != 1:
return
blueprint_file.seek(0)
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=path).first()
rel_path = path.relative_to(Path(CONFIG.y("blueprints_dir")))
meta = None
if metadata:
meta = from_dict(BlueprintMetadata, metadata)
if meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false":
return
if not instance:
instance = BlueprintInstance(
name=meta.name if meta else str(rel_path),
path=str(rel_path),
context={},
status=BlueprintInstanceStatus.UNKNOWN,
enabled=True,
managed_models=[],
last_applied_hash="",
metadata=metadata,
)
instance.save()
def migration_blueprint_import(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
BlueprintInstance = apps.get_model("authentik_blueprints", "BlueprintInstance")
Flow = apps.get_model("authentik_flows", "Flow")
db_alias = schema_editor.connection.alias
for file in glob(f"{CONFIG.y('blueprints_dir')}/**/*.yaml", recursive=True):
check_blueprint_v1_file(BlueprintInstance, Path(file))
for blueprint in BlueprintInstance.objects.using(db_alias).all():
# If we already have flows (and we should always run before flow migrations)
# then this is an existing install and we want to disable all blueprints
if Flow.objects.using(db_alias).all().exists():
blueprint.enabled = False
# System blueprints are always enabled
if blueprint.metadata.get("labels", {}).get(LABEL_AUTHENTIK_SYSTEM, "").lower() == "true":
blueprint.enabled = True
blueprint.save()
class Migration(migrations.Migration):
initial = True
dependencies = [("authentik_flows", "0001_initial")]
operations = [
migrations.CreateModel(
name="BlueprintInstance",
fields=[
("created", models.DateTimeField(auto_now_add=True)),
("last_updated", models.DateTimeField(auto_now=True)),
(
"managed",
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",
),
),
(
"instance_uuid",
models.UUIDField(
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
),
),
("name", models.TextField()),
("metadata", models.JSONField(default=dict)),
("path", models.TextField()),
("context", models.JSONField(default=dict)),
("last_applied", models.DateTimeField(auto_now=True)),
("last_applied_hash", models.TextField()),
(
"status",
models.TextField(
choices=[
("successful", "Successful"),
("warning", "Warning"),
("error", "Error"),
("orphaned", "Orphaned"),
("unknown", "Unknown"),
],
default="unknown",
),
),
("enabled", models.BooleanField(default=True)),
(
"managed_models",
django.contrib.postgres.fields.ArrayField(
base_field=models.TextField(), default=list, size=None
),
),
],
options={
"verbose_name": "Blueprint Instance",
"verbose_name_plural": "Blueprint Instances",
"unique_together": {("name", "path")},
},
),
migrations.RunPython(migration_blueprint_import),
]

View File

@ -1,163 +0,0 @@
"""blueprint models"""
from pathlib import Path
from urllib.parse import urlparse
from uuid import uuid4
from django.contrib.postgres.fields import ArrayField
from django.db import models
from django.utils.translation import gettext_lazy as _
from opencontainers.distribution.reggie import (
NewClient,
WithDebug,
WithDefaultName,
WithDigest,
WithReference,
WithUserAgent,
WithUsernamePassword,
)
from requests.exceptions import RequestException
from rest_framework.serializers import Serializer
from structlog import get_logger
from authentik.lib.config import CONFIG
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.http import authentik_user_agent
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
LOGGER = get_logger()
class BlueprintRetrievalFailed(SentryIgnoredException):
"""Error raised when we're unable to fetch the blueprint contents, whether it be HTTP files
not being accessible or local files not being readable"""
class ManagedModel(models.Model):
"""Model which can be managed by authentik exclusively"""
managed = models.TextField(
default=None,
null=True,
verbose_name=_("Managed by authentik"),
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."
)
),
unique=True,
)
class Meta:
abstract = True
class BlueprintInstanceStatus(models.TextChoices):
"""Instance status"""
SUCCESSFUL = "successful"
WARNING = "warning"
ERROR = "error"
ORPHANED = "orphaned"
UNKNOWN = "unknown"
class BlueprintInstance(SerializerModel, ManagedModel, CreatedUpdatedModel):
"""Instance of a single blueprint. Can be parameterized via context attribute when
blueprint in `path` has inputs."""
instance_uuid = models.UUIDField(primary_key=True, editable=False, default=uuid4)
name = models.TextField()
metadata = models.JSONField(default=dict)
path = models.TextField()
context = models.JSONField(default=dict)
last_applied = models.DateTimeField(auto_now=True)
last_applied_hash = models.TextField()
status = models.TextField(
choices=BlueprintInstanceStatus.choices, default=BlueprintInstanceStatus.UNKNOWN
)
enabled = models.BooleanField(default=True)
managed_models = ArrayField(models.TextField(), default=list)
def retrieve_oci(self) -> str:
"""Get blueprint from an OCI registry"""
url = urlparse(self.path)
ref = "latest"
path = url.path[1:]
if ":" in url.path:
path, _, ref = path.partition(":")
client = NewClient(
f"{url.scheme}://{url.hostname}",
WithUserAgent(authentik_user_agent()),
WithUsernamePassword(url.username, url.password),
WithDefaultName(path),
WithDebug(True),
)
LOGGER.info("Fetching OCI manifests for blueprint", instance=self)
manifest_request = client.NewRequest(
"GET",
"/v2/<name>/manifests/<reference>",
WithReference(ref),
).SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
try:
manifest_response = client.Do(manifest_request)
manifest_response.raise_for_status()
except RequestException as exc:
raise BlueprintRetrievalFailed(exc) from exc
manifest = manifest_response.json()
if "errors" in manifest:
raise BlueprintRetrievalFailed(manifest["errors"])
blob = None
for layer in manifest.get("layers", []):
if layer.get("mediaType", "") == OCI_MEDIA_TYPE:
blob = layer.get("digest")
LOGGER.debug("Found layer with matching media type", instance=self, blob=blob)
if not blob:
raise BlueprintRetrievalFailed("Blob not found")
blob_request = client.NewRequest(
"GET",
"/v2/<name>/blobs/<digest>",
WithDigest(blob),
)
try:
blob_response = client.Do(blob_request)
blob_response.raise_for_status()
return blob_response.text
except RequestException as exc:
raise BlueprintRetrievalFailed(exc) from exc
def retrieve(self) -> str:
"""Retrieve blueprint contents"""
full_path = Path(CONFIG.y("blueprints_dir")).joinpath(Path(self.path))
if full_path.exists():
LOGGER.debug("Blueprint path exists locally", instance=self)
with full_path.open("r", encoding="utf-8") as _file:
return _file.read()
return self.retrieve_oci()
@property
def serializer(self) -> Serializer:
from authentik.blueprints.api import BlueprintInstanceSerializer
return BlueprintInstanceSerializer
def __str__(self) -> str:
return f"Blueprint Instance {self.name}"
class Meta:
verbose_name = _("Blueprint Instance")
verbose_name_plural = _("Blueprint Instances")
unique_together = (
(
"name",
"path",
),
)

View File

@ -1,12 +0,0 @@
"""blueprint Settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"blueprints_v1_discover": {
"task": "authentik.blueprints.v1.tasks.blueprints_discover",
"schedule": crontab(minute=fqdn_rand("blueprints_v1_discover"), hour="*"),
"options": {"queue": "authentik_scheduled"},
},
}

View File

@ -1,48 +0,0 @@
"""Blueprint helpers"""
from functools import wraps
from pathlib import Path
from typing import Callable
from django.apps import apps
from authentik.blueprints.apps import ManagedAppConfig
from authentik.blueprints.models import BlueprintInstance
from authentik.lib.config import CONFIG
def apply_blueprint(*files: str):
"""Apply blueprint before test"""
from authentik.blueprints.v1.importer import Importer
def wrapper_outer(func: Callable):
"""Apply blueprint before test"""
@wraps(func)
def wrapper(*args, **kwargs):
for file in files:
content = BlueprintInstance(path=file).retrieve()
Importer(content).apply()
return func(*args, **kwargs)
return wrapper
return wrapper_outer
def reconcile_app(app_name: str):
"""Re-reconcile AppConfig methods"""
def wrapper_outer(func: Callable):
"""Re-reconcile AppConfig methods"""
@wraps(func)
def wrapper(*args, **kwargs):
config = apps.get_app_config(app_name)
if isinstance(config, ManagedAppConfig):
config.reconcile()
return func(*args, **kwargs)
return wrapper
return wrapper_outer

View File

@ -1,34 +0,0 @@
"""authentik managed models tests"""
from typing import Callable, Type
from django.apps import apps
from django.test import TestCase
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.lib.models import SerializerModel
class TestModels(TestCase):
"""Test Models"""
def serializer_tester_factory(test_model: Type[SerializerModel]) -> Callable:
"""Test serializer"""
def tester(self: TestModels):
if test_model._meta.abstract:
return
model_class = test_model()
self.assertTrue(isinstance(model_class, SerializerModel))
self.assertIsNotNone(model_class.serializer)
return tester
for app in apps.get_app_configs():
if not app.label.startswith("authentik"):
continue
for model in app.get_models():
if not is_model_allowed(model):
continue
setattr(TestModels, f"test_{app.label}_{model.__name__}", serializer_tester_factory(model))

View File

@ -1,97 +0,0 @@
"""Test blueprints OCI"""
from django.test import TransactionTestCase
from requests_mock import Mocker
from authentik.blueprints.models import OCI_MEDIA_TYPE, BlueprintInstance, BlueprintRetrievalFailed
class TestBlueprintOCI(TransactionTestCase):
"""Test Blueprints OCI Tasks"""
def test_successful(self):
"""Successful retrieval"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest",
json={
"layers": [
{
"mediaType": OCI_MEDIA_TYPE,
"digest": "foo",
}
]
},
)
mocker.get("https://ghcr.io/v2/goauthentik/blueprints/test/blobs/foo", text="foo")
self.assertEqual(
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci(),
"foo",
)
def test_manifests_error(self):
"""Test manifests request erroring"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest", status_code=401
)
with self.assertRaises(BlueprintRetrievalFailed):
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci()
def test_manifests_error_response(self):
"""Test manifests request erroring"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest",
json={"errors": ["foo"]},
)
with self.assertRaises(BlueprintRetrievalFailed):
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci()
def test_no_matching_blob(self):
"""Successful retrieval"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest",
json={
"layers": [
{
"mediaType": OCI_MEDIA_TYPE + "foo",
"digest": "foo",
}
]
},
)
with self.assertRaises(BlueprintRetrievalFailed):
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci()
def test_blob_error(self):
"""Successful retrieval"""
with Mocker() as mocker:
mocker.get(
"https://ghcr.io/v2/goauthentik/blueprints/test/manifests/latest",
json={
"layers": [
{
"mediaType": OCI_MEDIA_TYPE,
"digest": "foo",
}
]
},
)
mocker.get("https://ghcr.io/v2/goauthentik/blueprints/test/blobs/foo", status_code=401)
with self.assertRaises(BlueprintRetrievalFailed):
BlueprintInstance(
path="https://ghcr.io/goauthentik/blueprints/test:latest"
).retrieve_oci()

View File

@ -1,38 +0,0 @@
"""test packaged blueprints"""
from pathlib import Path
from typing import Callable
from django.test import TransactionTestCase
from authentik.blueprints.models import BlueprintInstance
from authentik.blueprints.tests import apply_blueprint
from authentik.blueprints.v1.importer import Importer
from authentik.tenants.models import Tenant
class TestPackaged(TransactionTestCase):
"""Empty class, test methods are added dynamically"""
@apply_blueprint("default/90-default-tenant.yaml")
def test_decorator_static(self):
"""Test @apply_blueprint decorator"""
self.assertTrue(Tenant.objects.filter(domain="authentik-default").exists())
def blueprint_tester(file_name: Path) -> Callable:
"""This is used instead of subTest for better visibility"""
def tester(self: TestPackaged):
base = Path("blueprints/")
rel_path = Path(file_name).relative_to(base)
importer = Importer(BlueprintInstance(path=str(rel_path)).retrieve())
self.assertTrue(importer.validate()[0])
self.assertTrue(importer.apply())
return tester
for blueprint_file in Path("blueprints/").glob("**/*.yaml"):
if "local" in str(blueprint_file):
continue
setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file))

View File

@ -1,45 +0,0 @@
"""Test blueprints v1 api"""
from json import loads
from tempfile import NamedTemporaryFile, mkdtemp
from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import dump
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.config import CONFIG
TMP = mkdtemp("authentik-blueprints")
class TestBlueprintsV1API(APITestCase):
"""Test Blueprints API"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.client.force_login(self.user)
@CONFIG.patch("blueprints_dir", TMP)
def test_api_available(self):
"""Test valid file"""
with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
file.write(
dump(
{
"version": 1,
"entries": [],
}
)
)
file.flush()
res = self.client.get(reverse("authentik_api:blueprintinstance-available"))
self.assertEqual(res.status_code, 200)
response = loads(res.content.decode())
self.assertEqual(len(response), 1)
self.assertEqual(
response[0]["hash"],
(
"e52bb445b03cd36057258dc9f0ce0fbed8278498ee1470e45315293e5f026d1bd1f9b352"
"6871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
),
)

View File

@ -1,148 +0,0 @@
"""Test blueprints v1 tasks"""
from hashlib import sha512
from tempfile import NamedTemporaryFile, mkdtemp
from django.test import TransactionTestCase
from yaml import dump
from authentik.blueprints.models import BlueprintInstance, BlueprintInstanceStatus
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_discover, blueprints_find
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
TMP = mkdtemp("authentik-blueprints")
class TestBlueprintsV1Tasks(TransactionTestCase):
"""Test Blueprints v1 Tasks"""
@CONFIG.patch("blueprints_dir", TMP)
def test_invalid_file_syntax(self):
"""Test syntactically invalid file"""
with NamedTemporaryFile(suffix=".yaml", dir=TMP) as file:
file.write(b"{")
file.flush()
blueprints = blueprints_find()
self.assertEqual(blueprints, [])
@CONFIG.patch("blueprints_dir", TMP)
def test_invalid_file_version(self):
"""Test invalid file"""
with NamedTemporaryFile(suffix=".yaml", dir=TMP) as file:
file.write(b"version: 2")
file.flush()
blueprints = blueprints_find()
self.assertEqual(blueprints, [])
@CONFIG.patch("blueprints_dir", TMP)
def test_valid(self):
"""Test valid file"""
blueprint_id = generate_id()
with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
file.write(
dump(
{
"version": 1,
"entries": [],
"metadata": {
"name": blueprint_id,
},
}
)
)
file.seek(0)
file_hash = sha512(file.read().encode()).hexdigest()
file.flush()
blueprints_discover() # pylint: disable=no-value-for-parameter
instance = BlueprintInstance.objects.filter(name=blueprint_id).first()
self.assertEqual(instance.last_applied_hash, file_hash)
self.assertEqual(
instance.metadata,
{
"name": blueprint_id,
"labels": {},
},
)
@CONFIG.patch("blueprints_dir", TMP)
def test_valid_updated(self):
"""Test valid file"""
with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
file.write(
dump(
{
"version": 1,
"entries": [],
}
)
)
file.flush()
blueprints_discover() # pylint: disable=no-value-for-parameter
self.assertEqual(
BlueprintInstance.objects.first().last_applied_hash,
(
"e52bb445b03cd36057258dc9f0ce0fbed8278498ee1470e45315293e5f026d1b"
"d1f9b3526871c0003f5c07be5c3316d9d4a08444bd8fed1b3f03294e51e44522"
),
)
self.assertEqual(BlueprintInstance.objects.first().metadata, {})
file.write(
dump(
{
"version": 1,
"entries": [],
"metadata": {
"name": "foo",
},
}
)
)
file.flush()
blueprints_discover() # pylint: disable=no-value-for-parameter
self.assertEqual(
BlueprintInstance.objects.first().last_applied_hash,
(
"fc62fea96067da8592bdf90927246d0ca150b045447df93b0652a0e20a8bc327"
"681510b5db37ea98759c61f9a98dd2381f46a3b5a2da69dfb45158897f14e824"
),
)
self.assertEqual(
BlueprintInstance.objects.first().metadata,
{
"name": "foo",
"labels": {},
},
)
@CONFIG.patch("blueprints_dir", TMP)
def test_valid_disabled(self):
"""Test valid file"""
with NamedTemporaryFile(mode="w+", suffix=".yaml", dir=TMP) as file:
file.write(
dump(
{
"version": 1,
"entries": [],
}
)
)
file.flush()
instance: BlueprintInstance = BlueprintInstance.objects.create(
name=generate_id(),
path=file.name,
enabled=False,
status=BlueprintInstanceStatus.UNKNOWN,
)
instance.refresh_from_db()
self.assertEqual(instance.last_applied_hash, "")
self.assertEqual(
instance.status,
BlueprintInstanceStatus.UNKNOWN,
)
apply_blueprint(instance.pk) # pylint: disable=no-value-for-parameter
instance.refresh_from_db()
self.assertEqual(instance.last_applied_hash, "")
self.assertEqual(
instance.status,
BlueprintInstanceStatus.UNKNOWN,
)

View File

@ -1,262 +0,0 @@
"""transfer common classes"""
from collections import OrderedDict
from dataclasses import asdict, dataclass, field, is_dataclass
from enum import Enum
from typing import Any, Optional
from uuid import UUID
from django.apps import apps
from django.db.models import Model, Q
from rest_framework.fields import Field
from rest_framework.serializers import Serializer
from yaml import SafeDumper, SafeLoader, ScalarNode, SequenceNode
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.models import PolicyBindingModel
def get_attrs(obj: SerializerModel) -> dict[str, Any]:
"""Get object's attributes via their serializer, and convert it to a normal dict"""
serializer: Serializer = obj.serializer(obj)
data = dict(serializer.data)
for field_name, _field in serializer.fields.items():
_field: Field
if field_name not in data:
continue
if _field.read_only:
data.pop(field_name, None)
if _field.get_initial() == data.get(field_name, None):
data.pop(field_name, None)
if field_name.endswith("_set"):
data.pop(field_name, None)
return data
@dataclass
class BlueprintEntryState:
"""State of a single instance"""
instance: Optional[Model] = None
@dataclass
class BlueprintEntry:
"""Single entry of a blueprint"""
model: str
identifiers: dict[str, Any] = field(default_factory=dict)
attrs: Optional[dict[str, Any]] = field(default_factory=dict)
# pylint: disable=invalid-name
id: Optional[str] = None
_state: BlueprintEntryState = field(default_factory=BlueprintEntryState)
@staticmethod
def from_model(model: SerializerModel, *extra_identifier_names: str) -> "BlueprintEntry":
"""Convert a SerializerModel instance to a blueprint Entry"""
identifiers = {
"pk": model.pk,
}
all_attrs = get_attrs(model)
for extra_identifier_name in extra_identifier_names:
identifiers[extra_identifier_name] = all_attrs.pop(extra_identifier_name, None)
return BlueprintEntry(
identifiers=identifiers,
model=f"{model._meta.app_label}.{model._meta.model_name}",
attrs=all_attrs,
)
def tag_resolver(self, value: Any, blueprint: "Blueprint") -> Any:
"""Check if we have any special tags that need handling"""
if isinstance(value, YAMLTag):
return value.resolve(self, blueprint)
if isinstance(value, dict):
for key, inner_value in value.items():
value[key] = self.tag_resolver(inner_value, blueprint)
if isinstance(value, list):
for idx, inner_value in enumerate(value):
value[idx] = self.tag_resolver(inner_value, blueprint)
return value
def get_attrs(self, blueprint: "Blueprint") -> dict[str, Any]:
"""Get attributes of this entry, with all yaml tags resolved"""
return self.tag_resolver(self.attrs, blueprint)
def get_identifiers(self, blueprint: "Blueprint") -> dict[str, Any]:
"""Get attributes of this entry, with all yaml tags resolved"""
return self.tag_resolver(self.identifiers, blueprint)
@dataclass
class BlueprintMetadata:
"""Optional blueprint metadata"""
name: str
labels: dict[str, str] = field(default_factory=dict)
@dataclass
class Blueprint:
"""Dataclass used for a full export"""
version: int = field(default=1)
entries: list[BlueprintEntry] = field(default_factory=list)
context: dict = field(default_factory=dict)
metadata: Optional[BlueprintMetadata] = field(default=None)
class YAMLTag:
"""Base class for all YAML Tags"""
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
"""Implement yaml tag logic"""
raise NotImplementedError
class KeyOf(YAMLTag):
"""Reference another object by their ID"""
id_from: str
# pylint: disable=unused-argument
def __init__(self, loader: "BlueprintLoader", node: ScalarNode) -> None:
super().__init__()
self.id_from = node.value
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
for _entry in blueprint.entries:
if _entry.id == self.id_from and _entry._state.instance:
# Special handling for PolicyBindingModels, as they'll have a different PK
# which is used when creating policy bindings
if (
isinstance(_entry._state.instance, PolicyBindingModel)
and entry.model.lower() == "authentik_policies.policybinding"
):
return _entry._state.instance.pbm_uuid
return _entry._state.instance.pk
raise EntryInvalidError(
f"KeyOf: failed to find entry with `id` of `{self.id_from}` and a model instance"
)
class Context(YAMLTag):
"""Lookup key from instance context"""
key: str
default: Optional[Any]
# pylint: disable=unused-argument
def __init__(self, loader: "BlueprintLoader", node: ScalarNode | SequenceNode) -> None:
super().__init__()
self.default = None
if isinstance(node, ScalarNode):
self.key = node.value
if isinstance(node, SequenceNode):
self.key = node.value[0].value
self.default = node.value[1].value
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
value = self.default
if self.key in blueprint.context:
value = blueprint.context[self.key]
return value
class Format(YAMLTag):
"""Format a string"""
format_string: str
args: list[Any]
# pylint: disable=unused-argument
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.format_string = node.value[0].value
self.args = []
for raw_node in node.value[1:]:
self.args.append(raw_node.value)
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
try:
return self.format_string % tuple(self.args)
except TypeError as exc:
raise EntryInvalidError(exc)
class Find(YAMLTag):
"""Find any object"""
model_name: str
conditions: list[list]
model_class: type[Model]
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.model_name = node.value[0].value
self.model_class = apps.get_model(*self.model_name.split("."))
self.conditions = []
for raw_node in node.value[1:]:
values = []
for node_values in raw_node.value:
values.append(loader.construct_object(node_values))
self.conditions.append(values)
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
query = Q()
for cond in self.conditions:
query &= Q(**{cond[0]: cond[1]})
instance = self.model_class.objects.filter(query).first()
if instance:
return instance.pk
return None
class BlueprintDumper(SafeDumper):
"""Dump dataclasses to yaml"""
default_flow_style = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_representer(UUID, lambda self, data: self.represent_str(str(data)))
self.add_representer(OrderedDict, lambda self, data: self.represent_dict(dict(data)))
self.add_representer(Enum, lambda self, data: self.represent_str(data.value))
self.add_representer(None, lambda self, data: self.represent_str(str(data)))
def represent(self, data) -> None:
if is_dataclass(data):
def factory(items):
final_dict = dict(items)
final_dict.pop("_state", None)
return final_dict
data = asdict(data, dict_factory=factory)
return super().represent(data)
class BlueprintLoader(SafeLoader):
"""Loader for blueprints with custom tag support"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_constructor("!KeyOf", KeyOf)
self.add_constructor("!Find", Find)
self.add_constructor("!Context", Context)
self.add_constructor("!Format", Format)
class EntryInvalidError(SentryIgnoredException):
"""Error raised when an entry is invalid"""
serializer_errors: Optional[dict]
def __init__(self, *args: object, serializer_errors: Optional[dict] = None) -> None:
super().__init__(*args)
self.serializer_errors = serializer_errors

View File

@ -1,149 +0,0 @@
"""Blueprint exporter"""
from typing import Iterable
from uuid import UUID
from django.apps import apps
from django.contrib.auth import get_user_model
from django.db.models import Model, Q, QuerySet
from django.utils.timezone import now
from django.utils.translation import gettext as _
from guardian.shortcuts import get_anonymous_user
from yaml import dump
from authentik.blueprints.v1.common import (
Blueprint,
BlueprintDumper,
BlueprintEntry,
BlueprintMetadata,
)
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_GENERATED
from authentik.events.models import Event
from authentik.flows.models import Flow, FlowStageBinding, Stage
from authentik.policies.models import Policy, PolicyBinding
from authentik.stages.prompt.models import PromptStage
class Exporter:
"""Export flow with attached stages into yaml"""
excluded_models: list[type[Model]] = []
def __init__(self):
self.excluded_models = [
Event,
]
def get_entries(self) -> Iterable[BlueprintEntry]:
"""Get blueprint entries"""
for model in apps.get_models():
if not is_model_allowed(model):
continue
if model in self.excluded_models:
continue
for obj in self.get_model_instances(model):
yield BlueprintEntry.from_model(obj)
def get_model_instances(self, model: type[Model]) -> QuerySet:
"""Return a queryset for `model`. Can be used to filter some
objects on some models"""
if model == get_user_model():
return model.objects.exclude(pk=get_anonymous_user().pk)
return model.objects.all()
def _pre_export(self, blueprint: Blueprint):
"""Hook to run anything pre-export"""
def export(self) -> Blueprint:
"""Create a list of all objects and create a blueprint"""
blueprint = Blueprint()
self._pre_export(blueprint)
blueprint.metadata = BlueprintMetadata(
name=_("authentik Export - %(date)s" % {"date": str(now())}),
labels={
LABEL_AUTHENTIK_GENERATED: "true",
},
)
blueprint.entries = list(self.get_entries())
return blueprint
def export_to_string(self) -> str:
"""Call export and convert it to yaml"""
blueprint = self.export()
return dump(blueprint, Dumper=BlueprintDumper)
class FlowExporter(Exporter):
"""Exporter customised to only return objects related to `flow`"""
flow: Flow
with_policies: bool
with_stage_prompts: bool
pbm_uuids: list[UUID]
def __init__(self, flow: Flow):
super().__init__()
self.flow = flow
self.with_policies = True
self.with_stage_prompts = True
def _pre_export(self, blueprint: Blueprint):
if not self.with_policies:
return
self.pbm_uuids = [self.flow.pbm_uuid]
self.pbm_uuids += FlowStageBinding.objects.filter(target=self.flow).values_list(
"pbm_uuid", flat=True
)
def walk_stages(self) -> Iterable[BlueprintEntry]:
"""Convert all stages attached to self.flow into BlueprintEntry objects"""
stages = Stage.objects.filter(flow=self.flow).select_related().select_subclasses()
for stage in stages:
if isinstance(stage, PromptStage):
pass
yield BlueprintEntry.from_model(stage, "name")
def walk_stage_bindings(self) -> Iterable[BlueprintEntry]:
"""Convert all bindings attached to self.flow into BlueprintEntry objects"""
bindings = FlowStageBinding.objects.filter(target=self.flow).select_related()
for binding in bindings:
yield BlueprintEntry.from_model(binding, "target", "stage", "order")
def walk_policies(self) -> Iterable[BlueprintEntry]:
"""Walk over all policies. This is done at the beginning of the export for stages that have
a direct foreign key to a policy."""
# Special case for PromptStage as that has a direct M2M to policy, we have to ensure
# all policies referenced in there we also include here
prompt_stages = PromptStage.objects.filter(flow=self.flow).values_list("pk", flat=True)
query = Q(bindings__in=self.pbm_uuids) | Q(promptstage__in=prompt_stages)
policies = Policy.objects.filter(query).select_related()
for policy in policies:
yield BlueprintEntry.from_model(policy)
def walk_policy_bindings(self) -> Iterable[BlueprintEntry]:
"""Walk over all policybindings relative to us. This is run at the end of the export, as
we are sure all objects exist now."""
bindings = PolicyBinding.objects.filter(target__in=self.pbm_uuids).select_related()
for binding in bindings:
yield BlueprintEntry.from_model(binding, "policy", "target", "order")
def walk_stage_prompts(self) -> Iterable[BlueprintEntry]:
"""Walk over all prompts associated with any PromptStages"""
prompt_stages = PromptStage.objects.filter(flow=self.flow)
for stage in prompt_stages:
for prompt in stage.fields.all():
yield BlueprintEntry.from_model(prompt)
def get_entries(self) -> Iterable[BlueprintEntry]:
entries = []
entries.append(BlueprintEntry.from_model(self.flow, "slug"))
if self.with_stage_prompts:
entries.extend(self.walk_stage_prompts())
if self.with_policies:
entries.extend(self.walk_policies())
entries.extend(self.walk_stages())
entries.extend(self.walk_stage_bindings())
if self.with_policies:
entries.extend(self.walk_policy_bindings())
return entries

View File

@ -1,263 +0,0 @@
"""Blueprint importer"""
from contextlib import contextmanager
from copy import deepcopy
from typing import Any, Optional
from dacite.core import from_dict
from dacite.exceptions import DaciteError
from deepmerge import always_merger
from django.db import transaction
from django.db.models import Model
from django.db.models.query_utils import Q
from django.db.utils import IntegrityError
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
from structlog.stdlib import BoundLogger, get_logger
from structlog.testing import capture_logs
from structlog.types import EventDict
from yaml import load
from authentik.blueprints.v1.common import (
Blueprint,
BlueprintEntry,
BlueprintEntryState,
BlueprintLoader,
EntryInvalidError,
)
from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry
from authentik.core.models import (
AuthenticatedSession,
PropertyMapping,
Provider,
Source,
UserSourceConnection,
)
from authentik.flows.models import Stage
from authentik.lib.models import SerializerModel
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
def is_model_allowed(model: type[Model]) -> bool:
"""Check if model is allowed"""
# pylint: disable=imported-auth-user
from django.contrib.auth.models import Group as DjangoGroup
from django.contrib.auth.models import User as DjangoUser
excluded_models = (
DjangoUser,
DjangoGroup,
# Base classes
Provider,
Source,
PropertyMapping,
UserSourceConnection,
Stage,
OutpostServiceConnection,
Policy,
PolicyBindingModel,
# Classes that have other dependencies
AuthenticatedSession,
)
return model not in excluded_models and issubclass(model, (SerializerModel, BaseMetaModel))
@contextmanager
def transaction_rollback():
"""Enters an atomic transaction and always triggers a rollback at the end of the block."""
atomic = transaction.atomic()
# pylint: disable=unnecessary-dunder-call
atomic.__enter__()
yield
atomic.__exit__(IntegrityError, None, None)
class Importer:
"""Import Blueprint from YAML"""
logger: BoundLogger
def __init__(self, yaml_input: str, context: Optional[dict] = None):
self.__pk_map: dict[Any, Model] = {}
self.logger = get_logger()
import_dict = load(yaml_input, BlueprintLoader)
try:
self.__import = from_dict(Blueprint, import_dict)
except DaciteError as exc:
raise EntryInvalidError from exc
context = {}
always_merger.merge(context, self.__import.context)
if context:
always_merger.merge(context, context)
self.__import.context = context
@property
def blueprint(self) -> Blueprint:
"""Get imported blueprint"""
return self.__import
def __update_pks_for_attrs(self, attrs: dict[str, Any]) -> dict[str, Any]:
"""Replace any value if it is a known primary key of an other object"""
def updater(value) -> Any:
if value in self.__pk_map:
self.logger.debug("updating reference in entry", value=value)
return self.__pk_map[value]
return value
for key, value in attrs.items():
try:
if isinstance(value, dict):
for idx, _inner_key in enumerate(value):
value[_inner_key] = updater(value[_inner_key])
elif isinstance(value, list):
for idx, _inner_value in enumerate(value):
attrs[key][idx] = updater(_inner_value)
else:
attrs[key] = updater(value)
except TypeError:
continue
return attrs
def __query_from_identifier(self, attrs: dict[str, Any]) -> Q:
"""Generate an or'd query from all identifiers in an entry"""
# Since identifiers can also be pk-references to other objects (see FlowStageBinding)
# we have to ensure those references are also replaced
main_query = Q()
if "pk" in attrs:
main_query = Q(pk=attrs["pk"])
sub_query = Q()
for identifier, value in attrs.items():
if isinstance(value, dict):
continue
if identifier == "pk":
continue
sub_query &= Q(**{identifier: value})
return main_query | sub_query
def _validate_single(self, entry: BlueprintEntry) -> BaseSerializer:
"""Validate a single entry"""
model_app_label, model_name = entry.model.split(".")
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
# Don't use isinstance since we don't want to check for inheritance
if not is_model_allowed(model):
raise EntryInvalidError(f"Model {model} not allowed")
if issubclass(model, BaseMetaModel):
serializer_class: type[Serializer] = model.serializer()
serializer = serializer_class(data=entry.get_attrs(self.__import))
try:
serializer.is_valid(raise_exception=True)
except ValidationError as exc:
raise EntryInvalidError(
f"Serializer errors {serializer.errors}", serializer_errors=serializer.errors
) from exc
return serializer
if entry.identifiers == {}:
raise EntryInvalidError("No identifiers")
# If we try to validate without referencing a possible instance
# we'll get a duplicate error, hence we load the model here and return
# the full serializer for later usage
# Because a model might have multiple unique columns, we chain all identifiers together
# to create an OR query.
updated_identifiers = self.__update_pks_for_attrs(entry.get_identifiers(self.__import))
for key, value in list(updated_identifiers.items()):
if isinstance(value, dict) and "pk" in value:
del updated_identifiers[key]
updated_identifiers[f"{key}"] = value["pk"]
existing_models = model.objects.filter(self.__query_from_identifier(updated_identifiers))
serializer_kwargs = {}
if not isinstance(model(), BaseMetaModel) and existing_models.exists():
model_instance = existing_models.first()
self.logger.debug(
"initialise serializer with instance",
model=model,
instance=model_instance,
pk=model_instance.pk,
)
serializer_kwargs["instance"] = model_instance
serializer_kwargs["partial"] = True
else:
self.logger.debug(
"initialised new serializer instance", model=model, **updated_identifiers
)
model_instance = model()
# pk needs to be set on the model instance otherwise a new one will be generated
if "pk" in updated_identifiers:
model_instance.pk = updated_identifiers["pk"]
serializer_kwargs["instance"] = model_instance
try:
full_data = self.__update_pks_for_attrs(entry.get_attrs(self.__import))
except ValueError as exc:
raise EntryInvalidError(exc) from exc
full_data.update(updated_identifiers)
serializer_kwargs["data"] = full_data
serializer: Serializer = model().serializer(**serializer_kwargs)
try:
serializer.is_valid(raise_exception=True)
except ValidationError as exc:
raise EntryInvalidError(
f"Serializer errors {serializer.errors}", serializer_errors=serializer.errors
) from exc
return serializer
def apply(self) -> bool:
"""Apply (create/update) models yaml, in database transaction"""
try:
with transaction.atomic():
if not self._apply_models():
self.logger.debug("Reverting changes due to error")
raise IntegrityError
except IntegrityError:
return False
else:
self.logger.debug("Committing changes")
return True
def _apply_models(self) -> bool:
"""Apply (create/update) models yaml"""
self.__pk_map = {}
for entry in self.__import.entries:
model_app_label, model_name = entry.model.split(".")
try:
model: type[SerializerModel] = registry.get_model(model_app_label, model_name)
except LookupError:
self.logger.warning(
"app or model does not exist", app=model_app_label, model=model_name
)
return False
# Validate each single entry
try:
serializer = self._validate_single(entry)
except EntryInvalidError as exc:
self.logger.warning(f"entry invalid: {exc}", entry=entry, error=exc)
return False
model = serializer.save()
if "pk" in entry.identifiers:
self.__pk_map[entry.identifiers["pk"]] = model.pk
entry._state = BlueprintEntryState(model)
self.logger.debug("updated model", model=model)
return True
def validate(self) -> tuple[bool, list[EventDict]]:
"""Validate loaded blueprint export, ensure all models are allowed
and serializers have no errors"""
self.logger.debug("Starting blueprint import validation")
orig_import = deepcopy(self.__import)
if self.__import.version != 1:
self.logger.warning("Invalid blueprint version")
return False, []
with (
transaction_rollback(),
capture_logs() as logs,
):
successful = self._apply_models()
if not successful:
self.logger.debug("Blueprint validation failed")
for log in logs:
getattr(self.logger, log.get("log_level"))(**log)
self.__import = orig_import
return successful, logs

View File

@ -1,5 +0,0 @@
"""Blueprint labels"""
LABEL_AUTHENTIK_SYSTEM = "blueprints.goauthentik.io/system"
LABEL_AUTHENTIK_INSTANTIATE = "blueprints.goauthentik.io/instantiate"
LABEL_AUTHENTIK_GENERATED = "blueprints.goauthentik.io/generated"

View File

@ -1,60 +0,0 @@
"""Apply Blueprint meta model"""
from typing import TYPE_CHECKING
from rest_framework.exceptions import ValidationError
from rest_framework.fields import BooleanField, JSONField
from structlog.stdlib import get_logger
from authentik.blueprints.v1.meta.registry import BaseMetaModel, MetaResult, registry
from authentik.core.api.utils import PassiveSerializer, is_dict
if TYPE_CHECKING:
from authentik.blueprints.models import BlueprintInstance
LOGGER = get_logger()
class ApplyBlueprintMetaSerializer(PassiveSerializer):
"""Serializer for meta apply blueprint model"""
identifiers = JSONField(validators=[is_dict])
required = BooleanField(default=True)
# We cannot override `instance` as that will confuse rest_framework
# and make it attempt to update the instance
blueprint_instance: "BlueprintInstance"
def validate(self, attrs):
from authentik.blueprints.models import BlueprintInstance
identifiers = attrs["identifiers"]
required = attrs["required"]
instance = BlueprintInstance.objects.filter(**identifiers).first()
if not instance and required:
raise ValidationError("Required blueprint does not exist")
self.blueprint_instance = instance
return super().validate(attrs)
def create(self, validated_data: dict) -> MetaResult:
from authentik.blueprints.v1.tasks import apply_blueprint
if not self.blueprint_instance:
LOGGER.info("Blueprint does not exist, but not required")
return MetaResult()
LOGGER.debug("Applying blueprint from meta model", blueprint=self.blueprint_instance)
# pylint: disable=no-value-for-parameter
apply_blueprint(str(self.blueprint_instance.pk))
return MetaResult()
@registry.register("metaapplyblueprint")
class MetaApplyBlueprint(BaseMetaModel):
"""Meta model to apply another blueprint"""
@staticmethod
def serializer() -> ApplyBlueprintMetaSerializer:
return ApplyBlueprintMetaSerializer
class Meta:
abstract = True

View File

@ -1,61 +0,0 @@
"""Base models"""
from django.apps import apps
from django.db.models import Model
from rest_framework.serializers import Serializer
class BaseMetaModel(Model):
"""Base models"""
@staticmethod
def serializer() -> Serializer:
"""Serializer similar to SerializerModel, but as a static method since
this is an abstract model"""
raise NotImplementedError
class Meta:
abstract = True
class MetaResult:
"""Result returned by Meta Models' serializers. Empty class but we can't return none as
the framework doesn't allow that"""
class MetaModelRegistry:
"""Registry for pseudo meta models"""
models: dict[str, BaseMetaModel]
virtual_prefix: str
def __init__(self, prefix: str) -> None:
self.models = {}
self.virtual_prefix = prefix
def register(self, model_id: str):
"""Register model class under `model_id`"""
def inner_wrapper(cls):
self.models[model_id] = cls
return cls
return inner_wrapper
def get_models(self):
"""Wrapper for django's `get_models` to list all models"""
models = apps.get_models()
for _, value in self.models.items():
models.append(value)
return models
def get_model(self, app_label: str, model_id: str) -> type[Model]:
"""Get model checks if any virtual models are registered, and falls back
to actual django models"""
if app_label.lower() == self.virtual_prefix:
if model_id.lower() in self.models:
return self.models[model_id]
return apps.get_model(app_label, model_id)
registry = MetaModelRegistry("authentik_blueprints")

View File

@ -1,181 +0,0 @@
"""v1 blueprints tasks"""
from dataclasses import asdict, dataclass, field
from hashlib import sha512
from pathlib import Path
from typing import Optional
from dacite.core import from_dict
from django.db import DatabaseError, InternalError, ProgrammingError
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from structlog.stdlib import get_logger
from yaml import load
from yaml.error import YAMLError
from authentik.blueprints.models import (
BlueprintInstance,
BlueprintInstanceStatus,
BlueprintRetrievalFailed,
)
from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata, EntryInvalidError
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE
from authentik.events.monitored_tasks import (
MonitoredTask,
TaskResult,
TaskResultStatus,
prefill_task,
)
from authentik.events.utils import sanitize_dict
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@dataclass
class BlueprintFile:
"""Basic info about a blueprint file"""
path: str
version: int
hash: str
last_m: int
meta: Optional[BlueprintMetadata] = field(default=None)
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError),
)
def blueprints_find_dict():
"""Find blueprints as `blueprints_find` does, but return a safe dict"""
blueprints = []
for blueprint in blueprints_find():
blueprints.append(sanitize_dict(asdict(blueprint)))
return blueprints
def blueprints_find():
"""Find blueprints and return valid ones"""
blueprints = []
root = Path(CONFIG.y("blueprints_dir"))
for file in root.glob("**/*.yaml"):
path = Path(file)
LOGGER.debug("found blueprint", path=str(path))
with open(path, "r", encoding="utf-8") as blueprint_file:
try:
raw_blueprint = load(blueprint_file.read(), BlueprintLoader)
except YAMLError as exc:
raw_blueprint = None
LOGGER.warning("failed to parse blueprint", exc=exc, path=str(path))
if not raw_blueprint:
continue
metadata = raw_blueprint.get("metadata", None)
version = raw_blueprint.get("version", 1)
if version != 1:
LOGGER.warning("invalid blueprint version", version=version, path=str(path))
continue
file_hash = sha512(path.read_bytes()).hexdigest()
blueprint = BlueprintFile(
str(path.relative_to(root)), version, file_hash, int(path.stat().st_mtime)
)
blueprint.meta = from_dict(BlueprintMetadata, metadata) if metadata else None
blueprints.append(blueprint)
LOGGER.info(
"parsed & loaded blueprint",
hash=file_hash,
path=str(path),
)
return blueprints
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError), base=MonitoredTask, bind=True
)
@prefill_task
def blueprints_discover(self: MonitoredTask):
"""Find blueprints and check if they need to be created in the database"""
count = 0
for blueprint in blueprints_find():
check_blueprint_v1_file(blueprint)
count += 1
self.set_status(
TaskResult(
TaskResultStatus.SUCCESSFUL,
messages=[_("Successfully imported %(count)d files." % {"count": count})],
)
)
def check_blueprint_v1_file(blueprint: BlueprintFile):
"""Check if blueprint should be imported"""
instance: BlueprintInstance = BlueprintInstance.objects.filter(path=blueprint.path).first()
if (
blueprint.meta
and blueprint.meta.labels.get(LABEL_AUTHENTIK_INSTANTIATE, "").lower() == "false"
):
return
if not instance:
instance = BlueprintInstance(
name=blueprint.meta.name if blueprint.meta else str(blueprint.path),
path=blueprint.path,
context={},
status=BlueprintInstanceStatus.UNKNOWN,
enabled=True,
managed_models=[],
metadata={},
)
instance.save()
if instance.last_applied_hash != blueprint.hash:
apply_blueprint.delay(str(instance.pk))
@CELERY_APP.task(
bind=True,
base=MonitoredTask,
)
def apply_blueprint(self: MonitoredTask, instance_pk: str):
"""Apply single blueprint"""
self.save_on_success = False
instance: Optional[BlueprintInstance] = None
try:
instance: BlueprintInstance = BlueprintInstance.objects.filter(pk=instance_pk).first()
self.set_uid(slugify(instance.name))
if not instance or not instance.enabled:
return
blueprint_content = instance.retrieve()
file_hash = sha512(blueprint_content.encode()).hexdigest()
importer = Importer(blueprint_content, instance.context)
if importer.blueprint.metadata:
instance.metadata = asdict(importer.blueprint.metadata)
valid, logs = importer.validate()
if not valid:
instance.status = BlueprintInstanceStatus.ERROR
instance.save()
self.set_status(TaskResult(TaskResultStatus.ERROR, [x["event"] for x in logs]))
return
applied = importer.apply()
if not applied:
instance.status = BlueprintInstanceStatus.ERROR
instance.save()
self.set_status(TaskResult(TaskResultStatus.ERROR, "Failed to apply"))
return
instance.status = BlueprintInstanceStatus.SUCCESSFUL
instance.last_applied_hash = file_hash
instance.last_applied = now()
self.set_status(TaskResult(TaskResultStatus.SUCCESSFUL))
except (
DatabaseError,
ProgrammingError,
InternalError,
IOError,
BlueprintRetrievalFailed,
EntryInvalidError,
) as exc:
if instance:
instance.status = BlueprintInstanceStatus.ERROR
self.set_status(TaskResult(TaskResultStatus.ERROR).with_error(exc))
finally:
if instance:
instance.save()

View File

@ -4,12 +4,10 @@ from typing import Optional
from django.core.cache import cache
from django.db.models import QuerySet
from django.http.response import HttpResponseBadRequest
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from guardian.shortcuts import get_objects_for_user
from drf_yasg import openapi
from drf_yasg.utils import no_body, swagger_auto_schema
from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField, SerializerMethodField
from rest_framework.fields import SerializerMethodField
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
@ -17,20 +15,13 @@ from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
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.core.api.providers import ProviderSerializer
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
from authentik.events.models import EventAction
from authentik.events.utils import sanitize_dict
from authentik.policies.api.exec import PolicyTestResultSerializer
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyResult
from authentik.stages.user_login.stage import USER_LOGIN_AUTHENTICATED
LOGGER = get_logger()
@ -44,16 +35,11 @@ class ApplicationSerializer(ModelSerializer):
"""Application Serializer"""
launch_url = SerializerMethodField()
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")
def get_launch_url(self, app: Application) -> Optional[str]:
"""Allow formatting of launch URL"""
user = None
if "request" in self.context:
user = self.context["request"].user
return app.get_launch_url(user)
def get_launch_url(self, instance: Application) -> Optional[str]:
"""Get generated launch URL"""
return instance.get_launch_url()
class Meta:
@ -65,23 +51,18 @@ class ApplicationSerializer(ModelSerializer):
"provider",
"provider_obj",
"launch_url",
"open_in_new_tab",
"meta_launch_url",
"meta_icon",
"meta_description",
"meta_publisher",
"policy_engine_mode",
"group",
]
extra_kwargs = {
"meta_icon": {"read_only": True},
}
class ApplicationViewSet(UsedByMixin, ModelViewSet):
class ApplicationViewSet(ModelViewSet):
"""Application Viewset"""
queryset = Application.objects.all().prefetch_related("provider")
queryset = Application.objects.all()
serializer_class = ApplicationSerializer
search_fields = [
"name",
@ -89,15 +70,6 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"meta_launch_url",
"meta_description",
"meta_publisher",
"group",
]
filterset_fields = [
"name",
"slug",
"meta_launch_url",
"meta_description",
"meta_publisher",
"group",
]
lookup_field = "slug"
ordering = ["name"]
@ -119,74 +91,29 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
applications.append(application)
return applications
@extend_schema(
parameters=[
OpenApiParameter(
name="for_user",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
)
],
responses={
200: PolicyTestResultSerializer(),
404: OpenApiResponse(description="for_user user not found"),
},
)
@action(detail=True, methods=["GET"])
def check_access(self, request: Request, slug: str) -> Response:
"""Check access to a single application by slug"""
# Don't use self.get_object as that checks for view_application permission
# which the user might not have, even if they have access
application = get_object_or_404(Application, slug=slug)
# If the current user is superuser, they can set `for_user`
for_user = request.user
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"))
except ValueError:
return HttpResponseBadRequest("for_user must be numerical")
engine = PolicyEngine(application, for_user, request)
engine.use_cache = False
with capture_logs() as logs:
engine.build()
result = engine.result
response = PolicyTestResultSerializer(PolicyResult(False))
if result.passing:
response = PolicyTestResultSerializer(PolicyResult(True))
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)
return Response(response.data)
@extend_schema(
parameters=[
OpenApiParameter(
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(
name="superuser_full_list",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.BOOL,
in_=openapi.IN_QUERY,
type=openapi.TYPE_BOOLEAN,
)
]
)
def list(self, request: Request) -> Response:
"""Custom list method that checks Policy based access instead of guardian"""
should_cache = request.GET.get("search", "") == ""
superuser_full_list = str(request.GET.get("superuser_full_list", "false")).lower() == "true"
if superuser_full_list and request.user.is_superuser:
return super().list(request)
# To prevent the user from having to double login when prompt is set to login
# and the user has just signed it. This session variable is set in the UserLoginStage
# and is (quite hackily) removed from the session in applications's API's List method
self.request.session.pop(USER_LOGIN_AUTHENTICATED, None)
queryset = self._filter_queryset_for_list(self.get_queryset())
self.paginate_queryset(queryset)
should_cache = request.GET.get("search", "") == ""
superuser_full_list = (
str(request.GET.get("superuser_full_list", "false")).lower() == "true"
)
if superuser_full_list and request.user.is_superuser:
serializer = self.get_serializer(queryset, many=True)
return self.get_paginated_response(serializer.data)
allowed_applications = []
if not should_cache:
allowed_applications = self._get_allowed_applications(queryset)
@ -204,14 +131,17 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
return self.get_paginated_response(serializer.data)
@permission_required("authentik_core.change_application")
@extend_schema(
request={
"multipart/form-data": FileUploadSerializer,
},
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
@swagger_auto_schema(
request_body=no_body,
manual_parameters=[
openapi.Parameter(
name="file",
in_=openapi.IN_FORM,
type=openapi.TYPE_FILE,
required=True,
)
],
responses={200: "Success", 400: "Bad request"},
)
@action(
detail=True,
@ -225,58 +155,24 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Set application icon"""
app: Application = self.get_object()
icon = request.FILES.get("file", None)
clear = request.data.get("clear", "false").lower() == "true"
if clear:
# .delete() saves the model by default
app.meta_icon.delete()
return Response({})
if icon:
app.meta_icon = icon
try:
app.save()
except PermissionError as exc:
LOGGER.warning("Failed to save icon", exc=exc)
return HttpResponseBadRequest()
return Response({})
return HttpResponseBadRequest()
@permission_required("authentik_core.change_application")
@extend_schema(
request=FilePathSerializer,
responses={
200: OpenApiResponse(description="Success"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
pagination_class=None,
filter_backends=[],
methods=["POST"],
)
# pylint: disable=unused-argument
def set_icon_url(self, request: Request, slug: str):
"""Set application icon (as URL)"""
app: Application = self.get_object()
url = request.data.get("url", None)
if url is None:
if not icon:
return HttpResponseBadRequest()
app.meta_icon.name = url
app.meta_icon = icon
app.save()
return Response({})
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
@extend_schema(responses={200: CoordinateSerializer(many=True)})
@permission_required(
"authentik_core.view_application", ["authentik_events.view_event"]
)
@swagger_auto_schema(responses={200: CoordinateSerializer(many=True)})
@action(detail=True, pagination_class=None, filter_backends=[])
# pylint: disable=unused-argument
def metrics(self, request: Request, slug: str):
"""Metrics for application logins"""
app = self.get_object()
return Response(
get_objects_for_user(request.user, "authentik_events.view_event")
.filter(
get_events_per_1h(
action=EventAction.AUTHORIZE_APPLICATION,
context__authorized_application__pk=app.pk.hex,
)
.get_events_per_hour()
)

View File

@ -1,113 +0,0 @@
"""AuthenticatedSessions API Viewset"""
from typing import Optional, TypedDict
from django_filters.rest_framework import DjangoFilterBackend
from guardian.utils import get_anonymous_user
from rest_framework import mixins
from rest_framework.fields import SerializerMethodField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser
from authentik.api.authorization import OwnerSuperuserPermissions
from authentik.core.api.used_by import UsedByMixin
from authentik.core.models import AuthenticatedSession
from authentik.events.geo import GEOIP_READER, GeoIPDict
class UserAgentDeviceDict(TypedDict):
"""User agent device"""
brand: str
family: str
model: str
class UserAgentOSDict(TypedDict):
"""User agent os"""
family: str
major: str
minor: str
patch: str
patch_minor: str
class UserAgentBrowserDict(TypedDict):
"""User agent browser"""
family: str
major: str
minor: str
patch: str
class UserAgentDict(TypedDict):
"""User agent details"""
device: UserAgentDeviceDict
os: UserAgentOSDict
user_agent: UserAgentBrowserDict
string: str
class AuthenticatedSessionSerializer(ModelSerializer):
"""AuthenticatedSession Serializer"""
current = SerializerMethodField()
user_agent = SerializerMethodField()
geo_ip = SerializerMethodField()
def get_current(self, instance: AuthenticatedSession) -> bool:
"""Check if session is currently active session"""
request: Request = self.context["request"]
return request._request.session.session_key == instance.session_key
def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict:
"""Get parsed user agent"""
return user_agent_parser.Parse(instance.last_user_agent)
def get_geo_ip(self, instance: AuthenticatedSession) -> Optional[GeoIPDict]: # pragma: no cover
"""Get parsed user agent"""
return GEOIP_READER.city_dict(instance.last_ip)
class Meta:
model = AuthenticatedSession
fields = [
"uuid",
"current",
"user_agent",
"geo_ip",
"user",
"last_ip",
"last_user_agent",
"last_used",
"expires",
]
class AuthenticatedSessionViewSet(
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""AuthenticatedSession Viewset"""
queryset = AuthenticatedSession.objects.all()
serializer_class = AuthenticatedSessionSerializer
search_fields = ["user__username", "last_ip", "last_user_agent"]
filterset_fields = ["user__username", "last_ip", "last_user_agent"]
ordering = ["user__username"]
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)

View File

@ -1,68 +0,0 @@
"""Authenticator Devices API Views"""
from django_otp import device_classes, devices_for_user
from django_otp.models import Device
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.fields import BooleanField, CharField, IntegerField, SerializerMethodField
from rest_framework.permissions import IsAdminUser, 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()
confirmed = BooleanField()
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)
class AdminDeviceViewSet(ViewSet):
"""Viewset for authenticator devices"""
serializer_class = DeviceSerializer
permission_classes = [IsAdminUser]
def get_devices(self, **kwargs):
"""Get all devices in all child classes"""
for model in device_classes():
device_set = model.objects.filter(**kwargs)
yield from device_set
@extend_schema(
parameters=[
OpenApiParameter(
name="user",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
)
],
responses={200: DeviceSerializer(many=True)},
)
def list(self, request: Request) -> Response:
"""Get all devices for current user"""
kwargs = {}
if "user" in request.query_params:
kwargs = {"user": request.query_params["user"]}
return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data)

View File

@ -1,136 +1,28 @@
"""Groups API Viewset"""
from json import loads
from django.db.models.query import QuerySet
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet
from rest_framework.fields import CharField, IntegerField, JSONField
from rest_framework.serializers import ListSerializer, ModelSerializer, ValidationError
from rest_framework.fields import JSONField
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import ModelViewSet
from rest_framework_guardian.filters import ObjectPermissionsFilter
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import is_dict
from authentik.core.models import Group, User
class GroupMemberSerializer(ModelSerializer):
"""Stripped down user serializer to show relevant users for groups"""
avatar = CharField(read_only=True)
attributes = JSONField(validators=[is_dict], required=False)
uid = CharField(read_only=True)
class Meta:
model = User
fields = [
"pk",
"username",
"name",
"is_active",
"last_login",
"email",
"avatar",
"attributes",
"uid",
]
from authentik.core.models import Group
class GroupSerializer(ModelSerializer):
"""Group Serializer"""
attributes = JSONField(validators=[is_dict], required=False)
users_obj = ListSerializer(
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:
model = Group
fields = [
"pk",
"num_pk",
"name",
"is_superuser",
"parent",
"parent_name",
"users",
"attributes",
"users_obj",
]
extra_kwargs = {
"users": {
"default": list,
}
}
fields = ["pk", "name", "is_superuser", "parent", "users", "attributes"]
class GroupFilter(FilterSet):
"""Filter for groups"""
attributes = CharFilter(
field_name="attributes",
lookup_expr="",
label="Attributes",
method="filter_attributes",
)
members_by_username = ModelMultipleChoiceFilter(
field_name="users__username",
to_field_name="username",
queryset=User.objects.all(),
)
members_by_pk = ModelMultipleChoiceFilter(
field_name="users",
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:
model = Group
fields = ["name", "is_superuser", "members_by_pk", "attributes", "members_by_username"]
class GroupViewSet(UsedByMixin, ModelViewSet):
class GroupViewSet(ModelViewSet):
"""Group Viewset"""
queryset = Group.objects.all().select_related("parent").prefetch_related("users")
queryset = Group.objects.all()
serializer_class = GroupSerializer
search_fields = ["name", "is_superuser"]
filterset_class = GroupFilter
filterset_fields = ["name", "is_superuser"]
ordering = ["name"]
def _filter_queryset_for_list(self, queryset: QuerySet) -> QuerySet:
"""Custom filter_queryset method which ignores guardian, but still supports sorting"""
for backend in list(self.filter_backends):
if backend == ObjectPermissionsFilter:
continue
queryset = backend().filter_queryset(self.request, queryset, self)
return queryset
def filter_queryset(self, queryset):
if self.request.user.has_perm("authentik_core.view_group"):
return self._filter_queryset_for_list(queryset)
return super().filter_queryset(queryset)

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