Compare commits

..

14 Commits

Author SHA1 Message Date
1a340eb437 web/flow/table-driven-executor
# What

- Replaces the *massive* switch/case block for stages with a lookup table.

# Why

- Because it was a massive switch/case block calling 22 different functions with the *exact same*
  signatures, and that created both a ton of duplication in the code as well as introduced last-line
  risks.
2024-11-21 14:25:02 -08:00
8982798c95 Merge branch 'main' into web/flow/table-driven-executor
* main: (23 commits)
  website/docs: update info about footer links to match new UI (#12120)
  website/docs: prepare release notes (#12142)
  providers/oauth2: fix migration (#12138)
  providers/oauth2: fix migration dependencies (#12123)
  web: bump API Client version (#12129)
  providers/oauth2: fix redirect uri input (#12122)
  providers/proxy: fix redirect_uri (#12121)
  website/docs: prepare release notes (#12119)
  web: bump API Client version (#12118)
  security: fix CVE 2024 52289 (#12113)
  security: fix CVE 2024 52307 (#12115)
  security: fix CVE 2024 52287 (#12114)
  website/docs: add CSP to hardening (#11970)
  core: bump uvicorn from 0.32.0 to 0.32.1 (#12103)
  core: bump google-api-python-client from 2.153.0 to 2.154.0 (#12104)
  core: bump pydantic from 2.9.2 to 2.10.0 (#12105)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in it (#12110)
  internal: add CSP header to files in `/media` (#12092)
  core, web: update translations (#12101)
  web: fix bug that prevented error reporting in current wizard. (#12033)
  ...
2024-11-21 13:57:01 -08:00
c3b33fdb07 web/legible/disambiguate-footer-links
# What

- Replaces the "brand links" box at the bottom of FlowExecutor with a component for showing brand
  links.

# Why

- Confusion arose about what "footer links" mean in any given context, and breaking this out,
  labeling it "brand-links," reduces that confusion. It also isolates and reduces the testable
  surface area of the Executor.
2024-11-21 09:59:52 -08:00
9809b94030 Merge branch 'main' into dev
* main: (28 commits)
  providers/scim: accept string and int for SCIM IDs (#12093)
  website: bump the docusaurus group in /website with 9 updates (#12086)
  core: fix source_flow_manager throwing error when authenticated user attempts to re-authenticate with existing link (#12080)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in de (#12079)
  scripts: remove read_replicas from generated dev config (#12078)
  core: bump geoip2 from 4.8.0 to 4.8.1 (#12071)
  core: bump goauthentik.io/api/v3 from 3.2024100.2 to 3.2024102.2 (#12072)
  core: bump maxmind/geoipupdate from v7.0.1 to v7.1.0 (#12073)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#12074)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#12075)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#12076)
  translate: Updates for file web/xliff/en.xlf in zh_CN (#12077)
  web/admin: auto-prefill user path for new users based on selected path (#12070)
  core: bump aiohttp from 3.10.2 to 3.10.11 (#12069)
  web/admin: fix brand title not respected in application list (#12068)
  core: bump pyjwt from 2.9.0 to 2.10.0 (#12063)
  web: add italian locale (#11958)
  web/admin: better footer links (#12004)
  core, web: update translations (#12052)
  core: bump twilio from 9.3.6 to 9.3.7 (#12061)
  ...
2024-11-20 10:23:54 -08:00
e7527c551b Merge branch 'main' into dev
* main:
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#12045)
  translate: Updates for file web/xliff/en.xlf in zh_CN (#12047)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#12044)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#12046)
  web/flows: fix invisible captcha call (#12048)
  rbac: fix incorrect object_description for object-level permissions (#12029)
  stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#12036)
  core: bump coverage from 7.6.4 to 7.6.5 (#12037)
  ci: bump codecov/codecov-action from 4 to 5 (#12038)
  release: 2024.10.2 (#12031)
2024-11-15 12:47:17 -08:00
36b10b434a :wqge branch 'main' into dev
* main:
  providers/ldap: fix global search_full_directory permission not being sufficient (#12028)
  website/docs: 2024.10.2 release notes (#12025)
  lifecycle: fix ak exit status not being passed (#12024)
  core: use versioned_script for path only (#12003)
  core, web: update translations (#12020)
  core: bump google-api-python-client from 2.152.0 to 2.153.0 (#12021)
  providers/oauth2: fix manual device code entry (#12017)
  crypto: validate that generated certificate's name is unique (#12015)
  core, web: update translations (#12006)
  core: bump google-api-python-client from 2.151.0 to 2.152.0 (#12007)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#12011)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#12010)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#12012)
  translate: Updates for file web/xliff/en.xlf in zh_CN (#12013)
  providers/proxy: fix Issuer when AUTHENTIK_HOST_BROWSER is set (#11968)
  website/docs: move S3 ad GeoIP to System Management/Operations (#11998)
  website/integrations: nextcloud: add SSE warning (#11976)
2024-11-14 11:29:13 -08:00
831797b871 Merge branch 'main' into dev
* main: (21 commits)
  web: bump API Client version (#11997)
  sources/kerberos: use new python-kadmin implementation (#11932)
  core: add ability to provide reason for impersonation (#11951)
  website/integrations:  update vcenter integration docs (#11768)
  core, web: update translations (#11995)
  website: bump postcss from 8.4.48 to 8.4.49 in /website (#11996)
  web: bump API Client version (#11992)
  blueprints: add default Password policy (#11793)
  stages/captcha: Run interactive captcha in Frame (#11857)
  core, web: update translations (#11979)
  core: bump packaging from 24.1 to 24.2 (#11985)
  core: bump ruff from 0.7.2 to 0.7.3 (#11986)
  core: bump msgraph-sdk from 1.11.0 to 1.12.0 (#11987)
  website: bump the docusaurus group in /website with 9 updates (#11988)
  website: bump postcss from 8.4.47 to 8.4.48 in /website (#11989)
  stages/password: use recovery flow from brand (#11953)
  core: bump golang.org/x/sync from 0.8.0 to 0.9.0 (#11962)
  web: bump cookie, swagger-client and express in /web (#11966)
  core, web: update translations (#11959)
  core: bump debugpy from 1.8.7 to 1.8.8 (#11961)
  ...
2024-11-12 10:08:48 -08:00
5cc2c0f45f Merge branch 'main' into dev
* main:
  ci: fix dockerfile warning (#11956)
2024-11-07 12:58:15 -08:00
32442766f4 Merge branch 'main' into dev
* main:
  website/docs: fix slug matching redirect URI causing broken refresh (#11950)
  website/integrations: jellyfin: update plugin catalog location (#11948)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in de (#11942)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#11946)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#11947)
  website/docs: clarify traefik ingress setup (#11938)
  core: bump importlib-metadata from 8.4.0 to 8.5.0 (#11934)
  web: bump API Client version (#11930)
  root: backport version bump `2024.10.1` (#11929)
  website/docs: `2024.10.1` Release Notes (#11926)
  website: bump path-to-regexp from 1.8.0 to 1.9.0 in /website (#11924)
  core: bump sentry-sdk from 2.17.0 to 2.18.0 (#11918)
  website: bump the docusaurus group in /website with 9 updates (#11917)
  core: bump goauthentik.io/api/v3 from 3.2024100.1 to 3.2024100.2 (#11915)
  core, web: update translations (#11914)
2024-11-07 08:08:46 -08:00
75790909a8 Merge branch 'main' into dev
* main:
  web: bump API Client version (#11909)
  enterprise/rac: fix API Schema for invalidation_flow (#11907)
2024-11-04 13:20:15 -08:00
e0d5df89ca Merge branch 'main' into dev
* main:
  core: add `None` check to a device's `extra_description` (#11904)
  providers/oauth2: fix size limited index for tokens (#11879)
  web: fix missing status code on failed build (#11903)
  website: bump docusaurus-theme-openapi-docs from 4.1.0 to 4.2.0 in /website (#11897)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in de (#11891)
  stages/authenticator_webauthn: Update FIDO MDS3 & Passkey aaguid blobs (#11884)
  translate: Updates for file web/xliff/en.xlf in tr (#11878)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in tr (#11866)
  core: bump google-api-python-client from 2.149.0 to 2.151.0 (#11885)
  core: bump selenium from 4.26.0 to 4.26.1 (#11886)
  core, web: update translations (#11896)
  website: bump docusaurus-plugin-openapi-docs from 4.1.0 to 4.2.0 in /website (#11898)
  core: bump watchdog from 5.0.3 to 6.0.0 (#11899)
  core: bump ruff from 0.7.1 to 0.7.2 (#11900)
  core: bump django-pglock from 1.6.2 to 1.7.0 (#11901)
  website/docs: fix release notes to say Federation (#11889)
2024-11-04 10:25:52 -08:00
f25a9c624e Merge branch 'main' into dev
* main:
  website: bump elliptic from 6.5.7 to 6.6.0 in /website (#11869)
  core: bump selenium from 4.25.0 to 4.26.0 (#11875)
  core: bump goauthentik.io/api/v3 from 3.2024083.14 to 3.2024100.1 (#11876)
  website/docs: add info about invalidation flow, default flows in general (#11800)
  website: fix docs redirect (#11873)
  website: remove RC disclaimer for version 2024.10 (#11871)
  website: update supported versions (#11841)
  web: bump API Client version (#11870)
  root: backport version bump 2024.10.0 (#11868)
  website/docs: 2024.8.4 release notes (#11862)
  web/admin: provide default invalidation flows for LDAP and Radius (#11861)
2024-11-04 10:25:45 -08:00
914993a788 Merge branch 'main' into dev
* main: (43 commits)
  core, web: update translations (#11858)
  web/admin: fix code-based MFA toggle not working in wizard (#11854)
  sources/kerberos: add kiprop to ignored system principals (#11852)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#11846)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in it (#11845)
  translate: Updates for file web/xliff/en.xlf in zh_CN (#11847)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#11848)
  translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#11849)
  translate: Updates for file web/xliff/en.xlf in it (#11850)
  website: 2024.10 Release Notes (#11839)
  translate: Updates for file web/xliff/en.xlf in zh-Hans (#11814)
  core, web: update translations (#11821)
  core: bump goauthentik.io/api/v3 from 3.2024083.13 to 3.2024083.14 (#11830)
  core: bump service-identity from 24.1.0 to 24.2.0 (#11831)
  core: bump twilio from 9.3.5 to 9.3.6 (#11832)
  core: bump pytest-randomly from 3.15.0 to 3.16.0 (#11833)
  website/docs: Update social-logins github (#11822)
  website/docs: remove � (#11823)
  lifecycle: fix kdc5-config missing (#11826)
  website/docs: update preview status of different features (#11817)
  ...
2024-10-30 10:04:59 -07:00
89dad07a66 web: Add InvalidationFlow to Radius Provider dialogues
## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.
2024-10-23 14:17:30 -07:00
1482 changed files with 61408 additions and 163529 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2025.4.0-rc1
current_version = 2024.10.2
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
@ -17,8 +17,6 @@ optional_value = final
[bumpversion:file:pyproject.toml]
[bumpversion:file:uv.lock]
[bumpversion:file:package.json]
[bumpversion:file:docker-compose.yml]
@ -32,5 +30,3 @@ optional_value = final
[bumpversion:file:internal/constants/constants.go]
[bumpversion:file:web/src/common/constants.ts]
[bumpversion:file:lifecycle/aws/template.yaml]

View File

@ -28,11 +28,7 @@ Output of docker-compose logs or kubectl logs respectively
**Version and Deployment (please complete the following information):**
<!--
Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/.
-->
- authentik version: [e.g. 2025.2.0]
- authentik version: [e.g. 2021.8.5]
- Deployment: [e.g. docker-compose, helm]
**Additional context**

View File

@ -1,22 +0,0 @@
---
name: Documentation issue
about: Suggest an improvement or report a problem
title: ""
labels: documentation
assignees: ""
---
**Do you see an area that can be clarified or expanded, a technical inaccuracy, or a broken link? Please describe.**
A clear and concise description of what the problem is, or where the document can be improved. Ex. I believe we need more details about [...]
**Provide the URL or link to the exact page in the documentation to which you are referring.**
If there are multiple pages, list them all, and be sure to state the header or section where the content is.
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the documentation issue here.
**Consider opening a PR!**
If the issue is one that you can fix, or even make a good pass at, we'd appreciate a PR. For more information about making a contribution to the docs, and using our Style Guide and our templates, refer to ["Writing documentation"](https://docs.goauthentik.io/docs/developer-docs/docs/writing-documentation).

View File

@ -20,12 +20,7 @@ Output of docker-compose logs or kubectl logs respectively
**Version and Deployment (please complete the following information):**
<!--
Notice: authentik supports installation via Docker, Kubernetes, and AWS CloudFormation only. Support is not available for other methods. For detailed installation and configuration instructions, please refer to the official documentation at https://docs.goauthentik.io/docs/install-config/.
-->
- authentik version: [e.g. 2025.2.0]
- authentik version: [e.g. 2021.8.5]
- Deployment: [e.g. docker-compose, helm]
**Additional context**

View File

@ -35,6 +35,14 @@ runs:
AUTHENTIK_OUTPOSTS__CONTAINER_IMAGE_BASE=ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
```
For arm64, use these values:
```shell
AUTHENTIK_IMAGE=ghcr.io/goauthentik/dev-server
AUTHENTIK_TAG=${{ inputs.tag }}-arm64
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>
@ -52,6 +60,18 @@ runs:
tag: ${{ inputs.tag }}
```
For arm64, use these values:
```yaml
authentik:
outposts:
container_image_base: ghcr.io/goauthentik/dev-%(type)s:gh-%(build_hash)s
global:
image:
repository: ghcr.io/goauthentik/dev-server
tag: ${{ inputs.tag }}-arm64
```
Afterwards, run the upgrade commands from the latest release notes.
</details>
edit-mode: replace

View File

@ -9,14 +9,11 @@ inputs:
image-arch:
required: false
description: "Docker image arch"
release:
required: true
description: "True if this is a release build, false if this is a dev/PR build"
outputs:
shouldPush:
description: "Whether to push the image or not"
value: ${{ steps.ev.outputs.shouldPush }}
shouldBuild:
description: "Whether to build image or not"
value: ${{ steps.ev.outputs.shouldBuild }}
sha:
description: "sha"
@ -32,24 +29,15 @@ outputs:
imageTags:
description: "Docker image tags"
value: ${{ steps.ev.outputs.imageTags }}
imageTagsJSON:
description: "Docker image tags, as a JSON array"
value: ${{ steps.ev.outputs.imageTagsJSON }}
attestImageNames:
description: "Docker image names used for attestation"
value: ${{ steps.ev.outputs.attestImageNames }}
cacheTo:
description: "cache-to value for the docker build step"
value: ${{ steps.ev.outputs.cacheTo }}
imageMainTag:
description: "Docker image main tag"
value: ${{ steps.ev.outputs.imageMainTag }}
imageMainName:
description: "Docker image main name"
value: ${{ steps.ev.outputs.imageMainName }}
imageBuildArgs:
description: "Docker image build args"
value: ${{ steps.ev.outputs.imageBuildArgs }}
runs:
using: "composite"
@ -60,8 +48,6 @@ runs:
env:
IMAGE_NAME: ${{ inputs.image-name }}
IMAGE_ARCH: ${{ inputs.image-arch }}
RELEASE: ${{ inputs.release }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
REF: ${{ github.ref }}
run: |
python3 ${{ github.action_path }}/push_vars.py

View File

@ -2,20 +2,12 @@
import configparser
import os
from json import dumps
from time import time
parser = configparser.ConfigParser()
parser.read(".bumpversion.cfg")
# Decide if we should push the image or not
should_push = True
if len(os.environ.get("DOCKER_USERNAME", "")) < 1:
# Don't push if we don't have DOCKER_USERNAME, i.e. no secrets are available
should_push = False
if os.environ.get("GITHUB_REPOSITORY").lower() == "goauthentik/authentik-internal":
# Don't push on the internal repo
should_push = False
should_build = str(len(os.environ.get("DOCKER_USERNAME", "")) > 0).lower()
branch_name = os.environ["GITHUB_REF"]
if os.environ.get("GITHUB_HEAD_REF", "") != "":
@ -44,11 +36,12 @@ if is_release:
]
if not prerelease:
image_tags += [
f"{name}:latest",
f"{name}:{version_family}",
]
else:
suffix = ""
if image_arch:
if image_arch and image_arch != "amd64":
suffix = f"-{image_arch}"
for name in image_names:
image_tags += [
@ -70,31 +63,12 @@ def get_attest_image_names(image_with_tags: list[str]):
return ",".join(set(image_tags))
# Generate `cache-to` param
cache_to = ""
if should_push:
_cache_tag = "buildcache"
if image_arch:
_cache_tag += f"-{image_arch}"
cache_to = f"type=registry,ref={get_attest_image_names(image_tags)}:{_cache_tag},mode=max"
image_build_args = []
if os.getenv("RELEASE", "false").lower() == "true":
image_build_args = [f"VERSION={os.getenv('REF')}"]
else:
image_build_args = [f"GIT_BUILD_HASH={sha}"]
image_build_args = "\n".join(image_build_args)
with open(os.environ["GITHUB_OUTPUT"], "a+", encoding="utf-8") as _output:
print(f"shouldPush={str(should_push).lower()}", file=_output)
print(f"shouldBuild={should_build}", file=_output)
print(f"sha={sha}", file=_output)
print(f"version={version}", file=_output)
print(f"prerelease={prerelease}", file=_output)
print(f"imageTags={','.join(image_tags)}", file=_output)
print(f"imageTagsJSON={dumps(image_tags)}", file=_output)
print(f"attestImageNames={get_attest_image_names(image_tags)}", file=_output)
print(f"imageMainTag={image_main_tag}", file=_output)
print(f"imageMainName={image_tags[0]}", file=_output)
print(f"cacheTo={cache_to}", file=_output)
print(f"imageBuildArgs={image_build_args}", file=_output)

View File

@ -1,18 +1,7 @@
#!/bin/bash -x
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Non-pushing PR
GITHUB_OUTPUT=/dev/stdout \
GITHUB_REF=ref \
GITHUB_SHA=sha \
IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
GITHUB_REPOSITORY=goauthentik/authentik \
python $SCRIPT_DIR/push_vars.py
# Pushing PR/main
GITHUB_OUTPUT=/dev/stdout \
GITHUB_REF=ref \
GITHUB_SHA=sha \
IMAGE_NAME=ghcr.io/goauthentik/server,beryju/authentik \
GITHUB_REPOSITORY=goauthentik/authentik \
DOCKER_USERNAME=foo \
python $SCRIPT_DIR/push_vars.py

View File

@ -9,22 +9,17 @@ inputs:
runs:
using: "composite"
steps:
- name: Install apt deps
- name: Install poetry & deps
shell: bash
run: |
pipx install poetry || true
sudo apt-get update
sudo apt-get install --no-install-recommends -y libpq-dev openssl libxmlsec1-dev pkg-config gettext libkrb5-dev krb5-kdc krb5-user krb5-admin-server
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Setup python
- name: Setup python and restore poetry
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install Python deps
shell: bash
run: uv sync --all-extras --dev --frozen
cache: "poetry"
- name: Setup node
uses: actions/setup-node@v4
with:
@ -35,18 +30,15 @@ runs:
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
- name: Setup docker cache
uses: ScribeMD/docker-cache@0.5.0
with:
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/docker-compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
- name: Setup dependencies
shell: bash
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/docker-compose.yml up -d
poetry install
cd web && npm ci
- name: Generate config
shell: uv run python {0}
shell: poetry run python {0}
run: |
from authentik.lib.generators import generate_id
from yaml import safe_dump

View File

@ -11,7 +11,7 @@ services:
- 5432:5432
restart: always
redis:
image: docker.io/library/redis:7
image: docker.io/library/redis
ports:
- 6379:6379
restart: always

View File

@ -1,32 +1,7 @@
akadmin
asgi
assertIn
authentik
authn
crate
docstrings
entra
goauthentik
gunicorn
hass
jwe
jwks
keypair
keypairs
kubernetes
oidc
ontext
openid
passwordless
plex
saml
scim
singed
slo
sso
totp
traefik
# https://github.com/codespell-project/codespell/issues/1224
upToDate
hass
warmup
webauthn
ontext
singed
assertIn

View File

@ -82,23 +82,7 @@ updates:
docusaurus:
patterns:
- "@docusaurus/*"
build:
patterns:
- "@swc/*"
- "swc-*"
- "lightningcss*"
- "@rspack/binding*"
- package-ecosystem: npm
directory: "/lifecycle/aws"
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
commit-message:
prefix: "lifecycle/aws:"
labels:
- dependencies
- package-ecosystem: uv
- package-ecosystem: pip
directory: "/"
schedule:
interval: daily

View File

@ -1,96 +0,0 @@
# Re-usable workflow for a single-architecture build
name: Single-arch Container build
on:
workflow_call:
inputs:
image_name:
required: true
type: string
image_arch:
required: true
type: string
runs-on:
required: true
type: string
registry_dockerhub:
default: false
type: boolean
registry_ghcr:
default: false
type: boolean
release:
default: false
type: boolean
outputs:
image-digest:
value: ${{ jobs.build.outputs.image-digest }}
jobs:
build:
name: Build ${{ inputs.image_arch }}
runs-on: ${{ inputs.runs-on }}
outputs:
image-digest: ${{ steps.push.outputs.digest }}
permissions:
# Needed to upload container images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3.6.0
- uses: docker/setup-buildx-action@v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ${{ inputs.image_name }}
image-arch: ${{ inputs.image_arch }}
release: ${{ inputs.release }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: make empty clients
if: ${{ inputs.release }}
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: generate ts client
if: ${{ !inputs.release }}
run: make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@v6
id: push
with:
context: .
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
build-args: |
${{ steps.ev.outputs.imageBuildArgs }}
tags: ${{ steps.ev.outputs.imageTags }}
platforms: linux/${{ inputs.image_arch }}
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
cache-to: ${{ steps.ev.outputs.cacheTo }}
- uses: actions/attest-build-provenance@v2
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

View File

@ -1,104 +0,0 @@
# Re-usable workflow for a multi-architecture build
name: Multi-arch container build
on:
workflow_call:
inputs:
image_name:
required: true
type: string
registry_dockerhub:
default: false
type: boolean
registry_ghcr:
default: true
type: boolean
release:
default: false
type: boolean
outputs: {}
jobs:
build-server-amd64:
uses: ./.github/workflows/_reusable-docker-build-single.yaml
secrets: inherit
with:
image_name: ${{ inputs.image_name }}
image_arch: amd64
runs-on: ubuntu-latest
registry_dockerhub: ${{ inputs.registry_dockerhub }}
registry_ghcr: ${{ inputs.registry_ghcr }}
release: ${{ inputs.release }}
build-server-arm64:
uses: ./.github/workflows/_reusable-docker-build-single.yaml
secrets: inherit
with:
image_name: ${{ inputs.image_name }}
image_arch: arm64
runs-on: ubuntu-22.04-arm
registry_dockerhub: ${{ inputs.registry_dockerhub }}
registry_ghcr: ${{ inputs.registry_ghcr }}
release: ${{ inputs.release }}
get-tags:
runs-on: ubuntu-latest
needs:
- build-server-amd64
- build-server-arm64
outputs:
tags: ${{ steps.ev.outputs.imageTagsJSON }}
shouldPush: ${{ steps.ev.outputs.shouldPush }}
steps:
- uses: actions/checkout@v4
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ${{ inputs.image_name }}
merge-server:
runs-on: ubuntu-latest
if: ${{ needs.get-tags.outputs.shouldPush == 'true' }}
needs:
- get-tags
- build-server-amd64
- build-server-arm64
strategy:
fail-fast: false
matrix:
tag: ${{ fromJson(needs.get-tags.outputs.tags) }}
steps:
- uses: actions/checkout@v4
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ${{ inputs.image_name }}
- name: Login to Docker Hub
if: ${{ inputs.registry_dockerhub }}
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: ${{ inputs.registry_ghcr }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: int128/docker-manifest-create-action@v2
id: build
with:
tags: ${{ matrix.tag }}
sources: |
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-amd64.outputs.image-digest }}
${{ steps.ev.outputs.attestImageNames }}@${{ needs.build-server-arm64.outputs.image-digest }}
- uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true

View File

@ -7,7 +7,6 @@ on:
workflow_dispatch:
jobs:
build:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
permissions:
id-token: write
@ -30,6 +29,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
cache: "poetry"
- name: Generate API Client
run: make gen-client-py
- name: Publish package

View File

@ -7,7 +7,6 @@ on:
workflow_dispatch:
jobs:
build:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:
- id: generate_token

View File

@ -1,46 +0,0 @@
name: authentik-ci-aws-cfn
on:
push:
branches:
- main
- next
- version-*
pull_request:
branches:
- main
- version-*
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
jobs:
check-changes-applied:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: actions/setup-node@v4
with:
node-version-file: lifecycle/aws/package.json
cache: "npm"
cache-dependency-path: lifecycle/aws/package-lock.json
- working-directory: lifecycle/aws/
run: |
npm ci
- name: Check changes have been applied
run: |
uv run make aws-cfn
git diff --exit-code
ci-aws-cfn-mark:
if: always()
needs:
- check-changes-applied
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}

View File

@ -1,28 +0,0 @@
---
name: authentik-ci-main-daily
on:
workflow_dispatch:
schedule:
# Every night at 3am
- cron: "0 3 * * *"
jobs:
test-container:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
version:
- docs
- version-2025-2
- version-2024-12
steps:
- uses: actions/checkout@v4
- run: |
current="$(pwd)"
dir="/tmp/authentik/${{ matrix.version }}"
mkdir -p $dir
cd $dir
wget https://${{ matrix.version }}.goauthentik.io/docker-compose.yml
${current}/scripts/test_docker.sh

View File

@ -34,7 +34,7 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run job
run: uv run make ci-${{ matrix.job }}
run: poetry run make ci-${{ matrix.job }}
test-migrations:
runs-on: ubuntu-latest
steps:
@ -42,48 +42,35 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- name: run migrations
run: uv run python -m lifecycle.migrate
test-make-seed:
runs-on: ubuntu-latest
steps:
- id: seed
run: |
echo "seed=$(printf "%d\n" "0x$(openssl rand -hex 4)")" >> "$GITHUB_OUTPUT"
outputs:
seed: ${{ steps.seed.outputs.seed }}
run: poetry run python -m lifecycle.migrate
test-migrations-from-stable:
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }}
runs-on: ubuntu-latest
timeout-minutes: 20
needs: test-make-seed
strategy:
fail-fast: false
matrix:
psql:
- 15-alpine
- 16-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: checkout stable
run: |
# Delete all poetry envs
rm -rf /home/runner/.cache/pypoetry
# Copy current, latest config to local
# Temporarly comment the .github backup while migrating to uv
cp authentik/lib/default.yml local.env.yml
# cp -R .github ..
cp -R .github ..
cp -R scripts ..
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
# rm -rf .github/ scripts/
# mv ../.github ../scripts .
rm -rf scripts/
mv ../scripts .
rm -rf .github/ scripts/
mv ../.github ../scripts .
- name: Setup authentik env (stable)
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
continue-on-error: true
- name: run migrations to stable
run: poetry run python -m lifecycle.migrate
- name: checkout current code
@ -93,34 +80,31 @@ jobs:
git reset --hard HEAD
git clean -d -fx .
git checkout $GITHUB_SHA
# Delete previous poetry env
rm -rf /home/runner/.cache/pypoetry/virtualenvs/*
- name: Setup authentik env (ensure latest deps are installed)
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
- name: migrate to latest
run: |
uv run python -m lifecycle.migrate
poetry run python -m lifecycle.migrate
- name: run tests
env:
# Test in the main database that we just migrated from the previous stable version
AUTHENTIK_POSTGRESQL__TEST__NAME: authentik
CI_TEST_SEED: ${{ needs.test-make-seed.outputs.seed }}
CI_RUN_ID: ${{ matrix.run_id }}
CI_TOTAL_RUNS: "5"
run: |
uv run make ci-test
poetry run make test
test-unittest:
name: test-unittest - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
name: test-unittest - PostgreSQL ${{ matrix.psql }}
runs-on: ubuntu-latest
timeout-minutes: 20
needs: test-make-seed
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
psql:
- 15-alpine
- 16-alpine
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@v4
- name: Setup authentik env
@ -128,12 +112,9 @@ jobs:
with:
postgresql_version: ${{ matrix.psql }}
- name: run unittest
env:
CI_TEST_SEED: ${{ needs.test-make-seed.outputs.seed }}
CI_RUN_ID: ${{ matrix.run_id }}
CI_TOTAL_RUNS: "5"
run: |
uv run make ci-test
poetry run make test
poetry run coverage xml
- if: ${{ always() }}
uses: codecov/codecov-action@v5
with:
@ -153,11 +134,11 @@ jobs:
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Create k8s Kind Cluster
uses: helm/kind-action@v1.12.0
uses: helm/kind-action@v1.10.0
- name: run integration
run: |
uv run coverage run manage.py test tests/integration
uv run coverage xml
poetry run coverage run manage.py test tests/integration
poetry run coverage xml
- if: ${{ always() }}
uses: codecov/codecov-action@v5
with:
@ -214,8 +195,8 @@ jobs:
npm run build
- name: run e2e
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
uv run coverage xml
poetry run coverage run manage.py test ${{ matrix.job.glob }}
poetry run coverage xml
- if: ${{ always() }}
uses: codecov/codecov-action@v5
with:
@ -228,7 +209,6 @@ jobs:
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
ci-core-mark:
if: always()
needs:
- lint
- test-migrations
@ -238,22 +218,70 @@ jobs:
- test-e2e
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}
- run: echo mark
build:
strategy:
fail-fast: false
matrix:
arch:
- amd64
- arm64
needs: ci-core-mark
runs-on: ubuntu-latest
permissions:
# Needed to upload container images to ghcr.io
# Needed to upload contianer images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
needs: ci-core-mark
uses: ./.github/workflows/_reusable-docker-build.yaml
secrets: inherit
with:
image_name: ghcr.io/goauthentik/dev-server
release: false
timeout-minutes: 120
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/dev-server
image-arch: ${{ matrix.arch }}
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: generate ts client
run: make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@v6
id: push
with:
context: .
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
tags: ${{ steps.ev.outputs.imageTags }}
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache
cache-to: ${{ steps.ev.outputs.shouldBuild == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-server:buildcache,mode=max' || '' }}
platforms: linux/${{ matrix.arch }}
- uses: actions/attest-build-provenance@v1
id: attest
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
pr-comment:
needs:
- build
@ -275,7 +303,7 @@ jobs:
with:
image-name: ghcr.io/goauthentik/dev-server
- name: Comment on PR
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
uses: ./.github/actions/comment-pr-instructions
with:
tag: ${{ steps.ev.outputs.imageMainTag }}

View File

@ -29,7 +29,7 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
uses: golangci/golangci-lint-action@v6
with:
version: latest
args: --timeout 5000s --verbose
@ -49,15 +49,12 @@ jobs:
run: |
go test -timeout 0 -v -race -coverprofile=coverage.out -covermode=atomic -cover ./...
ci-outpost-mark:
if: always()
needs:
- lint-golint
- test-unittest
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}
- run: echo mark
build-container:
timeout-minutes: 120
needs:
@ -72,7 +69,7 @@ jobs:
- rac
runs-on: ubuntu-latest
permissions:
# Needed to upload container images to ghcr.io
# Needed to upload contianer images to ghcr.io
packages: write
# Needed for attestation
id-token: write
@ -82,7 +79,7 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
@ -93,7 +90,7 @@ jobs:
with:
image-name: ghcr.io/goauthentik/dev-${{ matrix.type }}
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
@ -107,16 +104,16 @@ jobs:
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: ${{ matrix.type }}.Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
push: ${{ steps.ev.outputs.shouldBuild == 'true' }}
build-args: |
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: linux/amd64,linux/arm64
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
- uses: actions/attest-build-provenance@v2
cache-to: ${{ steps.ev.outputs.shouldBuild == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
- uses: actions/attest-build-provenance@v1
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
if: ${{ steps.ev.outputs.shouldBuild == 'true' }}
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}

View File

@ -61,15 +61,12 @@ jobs:
working-directory: web/
run: npm run build
ci-web-mark:
if: always()
needs:
- build
- lint
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}
- run: echo mark
test:
needs:
- ci-web-mark

View File

@ -62,13 +62,10 @@ jobs:
working-directory: website/
run: npm run ${{ matrix.job }}
ci-website-mark:
if: always()
needs:
- lint
- test
- build
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1
with:
jobs: ${{ toJSON(needs) }}
- run: echo mark

View File

@ -2,7 +2,7 @@ name: authentik-gen-update-webauthn-mds
on:
workflow_dispatch:
schedule:
- cron: "30 1 1,15 * *"
- cron: '30 1 1,15 * *'
env:
POSTGRES_DB: authentik
@ -11,7 +11,6 @@ env:
jobs:
build:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:
- id: generate_token
@ -24,7 +23,7 @@ jobs:
token: ${{ steps.generate_token.outputs.token }}
- name: Setup authentik env
uses: ./.github/actions/setup
- run: uv run ak update_webauthn_mds
- run: poetry run ak update_webauthn_mds
- uses: peter-evans/create-pull-request@v7
id: cpr
with:

View File

@ -7,7 +7,6 @@ on:
jobs:
clean-ghcr:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
name: Delete old unused container images
runs-on: ubuntu-latest
steps:

View File

@ -1,45 +0,0 @@
name: authentik-packages-npm-publish
on:
push:
branches: [main]
paths:
- packages/docusaurus-config/**
- packages/eslint-config/**
- packages/prettier-config/**
- packages/tsconfig/**
workflow_dispatch:
jobs:
publish:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
package:
- docusaurus-config
- eslint-config
- prettier-config
- tsconfig
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2
- uses: actions/setup-node@v4
with:
node-version-file: packages/${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c
with:
files: |
packages/${{ matrix.package }}/package.json
- name: Publish package
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: packages/${{ matrix.package}}
run: |
npm ci
npm run build
npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}

View File

@ -12,7 +12,6 @@ env:
jobs:
publish-source-docs:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
@ -21,8 +20,8 @@ jobs:
uses: ./.github/actions/setup
- name: generate docs
run: |
uv run make migrate
uv run ak build_source_docs
poetry run make migrate
poetry run ak build_source_docs
- name: Publish
uses: netlify/actions/cli@master
with:

View File

@ -11,7 +11,6 @@ permissions:
jobs:
update-next:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
environment: internal-production
steps:

View File

@ -7,23 +7,64 @@ on:
jobs:
build-server:
uses: ./.github/workflows/_reusable-docker-build.yaml
secrets: inherit
runs-on: ubuntu-latest
permissions:
# Needed to upload container images to ghcr.io
# Needed to upload contianer images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
with:
image_name: ghcr.io/goauthentik/server,beryju/authentik
release: true
registry_dockerhub: true
registry_ghcr: true
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
uses: ./.github/actions/docker-push-variables
id: ev
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/server,beryju/authentik
- name: Docker Login Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: make empty clients
run: |
mkdir -p ./gen-ts-api
mkdir -p ./gen-go-api
- name: Build Docker Image
uses: docker/build-push-action@v6
id: push
with:
context: .
push: true
secrets: |
GEOIPUPDATE_ACCOUNT_ID=${{ secrets.GEOIPUPDATE_ACCOUNT_ID }}
GEOIPUPDATE_LICENSE_KEY=${{ secrets.GEOIPUPDATE_LICENSE_KEY }}
build-args: |
VERSION=${{ github.ref }}
tags: ${{ steps.ev.outputs.imageTags }}
platforms: linux/amd64,linux/arm64
- uses: actions/attest-build-provenance@v1
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
build-outpost:
runs-on: ubuntu-latest
permissions:
# Needed to upload container images to ghcr.io
# Needed to upload contianer images to ghcr.io
packages: write
# Needed for attestation
id-token: write
@ -42,7 +83,7 @@ jobs:
with:
go-version-file: "go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.0
uses: docker/setup-qemu-action@v3.2.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: prepare variables
@ -78,7 +119,7 @@ jobs:
file: ${{ matrix.type }}.Dockerfile
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@v2
- uses: actions/attest-build-provenance@v1
id: attest
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
@ -128,27 +169,6 @@ jobs:
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
tag: ${{ github.ref }}
upload-aws-cfn-template:
permissions:
# Needed for AWS login
id-token: write
contents: read
needs:
- build-server
- build-outpost
env:
AWS_REGION: eu-central-1
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
aws-region: ${{ env.AWS_REGION }}
- name: Upload template
run: |
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.${{ github.ref }}.yaml
aws s3 cp --acl=public-read lifecycle/aws/template.yaml s3://authentik-cloudformation-templates/authentik.ecs.latest.yaml
test-release:
needs:
- build-server
@ -186,7 +206,7 @@ jobs:
container=$(docker container create ${{ steps.ev.outputs.imageMainName }})
docker cp ${container}:web/ .
- name: Create a Sentry.io release
uses: getsentry/action-release@v3
uses: getsentry/action-release@v1
continue-on-error: true
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@ -14,7 +14,16 @@ jobs:
- uses: actions/checkout@v4
- name: Pre-release test
run: |
make test-docker
echo "PG_PASS=$(openssl rand 32 | base64 -w 0)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(openssl rand 32 | base64 -w 0)" >> .env
docker buildx install
mkdir -p ./gen-ts-api
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
- id: generate_token
uses: tibdex/github-app-token@v2
with:

View File

@ -1,21 +0,0 @@
name: "authentik-repo-mirror"
on: [push, delete]
jobs:
to_internal:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- if: ${{ env.MIRROR_KEY != '' }}
uses: pixta-dev/repository-mirroring-action@v1
with:
target_repo_url:
git@github.com:goauthentik/authentik-internal.git
ssh_private_key:
${{ secrets.GH_MIRROR_KEY }}
env:
MIRROR_KEY: ${{ secrets.GH_MIRROR_KEY }}

View File

@ -1,8 +1,8 @@
name: "authentik-repo-stale"
name: 'authentik-repo-stale'
on:
schedule:
- cron: "30 1 * * *"
- cron: '30 1 * * *'
workflow_dispatch:
permissions:
@ -11,7 +11,6 @@ permissions:
jobs:
stale:
if: ${{ github.repository != 'goauthentik/authentik-internal' }}
runs-on: ubuntu-latest
steps:
- id: generate_token
@ -25,7 +24,7 @@ jobs:
days-before-stale: 60
days-before-close: 7
exempt-issue-labels: pinned,security,pr_wanted,enhancement,bug/confirmed,enhancement/confirmed,question,status/reviewing
stale-issue-label: status/stale
stale-issue-label: wontfix
stale-issue-message: >
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

View File

@ -1,27 +0,0 @@
name: authentik-semgrep
on:
workflow_dispatch: {}
pull_request: {}
push:
branches:
- main
- master
paths:
- .github/workflows/semgrep.yml
schedule:
# random HH:MM to avoid a load spike on GitHub Actions at 00:00
- cron: '12 15 * * *'
jobs:
semgrep:
name: semgrep/ci
runs-on: ubuntu-latest
permissions:
contents: read
env:
SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
container:
image: semgrep/semgrep
if: (github.actor != 'dependabot[bot]')
steps:
- uses: actions/checkout@v4
- run: semgrep ci

View File

@ -1,13 +1,9 @@
---
name: authentik-translate-extract-compile
name: authentik-backend-translate-extract-compile
on:
schedule:
- cron: "0 0 * * *" # every day at midnight
workflow_dispatch:
pull_request:
branches:
- main
- version-*
env:
POSTGRES_DB: authentik
@ -19,30 +15,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- id: generate_token
if: ${{ github.event_name != 'pull_request' }}
uses: tibdex/github-app-token@v2
with:
app_id: ${{ secrets.GH_APP_ID }}
private_key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/checkout@v4
if: ${{ github.event_name != 'pull_request' }}
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: actions/checkout@v4
if: ${{ github.event_name == 'pull_request' }}
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Generate API
run: make gen-client-ts
- name: run extract
run: |
uv run make i18n-extract
poetry run make i18n-extract
- name: run compile
run: |
uv run ak compilemessages
poetry run ak compilemessages
make web-check-compile
- name: Create Pull Request
if: ${{ github.event_name != 'pull_request' }}
uses: peter-evans/create-pull-request@v7
with:
token: ${{ steps.generate_token.outputs.token }}

8
.gitignore vendored
View File

@ -11,10 +11,6 @@ local_settings.py
db.sqlite3
media
# Node
node_modules
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/
@ -37,7 +33,6 @@ eggs/
lib64/
parts/
dist/
out/
sdist/
var/
wheels/
@ -214,6 +209,3 @@ source_docs/
### Golang ###
/vendor/
### Docker ###
docker-compose.override.yml

View File

@ -1,47 +0,0 @@
# Prettier Ignorefile
## Static Files
**/LICENSE
authentik/stages/**/*
## Build asset directories
coverage
dist
out
.docusaurus
website/docs/developer-docs/api/**/*
## Environment
*.env
## Secrets
*.secrets
## Yarn
.yarn/**/*
## Node
node_modules
coverage
## Configs
*.log
*.yaml
*.yml
# Templates
# TODO: Rename affected files to *.template.* or similar.
*.html
*.mdx
*.md
## Import order matters
poly.ts
src/locale-codes.ts
src/locales/
# Storybook
storybook-static/
.storybook/css-import-maps*

View File

@ -2,7 +2,6 @@
"recommendations": [
"bashmish.es6-string-css",
"bpruitt-goddard.mermaid-markdown-syntax-highlighting",
"charliermarsh.ruff",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
@ -11,12 +10,12 @@
"Gruntfuggly.todo-tree",
"mechatroner.rainbow-csv",
"ms-python.black-formatter",
"ms-python.black-formatter",
"ms-python.debugpy",
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.black-formatter",
"redhat.vscode-yaml",
"Tobermory.es6-string-html",
"unifiedjs.vscode-mdx",
"unifiedjs.vscode-mdx"
]
}

66
.vscode/launch.json vendored
View File

@ -2,76 +2,26 @@
"version": "0.2.0",
"configurations": [
{
"name": "Debug: Attach Server Core",
"type": "debugpy",
"name": "Python: PDB attach Server",
"type": "python",
"request": "attach",
"connect": {
"host": "localhost",
"port": 9901
"port": 6800
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "."
}
],
"justMyCode": true,
"django": true
},
{
"name": "Debug: Attach Worker",
"type": "debugpy",
"name": "Python: PDB attach Worker",
"type": "python",
"request": "attach",
"connect": {
"host": "localhost",
"port": 9901
"port": 6900
},
"pathMappings": [
{
"localRoot": "${workspaceFolder}",
"remoteRoot": "."
}
],
"justMyCode": true,
"django": true
},
{
"name": "Debug: Start Server Router",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/server",
"cwd": "${workspaceFolder}"
},
{
"name": "Debug: Start LDAP Outpost",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/ldap",
"cwd": "${workspaceFolder}"
},
{
"name": "Debug: Start Proxy Outpost",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/proxy",
"cwd": "${workspaceFolder}"
},
{
"name": "Debug: Start RAC Outpost",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/rac",
"cwd": "${workspaceFolder}"
},
{
"name": "Debug: Start Radius Outpost",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}/cmd/radius",
"cwd": "${workspaceFolder}"
}
]
}

25
.vscode/settings.json vendored
View File

@ -1,4 +1,26 @@
{
"cSpell.words": [
"akadmin",
"asgi",
"authentik",
"authn",
"entra",
"goauthentik",
"jwe",
"jwks",
"kubernetes",
"oidc",
"openid",
"passwordless",
"plex",
"saml",
"scim",
"slo",
"sso",
"totp",
"traefik",
"webauthn"
],
"todo-tree.tree.showCountsInTree": true,
"todo-tree.tree.showBadges": true,
"yaml.customTags": [
@ -11,8 +33,7 @@
"!If sequence",
"!Index scalar",
"!KeyOf scalar",
"!Value scalar",
"!AtIndex scalar"
"!Value scalar"
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index",

46
.vscode/tasks.json vendored
View File

@ -3,13 +3,8 @@
"tasks": [
{
"label": "authentik/core: make",
"command": "uv",
"args": [
"run",
"make",
"lint-fix",
"lint"
],
"command": "poetry",
"args": ["run", "make", "lint-fix", "lint"],
"presentation": {
"panel": "new"
},
@ -17,12 +12,8 @@
},
{
"label": "authentik/core: run",
"command": "uv",
"args": [
"run",
"ak",
"server"
],
"command": "poetry",
"args": ["run", "ak", "server"],
"group": "build",
"presentation": {
"panel": "dedicated",
@ -32,17 +23,13 @@
{
"label": "authentik/web: make",
"command": "make",
"args": [
"web"
],
"args": ["web"],
"group": "build"
},
{
"label": "authentik/web: watch",
"command": "make",
"args": [
"web-watch"
],
"args": ["web-watch"],
"group": "build",
"presentation": {
"panel": "dedicated",
@ -52,26 +39,19 @@
{
"label": "authentik: install",
"command": "make",
"args": [
"install",
"-j4"
],
"args": ["install", "-j4"],
"group": "build"
},
{
"label": "authentik/website: make",
"command": "make",
"args": [
"website"
],
"args": ["website"],
"group": "build"
},
{
"label": "authentik/website: watch",
"command": "make",
"args": [
"website-watch"
],
"args": ["website-watch"],
"group": "build",
"presentation": {
"panel": "dedicated",
@ -80,12 +60,8 @@
},
{
"label": "authentik/api: generate",
"command": "uv",
"args": [
"run",
"make",
"gen"
],
"command": "poetry",
"args": ["run", "make", "gen"],
"group": "build"
}
]

View File

@ -10,30 +10,19 @@ schemas/ @goauthentik/backend
scripts/ @goauthentik/backend
tests/ @goauthentik/backend
pyproject.toml @goauthentik/backend
uv.lock @goauthentik/backend
poetry.lock @goauthentik/backend
go.mod @goauthentik/backend
go.sum @goauthentik/backend
# Infrastructure
.github/ @goauthentik/infrastructure
lifecycle/aws/ @goauthentik/infrastructure
Dockerfile @goauthentik/infrastructure
*Dockerfile @goauthentik/infrastructure
.dockerignore @goauthentik/infrastructure
docker-compose.yml @goauthentik/infrastructure
Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure
# Web packages
packages/ @goauthentik/frontend
# Web
web/ @goauthentik/frontend
tests/wdio/ @goauthentik/frontend
# Locale
locale/ @goauthentik/backend @goauthentik/frontend
web/xliff/ @goauthentik/backend @goauthentik/frontend
# Docs & Website
website/ @goauthentik/docs
CODE_OF_CONDUCT.md @goauthentik/docs
# Security
SECURITY.md @goauthentik/security @goauthentik/docs
website/docs/security/ @goauthentik/security @goauthentik/docs
website/docs/security/ @goauthentik/security

View File

@ -5,7 +5,7 @@
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, socioeconomic status,
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.

View File

@ -1 +1 @@
website/docs/developer-docs/index.md
website/developer-docs/index.md

View File

@ -43,7 +43,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build
# Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder
ARG TARGETOS
ARG TARGETARCH
@ -76,7 +76,7 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP
@ -93,59 +93,38 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
mkdir -p /usr/share/GeoIP && \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.6.16 AS uv
# Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_NATIVE_TLS=1 \
UV_PYTHON_DOWNLOADS=0
WORKDIR /ak-root/
COPY --from=uv /uv /uvx /bin/
# Stage 7: Python dependencies
FROM python-base AS python-deps
# Stage 5: Python dependencies
FROM ghcr.io/goauthentik/fips-python:3.12.7-slim-bookworm-fips-full AS python-deps
ARG TARGETARCH
ARG TARGETVARIANT
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
WORKDIR /ak-root/poetry
ENV PATH="/root/.cargo/bin:$PATH"
ENV VENV_PATH="/ak-root/venv" \
POETRY_VIRTUALENVS_CREATE=false \
PATH="/ak-root/venv/bin:$PATH"
RUN rm -f /etc/apt/apt.conf.d/docker-clean; echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,id=apt-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/var/cache/apt \
apt-get update && \
# Required for installing pip packages
apt-get install -y --no-install-recommends \
# Build essentials
build-essential pkg-config libffi-dev git \
# cryptography
curl \
# libxml
libxslt-dev zlib1g-dev \
# postgresql
libpq-dev \
# python-kadmin-rs
clang libkrb5-dev sccache \
# xmlsec
libltdl-dev && \
curl https://sh.rustup.rs -sSf | sh -s -- -y
apt-get install -y --no-install-recommends build-essential pkg-config libpq-dev libkrb5-dev
ENV UV_NO_BINARY_PACKAGE="cryptography lxml python-kadmin-rs xmlsec"
RUN --mount=type=bind,target=./pyproject.toml,src=./pyproject.toml \
--mount=type=bind,target=./poetry.lock,src=./poetry.lock \
--mount=type=cache,target=/root/.cache/pip \
--mount=type=cache,target=/root/.cache/pypoetry \
python -m venv /ak-root/venv/ && \
bash -c "source ${VENV_PATH}/bin/activate && \
pip3 install --upgrade pip && \
pip3 install poetry && \
poetry install --only=main --no-ansi --no-interaction --no-root && \
pip install --force-reinstall /wheels/*"
RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
--mount=type=bind,target=uv.lock,src=uv.lock \
--mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev
# Stage 8: Run
FROM python-base AS final-image
# Stage 6: Run
FROM ghcr.io/goauthentik/fips-python:3.12.7-slim-bookworm-fips-full AS final-image
ARG VERSION
ARG GIT_BUILD_HASH
@ -161,12 +140,10 @@ WORKDIR /
# We cannot cache this layer otherwise we'll end up with a bigger image
RUN apt-get update && \
apt-get upgrade -y && \
# Required for runtime
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 libltdl7 libxslt1.1 && \
apt-get install -y --no-install-recommends libpq5 libmaxminddb0 ca-certificates libkrb5-3 libkadm5clnt-mit12 libkdb5-10 && \
# Required for bootstrap & healtcheck
apt-get install -y --no-install-recommends runit && \
pip3 install --no-cache-dir --upgrade pip && \
apt-get clean && \
rm -rf /tmp/* /var/lib/apt/lists/* /var/tmp/ && \
adduser --system --no-create-home --uid 1000 --group --home /authentik authentik && \
@ -177,7 +154,7 @@ RUN apt-get update && \
COPY ./authentik/ /authentik
COPY ./pyproject.toml /
COPY ./uv.lock /
COPY ./poetry.lock /
COPY ./schemas /schemas
COPY ./locale /locale
COPY ./tests /tests
@ -186,7 +163,7 @@ COPY ./blueprints /blueprints
COPY ./lifecycle/ /lifecycle
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
COPY --from=go-builder /go/authentik /bin/authentik
COPY --from=python-deps /ak-root/.venv /ak-root/.venv
COPY --from=python-deps /ak-root/venv /ak-root/venv
COPY --from=web-builder /work/web/dist/ /web/dist/
COPY --from=web-builder /work/web/authentik/ /web/authentik/
COPY --from=website-builder /work/website/build/ /website/help/
@ -197,7 +174,11 @@ USER 1000
ENV TMPDIR=/dev/shm/ \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
GOFIPS=1
PATH="/ak-root/venv/bin:/lifecycle:$PATH" \
VENV_PATH="/ak-root/venv" \
POETRY_VIRTUALENVS_CREATE=false
ENV GOFIPS=1
HEALTHCHECK --interval=30s --timeout=30s --start-period=60s --retries=3 CMD [ "ak", "healthcheck" ]

View File

@ -4,7 +4,7 @@
PWD = $(shell pwd)
UID = $(shell id -u)
GID = $(shell id -g)
NPM_VERSION = $(shell python -m scripts.generate_semver)
NPM_VERSION = $(shell python -m scripts.npm_version)
PY_SOURCES = authentik tests scripts lifecycle .github
DOCKER_IMAGE ?= "authentik:test"
@ -12,9 +12,23 @@ GEN_API_TS = "gen-ts-api"
GEN_API_PY = "gen-py-api"
GEN_API_GO = "gen-go-api"
pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/null)
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
pg_user := $(shell python -m authentik.lib.config postgresql.user 2>/dev/null)
pg_host := $(shell python -m authentik.lib.config postgresql.host 2>/dev/null)
pg_name := $(shell python -m authentik.lib.config postgresql.name 2>/dev/null)
CODESPELL_ARGS = -D - -D .github/codespell-dictionary.txt \
-I .github/codespell-words.txt \
-S 'web/src/locales/**' \
-S 'website/docs/developer-docs/api/reference/**' \
authentik \
internal \
cmd \
web/src \
website/src \
website/blog \
website/docs \
website/integrations \
website/src
all: lint-fix lint test gen web ## Lint, build, and test everything
@ -31,38 +45,41 @@ help: ## Show this help
go-test:
go test -timeout 0 -v -race -cover ./...
test-docker: ## Run all tests in a docker-compose
echo "PG_PASS=$(shell openssl rand 32 | base64 -w 0)" >> .env
echo "AUTHENTIK_SECRET_KEY=$(shell openssl rand 32 | base64 -w 0)" >> .env
docker compose pull -q
docker compose up --no-start
docker compose start postgresql redis
docker compose run -u root server test-all
rm -f .env
test: ## Run the server tests and produce a coverage report (locally)
uv run coverage run manage.py test --keepdb authentik
uv run coverage html
uv run coverage report
coverage run manage.py test --keepdb authentik
coverage html
coverage report
lint-fix: lint-codespell ## Lint and automatically fix errors in the python source code. Reports spelling errors.
uv run black $(PY_SOURCES)
uv run ruff check --fix $(PY_SOURCES)
black $(PY_SOURCES)
ruff check --fix $(PY_SOURCES)
lint-codespell: ## Reports spelling errors.
uv run codespell -w
codespell -w $(CODESPELL_ARGS)
lint: ## Lint the python and golang sources
uv run bandit -c pyproject.toml -r $(PY_SOURCES)
bandit -r $(PY_SOURCES) -x web/node_modules -x tests/wdio/node_modules -x website/node_modules
golangci-lint run -v
core-install:
uv sync --frozen
poetry install
migrate: ## Run the Authentik Django server's migrations
uv run python -m lifecycle.migrate
python -m lifecycle.migrate
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
aws-cfn:
cd lifecycle/aws && npm run aws-cfn
run: ## Run the main authentik server process
uv run ak server
core-i18n-extract:
uv run ak makemessages \
ak makemessages \
--add-location file \
--no-obsolete \
--ignore web \
@ -93,11 +110,11 @@ gen-build: ## Extract the schema from the database
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
uv run ak make_blueprint_schema > blueprints/schema.json
ak make_blueprint_schema > blueprints/schema.json
AUTHENTIK_DEBUG=true \
AUTHENTIK_TENANTS__ENABLED=true \
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST=true \
uv run ak spectacular --file schema.yml
ak spectacular --file schema.yml
gen-changelog: ## (Release) generate the changelog based from the commits since the last tag
git log --pretty=format:" - %s" $(shell git describe --tags $(shell git rev-list --tags --max-count=1))...$(shell git branch --show-current) | sort > changelog.md
@ -132,7 +149,7 @@ gen-client-ts: gen-clean-ts ## Build and install the authentik API for Typescri
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \
docker.io/openapitools/openapi-generator-cli:v6.5.0 generate \
-i /local/schema.yml \
-g typescript-fetch \
-o /local/${GEN_API_TS} \
@ -148,7 +165,7 @@ gen-client-py: gen-clean-py ## Build and install the authentik API for Python
docker run \
--rm -v ${PWD}:/local \
--user ${UID}:${GID} \
docker.io/openapitools/openapi-generator-cli:v7.11.0 generate \
docker.io/openapitools/openapi-generator-cli:v7.4.0 generate \
-i /local/schema.yml \
-g python \
-o /local/${GEN_API_PY} \
@ -176,7 +193,7 @@ gen-client-go: gen-clean-go ## Build and install the authentik API for Golang
rm -rf ./${GEN_API_GO}/config.yaml ./${GEN_API_GO}/templates/
gen-dev-config: ## Generate a local development config file
uv run scripts/generate_config.py
python -m scripts.generate_config
gen: gen-build gen-client-ts
@ -243,9 +260,6 @@ docker: ## Build a docker image of the current source tree
mkdir -p ${GEN_API_TS}
DOCKER_BUILDKIT=1 docker build . --progress plain --tag ${DOCKER_IMAGE}
test-docker:
BUILD=true ./scripts/test_docker.sh
#########################
## CI
#########################
@ -257,21 +271,16 @@ ci--meta-debug:
node --version
ci-black: ci--meta-debug
uv run black --check $(PY_SOURCES)
black --check $(PY_SOURCES)
ci-ruff: ci--meta-debug
uv run ruff check $(PY_SOURCES)
ruff check $(PY_SOURCES)
ci-codespell: ci--meta-debug
uv run codespell -s
codespell $(CODESPELL_ARGS) -s
ci-bandit: ci--meta-debug
uv run bandit -r $(PY_SOURCES)
bandit -r $(PY_SOURCES)
ci-pending-migrations: ci--meta-debug
uv run ak makemigrations --check
ci-test: ci--meta-debug
uv run coverage run manage.py test --keepdb --randomly-seed ${CI_TEST_SEED} authentik
uv run coverage report
uv run coverage xml
ak makemigrations --check

View File

@ -2,7 +2,7 @@ authentik takes security very seriously. We follow the rules of [responsible di
## Independent audits and pentests
We are committed to engaging in regular pentesting and security audits of authentik. Defining and adhering to a cadence of external testing ensures a stronger probability that our code base, our features, and our architecture is as secure and non-exploitable as possible. For more details about specific audits and pentests, refer to "Audits and Certificates" in our [Security documentation](https://docs.goauthentik.io/docs/security).
In May/June of 2023 [Cure53](https://cure53.de) conducted an audit and pentest. The [results](https://cure53.de/pentest-report_authentik.pdf) are published on the [Cure53 website](https://cure53.de/#publications-2023). For more details about authentik's response to the findings of the audit refer to [2023-06 Cure53 Code audit](https://goauthentik.io/docs/security/2023-06-cure53).
## What authentik classifies as a CVE
@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported |
| --------- | --------- |
| 2024.12.x | ✅ |
| 2025.2.x | ✅ |
| 2024.8.x | ✅ |
| 2024.10.x | ✅ |
## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ
__version__ = "2025.4.0"
__version__ = "2024.10.2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"
@ -16,5 +16,5 @@ def get_full_version() -> str:
"""Get full version, with build hash appended"""
version = __version__
if (build_hash := get_build_hash()) != "":
return f"{version}+{build_hash}"
version += "." + build_hash
return version

View File

@ -7,9 +7,7 @@ from sys import version as python_version
from typing import TypedDict
from cryptography.hazmat.backends.openssl.backend import backend
from django.conf import settings
from django.utils.timezone import now
from django.views.debug import SafeExceptionReporterFilter
from drf_spectacular.utils import extend_schema
from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request
@ -54,16 +52,10 @@ class SystemInfoSerializer(PassiveSerializer):
def get_http_headers(self, request: Request) -> dict[str, str]:
"""Get HTTP Request headers"""
headers = {}
raw_session = request._request.COOKIES.get(settings.SESSION_COOKIE_NAME)
for key, value in request.META.items():
if not isinstance(value, str):
continue
actual_value = value
if raw_session is not None and raw_session in actual_value:
actual_value = actual_value.replace(
raw_session, SafeExceptionReporterFilter.cleansed_substitute
)
headers[key] = actual_value
headers[key] = value
return headers
def get_http_host(self, request: Request) -> str:

View File

@ -1,16 +1,12 @@
"""authentik administration overview"""
from socket import gethostname
from django.conf import settings
from drf_spectacular.utils import extend_schema, inline_serializer
from packaging.version import parse
from rest_framework.fields import BooleanField, CharField
from rest_framework.fields import IntegerField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik import get_full_version
from authentik.rbac.permissions import HasPermission
from authentik.root.celery import CELERY_APP
@ -20,38 +16,11 @@ class WorkerView(APIView):
permission_classes = [HasPermission("authentik_rbac.view_system_info")]
@extend_schema(
responses=inline_serializer(
"Worker",
fields={
"worker_id": CharField(),
"version": CharField(),
"version_matching": BooleanField(),
},
many=True,
)
)
@extend_schema(responses=inline_serializer("Workers", fields={"count": IntegerField()}))
def get(self, request: Request) -> Response:
"""Get currently connected worker count."""
raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5)
our_version = parse(get_full_version())
response = []
for worker in raw:
key = list(worker.keys())[0]
version = worker[key].get("version")
version_matching = False
if version:
version_matching = parse(version) == our_version
response.append(
{"worker_id": key, "version": version, "version_matching": version_matching}
)
count = len(CELERY_APP.control.ping(timeout=0.5))
# In debug we run with `task_always_eager`, so tasks are ran on the main process
if settings.DEBUG: # pragma: no cover
response.append(
{
"worker_id": f"authentik-debug@{gethostname()}",
"version": get_full_version(),
"version_matching": True,
}
)
return Response(response)
count += 1
return Response({"count": count})

View File

@ -1,10 +1,11 @@
"""authentik admin app config"""
from prometheus_client import Info
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")
class AuthentikAdminConfig(ManagedAppConfig):

View File

@ -1,35 +1,14 @@
"""admin signals"""
from django.dispatch import receiver
from packaging.version import parse
from prometheus_client import Gauge
from authentik import get_full_version
from authentik.admin.apps import GAUGE_WORKERS
from authentik.root.celery import CELERY_APP
from authentik.root.monitoring import monitoring_set
GAUGE_WORKERS = Gauge(
"authentik_admin_workers",
"Currently connected workers, their versions and if they are the same version as authentik",
["version", "version_matched"],
)
_version = parse(get_full_version())
@receiver(monitoring_set)
def monitoring_set_workers(sender, **kwargs):
"""Set worker gauge"""
raw: list[dict[str, dict]] = CELERY_APP.control.ping(timeout=0.5)
worker_version_count = {}
for worker in raw:
key = list(worker.keys())[0]
version = worker[key].get("version")
version_matching = False
if version:
version_matching = parse(version) == _version
worker_version_count.setdefault(version, {"count": 0, "matching": version_matching})
worker_version_count[version]["count"] += 1
for version, stats in worker_version_count.items():
GAUGE_WORKERS.labels(version, stats["matching"]).set(stats["count"])
count = len(CELERY_APP.control.ping(timeout=0.5))
GAUGE_WORKERS.set(count)

View File

@ -34,7 +34,7 @@ class TestAdminAPI(TestCase):
response = self.client.get(reverse("authentik_api:admin_workers"))
self.assertEqual(response.status_code, 200)
body = loads(response.content)
self.assertEqual(len(body), 0)
self.assertEqual(body["count"], 0)
def test_metrics(self):
"""Test metrics API"""

View File

@ -0,0 +1,67 @@
"""API Authorization"""
from django.conf import settings
from django.db.models import Model
from django.db.models.query import QuerySet
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework.authentication import get_authorization_header
from rest_framework.filters import BaseFilterBackend
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from authentik.api.authentication import validate_auth
from authentik.rbac.filters import ObjectFilter
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 SecretKeyFilter(DjangoFilterBackend):
"""Allow access to all objects when authenticated with secret key as token.
Replaces both DjangoFilterBackend and ObjectFilter"""
def filter_queryset(self, request: Request, queryset: QuerySet, view) -> QuerySet:
auth_header = get_authorization_header(request)
token = validate_auth(auth_header)
if token and token == settings.SECRET_KEY:
return queryset
queryset = ObjectFilter().filter_queryset(request, queryset, view)
return super().filter_queryset(request, queryset, view)
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

@ -7,7 +7,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, DateTimeField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer
from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.blueprints.models import BlueprintInstance
@ -15,7 +15,7 @@ from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.oci import OCI_PREFIX
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 JSONDictField, ModelSerializer, PassiveSerializer
from authentik.core.api.utils import JSONDictField, PassiveSerializer
from authentik.rbac.decorators import permission_required

View File

@ -1,68 +0,0 @@
"""Test and debug Blueprints"""
import atexit
import readline
from pathlib import Path
from pprint import pformat
from sys import exit as sysexit
from textwrap import indent
from django.core.management.base import BaseCommand, no_translations
from structlog.stdlib import get_logger
from yaml import load
from authentik.blueprints.v1.common import BlueprintLoader, EntryInvalidError
from authentik.core.management.commands.shell import get_banner_text
from authentik.lib.utils.errors import exception_to_string
LOGGER = get_logger()
class Command(BaseCommand):
"""Test and debug Blueprints"""
lines = []
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
histfolder = Path("~").expanduser() / Path(".local/share/authentik")
histfolder.mkdir(parents=True, exist_ok=True)
histfile = histfolder / Path("blueprint_shell_history")
readline.parse_and_bind("tab: complete")
readline.parse_and_bind("set editing-mode vi")
try:
readline.read_history_file(str(histfile))
except FileNotFoundError:
pass
atexit.register(readline.write_history_file, str(histfile))
@no_translations
def handle(self, *args, **options):
"""Interactively debug blueprint files"""
self.stdout.write(get_banner_text("Blueprint shell"))
self.stdout.write("Type '.eval' to evaluate previously entered statement(s).")
def do_eval():
yaml_input = "\n".join([line for line in self.lines if line])
data = load(yaml_input, BlueprintLoader)
self.stdout.write(pformat(data))
self.lines = []
while True:
try:
line = input("> ")
if line == ".eval":
do_eval()
else:
self.lines.append(line)
except EntryInvalidError as exc:
self.stdout.write("Failed to evaluate expression:")
self.stdout.write(indent(exception_to_string(exc), prefix=" "))
except EOFError:
break
except KeyboardInterrupt:
self.stdout.write()
sysexit(0)
self.stdout.write()

View File

@ -126,7 +126,7 @@ class Command(BaseCommand):
def_name_perm = f"model_{model_path}_permissions"
def_path_perm = f"#/$defs/{def_name_perm}"
self.schema["$defs"][def_name_perm] = self.model_permissions(model)
template = {
return {
"type": "object",
"required": ["model", "identifiers"],
"properties": {
@ -143,11 +143,6 @@ class Command(BaseCommand):
"identifiers": {"$ref": def_path},
},
}
# Meta models don't require identifiers, as there's no matching database model to find
if issubclass(model, BaseMetaModel):
del template["properties"]["identifiers"]
template["required"].remove("identifiers")
return template
def field_to_jsonschema(self, field: Field) -> dict:
"""Convert a single field to json schema"""

View File

@ -146,10 +146,6 @@ entries:
]
]
nested_context: !Context context2
at_index_sequence: !AtIndex [!Context sequence, 0]
at_index_sequence_default: !AtIndex [!Context sequence, 100, "non existent"]
at_index_mapping: !AtIndex [!Context mapping, "key2"]
at_index_mapping_default: !AtIndex [!Context mapping, "invalid", "non existent"]
identifiers:
name: test
conditions:

View File

@ -215,10 +215,6 @@ class TestBlueprintsV1(TransactionTestCase):
},
"nested_context": "context-nested-value",
"env_null": None,
"at_index_sequence": "foo",
"at_index_sequence_default": "non existent",
"at_index_mapping": 2,
"at_index_mapping_default": "non existent",
}
).exists()
)

View File

@ -24,10 +24,6 @@ from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.models import PolicyBindingModel
class UNSET:
"""Used to test whether a key has not been set."""
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)
@ -202,9 +198,6 @@ class Blueprint:
class YAMLTag:
"""Base class for all YAML Tags"""
def __repr__(self) -> str:
return str(self.resolve(BlueprintEntry(""), Blueprint()))
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
"""Implement yaml tag logic"""
raise NotImplementedError
@ -563,53 +556,6 @@ class Value(EnumeratedItem):
raise EntryInvalidError.from_entry(f"Empty/invalid context: {context}", entry) from exc
class AtIndex(YAMLTag):
"""Get value at index of a sequence or mapping"""
obj: YAMLTag | dict | list | tuple
attribute: int | str | YAMLTag
default: Any | UNSET
def __init__(self, loader: "BlueprintLoader", node: SequenceNode) -> None:
super().__init__()
self.obj = loader.construct_object(node.value[0])
self.attribute = loader.construct_object(node.value[1])
if len(node.value) == 2: # noqa: PLR2004
self.default = UNSET
else:
self.default = loader.construct_object(node.value[2])
def resolve(self, entry: BlueprintEntry, blueprint: Blueprint) -> Any:
if isinstance(self.obj, YAMLTag):
obj = self.obj.resolve(entry, blueprint)
else:
obj = self.obj
if isinstance(self.attribute, YAMLTag):
attribute = self.attribute.resolve(entry, blueprint)
else:
attribute = self.attribute
if isinstance(obj, list | tuple):
try:
return obj[attribute]
except TypeError as exc:
raise EntryInvalidError.from_entry(
f"Invalid index for list: {attribute}", entry
) from exc
except IndexError as exc:
if self.default is UNSET:
raise EntryInvalidError.from_entry(
f"Index out of range: {attribute}", entry
) from exc
return self.default
if attribute in obj:
return obj[attribute]
else:
if self.default is UNSET:
raise EntryInvalidError.from_entry(f"Key does not exist: {attribute}", entry)
return self.default
class BlueprintDumper(SafeDumper):
"""Dump dataclasses to yaml"""
@ -660,7 +606,6 @@ class BlueprintLoader(SafeLoader):
self.add_constructor("!Enumerate", Enumerate)
self.add_constructor("!Value", Value)
self.add_constructor("!Index", Index)
self.add_constructor("!AtIndex", AtIndex)
class EntryInvalidError(SentryIgnoredException):

View File

@ -36,7 +36,6 @@ from authentik.core.models import (
GroupSourceConnection,
PropertyMapping,
Provider,
Session,
Source,
User,
UserSourceConnection,
@ -51,7 +50,7 @@ from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.ssf.models import StreamEvent
from authentik.enterprise.providers.rac.models import ConnectionToken
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
EndpointDevice,
EndpointDeviceConnection,
@ -66,13 +65,7 @@ from authentik.lib.utils.reflection import get_apps
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
DeviceToken,
RefreshToken,
)
from authentik.providers.rac.models import ConnectionToken
from authentik.providers.oauth2.models import AccessToken, AuthorizationCode, RefreshToken
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
from authentik.rbac.models import Role
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
@ -109,7 +102,6 @@ def excluded_models() -> list[type[Model]]:
Policy,
PolicyBindingModel,
# Classes that have other dependencies
Session,
AuthenticatedSession,
# Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
@ -133,8 +125,6 @@ def excluded_models() -> list[type[Model]]:
MicrosoftEntraProviderGroup,
EndpointDevice,
EndpointDeviceConnection,
DeviceToken,
StreamEvent,
)

View File

@ -159,7 +159,7 @@ def blueprints_discovery(self: SystemTask, path: str | None = None):
check_blueprint_v1_file(blueprint)
count += 1
self.set_status(
TaskStatus.SUCCESSFUL, _("Successfully imported {count} files.".format(count=count))
TaskStatus.SUCCESSFUL, _("Successfully imported %(count)d files." % {"count": count})
)

View File

@ -14,10 +14,10 @@ from rest_framework.response import Response
from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import SecretKeyFilter
from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.rbac.filters import SecretKeyFilter
from authentik.tenants.utils import get_current_tenant
@ -49,8 +49,6 @@ class BrandSerializer(ModelSerializer):
"branding_title",
"branding_logo",
"branding_favicon",
"branding_custom_css",
"branding_default_flow_background",
"flow_authentication",
"flow_invalidation",
"flow_recovery",
@ -86,9 +84,8 @@ class CurrentBrandSerializer(PassiveSerializer):
matched_domain = CharField(source="domain")
branding_title = CharField()
branding_logo = CharField(source="branding_logo_url")
branding_favicon = CharField(source="branding_favicon_url")
branding_custom_css = CharField()
branding_logo = CharField()
branding_favicon = CharField()
ui_footer_links = ListField(
child=FooterLinkSerializer(),
read_only=True,
@ -128,7 +125,6 @@ class BrandViewSet(UsedByMixin, ModelViewSet):
"branding_title",
"branding_logo",
"branding_favicon",
"branding_default_flow_background",
"flow_authentication",
"flow_invalidation",
"flow_recovery",

View File

@ -25,7 +25,5 @@ class BrandMiddleware:
locale = brand.default_locale
if locale != "":
locale_to_set = locale
if locale_to_set:
with override(locale_to_set):
return self.get_response(request)
return self.get_response(request)
with override(locale_to_set):
return self.get_response(request)

View File

@ -1,35 +0,0 @@
# Generated by Django 5.0.12 on 2025-02-22 01:51
from pathlib import Path
from django.db import migrations, models
from django.apps.registry import Apps
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
def migrate_custom_css(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
Brand = apps.get_model("authentik_brands", "brand")
db_alias = schema_editor.connection.alias
path = Path("/web/dist/custom.css")
if not path.exists():
return
css = path.read_text()
Brand.objects.using(db_alias).update(branding_custom_css=css)
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0007_brand_default_application"),
]
operations = [
migrations.AddField(
model_name="brand",
name="branding_custom_css",
field=models.TextField(blank=True, default=""),
),
migrations.RunPython(migrate_custom_css),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.13 on 2025-03-19 22:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_brands", "0008_brand_branding_custom_css"),
]
operations = [
migrations.AddField(
model_name="brand",
name="branding_default_flow_background",
field=models.TextField(default="/static/dist/assets/images/flow_background.jpg"),
),
]

View File

@ -10,7 +10,6 @@ from structlog.stdlib import get_logger
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.models import SerializerModel
LOGGER = get_logger()
@ -33,10 +32,6 @@ class Brand(SerializerModel):
branding_logo = models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg")
branding_favicon = models.TextField(default="/static/dist/assets/icons/icon.png")
branding_custom_css = models.TextField(default="", blank=True)
branding_default_flow_background = models.TextField(
default="/static/dist/assets/images/flow_background.jpg"
)
flow_authentication = models.ForeignKey(
Flow, null=True, on_delete=models.SET_NULL, related_name="brand_authentication"
@ -76,24 +71,6 @@ class Brand(SerializerModel):
)
attributes = models.JSONField(default=dict, blank=True)
def branding_logo_url(self) -> str:
"""Get branding_logo with the correct prefix"""
if self.branding_logo.startswith("/static"):
return CONFIG.get("web.path", "/")[:-1] + self.branding_logo
return self.branding_logo
def branding_favicon_url(self) -> str:
"""Get branding_favicon with the correct prefix"""
if self.branding_favicon.startswith("/static"):
return CONFIG.get("web.path", "/")[:-1] + self.branding_favicon
return self.branding_favicon
def branding_default_flow_background_url(self) -> str:
"""Get branding_default_flow_background with the correct prefix"""
if self.branding_default_flow_background.startswith("/static"):
return CONFIG.get("web.path", "/")[:-1] + self.branding_default_flow_background
return self.branding_default_flow_background
@property
def serializer(self) -> Serializer:
from authentik.brands.api import BrandSerializer

View File

@ -24,7 +24,6 @@ class TestBrands(APITestCase):
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
@ -44,7 +43,6 @@ class TestBrands(APITestCase):
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "custom",
"branding_custom_css": "",
"matched_domain": "bar.baz",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
@ -61,7 +59,6 @@ class TestBrands(APITestCase):
"branding_logo": "/static/dist/assets/icons/icon_left_brand.svg",
"branding_favicon": "/static/dist/assets/icons/icon.png",
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": "fallback",
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
@ -124,27 +121,3 @@ class TestBrands(APITestCase):
"subject": None,
},
)
def test_branding_url(self):
"""Test branding attributes return correct values"""
brand = create_test_brand()
brand.branding_default_flow_background = "https://goauthentik.io/img/icon.png"
brand.branding_favicon = "https://goauthentik.io/img/icon.png"
brand.branding_logo = "https://goauthentik.io/img/icon.png"
brand.save()
self.assertEqual(
brand.branding_default_flow_background_url(), "https://goauthentik.io/img/icon.png"
)
self.assertJSONEqual(
self.client.get(reverse("authentik_api:brand-current")).content.decode(),
{
"branding_logo": "https://goauthentik.io/img/icon.png",
"branding_favicon": "https://goauthentik.io/img/icon.png",
"branding_title": "authentik",
"branding_custom_css": "",
"matched_domain": brand.domain,
"ui_footer_links": [],
"ui_theme": Themes.AUTOMATIC,
"default_locale": "",
},
)

View File

@ -1,58 +0,0 @@
"""Application Roles API Viewset"""
from django.http import HttpRequest
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError
from rest_framework.viewsets import ModelViewSet
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import (
Application,
ApplicationEntitlement,
)
class ApplicationEntitlementSerializer(ModelSerializer):
"""ApplicationEntitlement Serializer"""
def validate_app(self, app: Application) -> Application:
"""Ensure user has permission to view"""
request: HttpRequest = self.context.get("request")
if not request and SERIALIZER_CONTEXT_BLUEPRINT in self.context:
return app
user = request.user
if user.has_perm("view_application", app) or user.has_perm(
"authentik_core.view_application"
):
return app
raise ValidationError(_("User does not have access to application."), code="invalid")
class Meta:
model = ApplicationEntitlement
fields = [
"pbm_uuid",
"name",
"app",
"attributes",
]
class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
"""ApplicationEntitlement Viewset"""
queryset = ApplicationEntitlement.objects.all()
serializer_class = ApplicationEntitlementSerializer
search_fields = [
"pbm_uuid",
"name",
"app",
"attributes",
]
filterset_fields = [
"pbm_uuid",
"name",
"app",
]
ordering = ["name"]

View File

@ -46,7 +46,7 @@ LOGGER = get_logger()
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
"""Cache key where application list for user is saved"""
key = f"{CACHE_PREFIX}app_access/{user_pk}"
key = f"{CACHE_PREFIX}/app_access/{user_pk}"
if page_number:
key += f"/{page_number}"
return key

View File

@ -2,13 +2,16 @@
from typing import 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 CharField, DateTimeField, IPAddressField
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.api.utils import ModelSerializer
from authentik.core.models import AuthenticatedSession
@ -55,11 +58,6 @@ class UserAgentDict(TypedDict):
class AuthenticatedSessionSerializer(ModelSerializer):
"""AuthenticatedSession Serializer"""
expires = DateTimeField(source="session.expires", read_only=True)
last_ip = IPAddressField(source="session.last_ip", read_only=True)
last_user_agent = CharField(source="session.last_user_agent", read_only=True)
last_used = DateTimeField(source="session.last_used", read_only=True)
current = SerializerMethodField()
user_agent = SerializerMethodField()
geo_ip = SerializerMethodField()
@ -68,19 +66,19 @@ class AuthenticatedSessionSerializer(ModelSerializer):
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.session_key
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.session.last_user_agent)
return user_agent_parser.Parse(instance.last_user_agent)
def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None: # pragma: no cover
"""Get GeoIP Data"""
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.session.last_ip)
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip)
def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None: # pragma: no cover
"""Get ASN Data"""
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.session.last_ip)
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip)
class Meta:
model = AuthenticatedSession
@ -96,7 +94,6 @@ class AuthenticatedSessionSerializer(ModelSerializer):
"last_used",
"expires",
]
extra_args = {"uuid": {"read_only": True}}
class AuthenticatedSessionViewSet(
@ -108,10 +105,16 @@ class AuthenticatedSessionViewSet(
):
"""AuthenticatedSession Viewset"""
lookup_field = "uuid"
queryset = AuthenticatedSession.objects.select_related("session").all()
queryset = AuthenticatedSession.objects.all()
serializer_class = AuthenticatedSessionSerializer
search_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
search_fields = ["user__username", "last_ip", "last_user_agent"]
filterset_fields = ["user__username", "last_ip", "last_user_agent"]
ordering = ["user__username"]
owner_field = "user"
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

@ -3,7 +3,6 @@
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import (
BooleanField,
CharField,
@ -17,6 +16,7 @@ from rest_framework.viewsets import ViewSet
from authentik.core.api.utils import MetaNameSerializer
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
from authentik.rbac.decorators import permission_required
from authentik.stages.authenticator import device_classes, devices_for_user
from authentik.stages.authenticator.models import Device
from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
@ -73,9 +73,7 @@ class AdminDeviceViewSet(ViewSet):
def get_devices(self, **kwargs):
"""Get all devices in all child classes"""
for model in device_classes():
device_set = get_objects_for_user(
self.request.user, f"{model._meta.app_label}.view_{model._meta.model_name}", model
).filter(**kwargs)
device_set = model.objects.filter(**kwargs)
yield from device_set
@extend_schema(
@ -88,6 +86,10 @@ class AdminDeviceViewSet(ViewSet):
],
responses={200: DeviceSerializer(many=True)},
)
@permission_required(
None,
[f"{model._meta.app_label}.view_{model._meta.model_name}" for model in device_classes()],
)
def list(self, request: Request) -> Response:
"""Get all devices for current user"""
kwargs = {}

View File

@ -4,7 +4,6 @@ from json import loads
from django.db.models import Prefetch
from django.http import Http404
from django.utils.translation import gettext as _
from django_filters.filters import CharFilter, ModelMultipleChoiceFilter
from django_filters.filterset import FilterSet
from drf_spectacular.utils import (
@ -82,37 +81,9 @@ class GroupSerializer(ModelSerializer):
if not self.instance or not parent:
return parent
if str(parent.group_uuid) == str(self.instance.group_uuid):
raise ValidationError(_("Cannot set group as parent of itself."))
raise ValidationError("Cannot set group as parent of itself.")
return parent
def validate_is_superuser(self, superuser: bool):
"""Ensure that the user creating this group has permissions to set the superuser flag"""
request: Request = self.context.get("request", None)
if not request:
return superuser
# If we're updating an instance, and the state hasn't changed, we don't need to check perms
if self.instance and superuser == self.instance.is_superuser:
return superuser
user: User = request.user
perm = (
"authentik_core.enable_group_superuser"
if superuser
else "authentik_core.disable_group_superuser"
)
has_perm = user.has_perm(perm)
if self.instance and not has_perm:
has_perm = user.has_perm(perm, self.instance)
if not has_perm:
raise ValidationError(
_(
(
"User does not have permission to set "
"superuser status to {superuser_status}."
).format_map({"superuser_status": superuser})
)
)
return superuser
class Meta:
model = Group
fields = [

View File

@ -2,17 +2,19 @@
from collections.abc import Iterable
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, ReadOnlyField, SerializerMethodField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.parsers import MultiPartParser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from structlog.stdlib import get_logger
from authentik.api.authorization import OwnerFilter, OwnerSuperuserPermissions
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
@ -86,7 +88,7 @@ class SourceViewSet(
serializer_class = SourceSerializer
lookup_field = "slug"
search_fields = ["slug", "name"]
filterset_fields = ["slug", "name", "managed", "pbm_uuid"]
filterset_fields = ["slug", "name", "managed"]
def get_queryset(self): # pragma: no cover
return Source.objects.select_subclasses()
@ -155,22 +157,11 @@ class SourceViewSet(
matching_sources.append(source_settings.validated_data)
return Response(matching_sources)
def destroy(self, request: Request, *args, **kwargs):
"""Prevent deletion of built-in sources"""
instance: Source = self.get_object()
if instance.managed == Source.MANAGED_INBUILT:
raise ValidationError(
{"detail": "Built-in sources cannot be deleted"}, code="protected"
)
return super().destroy(request, *args, **kwargs)
class UserSourceConnectionSerializer(SourceSerializer):
"""User source connection"""
"""OAuth Source Serializer"""
source_obj = SourceSerializer(read_only=True, source="source")
source = SourceSerializer(read_only=True)
class Meta:
model = UserSourceConnection
@ -178,14 +169,11 @@ class UserSourceConnectionSerializer(SourceSerializer):
"pk",
"user",
"source",
"source_obj",
"identifier",
"created",
"last_updated",
]
extra_kwargs = {
"user": {"read_only": True},
"created": {"read_only": True},
"last_updated": {"read_only": True},
}
@ -201,16 +189,17 @@ class UserSourceConnectionViewSet(
queryset = UserSourceConnection.objects.all()
serializer_class = UserSourceConnectionSerializer
permission_classes = [OwnerSuperuserPermissions]
filterset_fields = ["user", "source__slug"]
search_fields = ["user__username", "source__slug", "identifier"]
search_fields = ["source__slug"]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["source__slug", "pk"]
owner_field = "user"
class GroupSourceConnectionSerializer(SourceSerializer):
"""Group Source Connection"""
"""Group Source Connection Serializer"""
source_obj = SourceSerializer(read_only=True)
source = SourceSerializer(read_only=True)
class Meta:
model = GroupSourceConnection
@ -218,14 +207,13 @@ class GroupSourceConnectionSerializer(SourceSerializer):
"pk",
"group",
"source",
"source_obj",
"identifier",
"created",
"last_updated",
]
extra_kwargs = {
"group": {"read_only": True},
"identifier": {"read_only": True},
"created": {"read_only": True},
"last_updated": {"read_only": True},
}
@ -241,6 +229,8 @@ class GroupSourceConnectionViewSet(
queryset = GroupSourceConnection.objects.all()
serializer_class = GroupSourceConnectionSerializer
permission_classes = [OwnerSuperuserPermissions]
filterset_fields = ["group", "source__slug"]
search_fields = ["group__name", "source__slug", "identifier"]
search_fields = ["source__slug"]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
ordering = ["source__slug", "pk"]

View File

@ -3,15 +3,18 @@
from typing import Any
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from guardian.shortcuts import assign_perm, get_anonymous_user
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from authentik.api.authorization import OwnerSuperuserPermissions
from authentik.blueprints.api import ManagedSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.used_by import UsedByMixin
@ -135,8 +138,8 @@ class TokenViewSet(UsedByMixin, ModelViewSet):
"managed",
]
ordering = ["identifier", "expires"]
owner_field = "user"
rbac_allow_create_without_perm = True
permission_classes = [OwnerSuperuserPermissions]
filter_backends = [DjangoFilterBackend, OrderingFilter, SearchFilter]
def get_queryset(self):
user = self.request.user if self.request else get_anonymous_user()

View File

@ -22,7 +22,7 @@ from authentik.blueprints.v1.common import (
from authentik.blueprints.v1.importer import Importer
from authentik.core.api.applications import ApplicationSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import Application, Provider
from authentik.core.models import Provider
from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.api.bindings import PolicyBindingSerializer
@ -51,13 +51,6 @@ class TransactionProviderField(DictField):
class TransactionPolicyBindingSerializer(PolicyBindingSerializer):
"""PolicyBindingSerializer which does not require target as target is set implicitly"""
def validate(self, attrs):
# As the PolicyBindingSerializer checks that the correct things can be bound to a target
# but we don't have a target here as that's set by the blueprint, pass in an empty app
# which will have the correct allowed combination of group/user/policy.
attrs["target"] = Application()
return super().validate(attrs)
class Meta(PolicyBindingSerializer.Meta):
fields = [x for x in PolicyBindingSerializer.Meta.fields if x != "target"]

View File

@ -6,6 +6,8 @@ from typing import Any
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.db.models.functions import ExtractHour
from django.db.transaction import atomic
from django.db.utils import IntegrityError
@ -69,8 +71,8 @@ from authentik.core.middleware import (
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_PATH_SERVICE_ACCOUNT,
AuthenticatedSession,
Group,
Session,
Token,
TokenIntents,
User,
@ -224,7 +226,6 @@ class UserSerializer(ModelSerializer):
"name",
"is_active",
"last_login",
"date_joined",
"is_superuser",
"groups",
"groups_obj",
@ -235,12 +236,9 @@ class UserSerializer(ModelSerializer):
"path",
"type",
"uuid",
"password_change_date",
]
extra_kwargs = {
"name": {"allow_blank": True},
"date_joined": {"read_only": True},
"password_change_date": {"read_only": True},
}
@ -373,7 +371,7 @@ class UsersFilter(FilterSet):
method="filter_attributes",
)
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
uuid = UUIDFilter(field_name="uuid")
path = CharFilter(field_name="path")
@ -391,11 +389,6 @@ class UsersFilter(FilterSet):
queryset=Group.objects.all().order_by("name"),
)
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(ak_groups__is_superuser=True).distinct()
return queryset.exclude(ak_groups__is_superuser=True).distinct()
def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args"""
try:
@ -434,7 +427,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
queryset = User.objects.none()
ordering = ["username"]
serializer_class = UserSerializer
search_fields = ["username", "name", "is_active", "email", "uuid", "attributes"]
search_fields = ["username", "name", "is_active", "email", "uuid"]
filterset_class = UsersFilter
def get_queryset(self):
@ -592,7 +585,7 @@ class UserViewSet(UsedByMixin, ModelViewSet):
"""Set password for user"""
user: User = self.get_object()
try:
user.set_password(request.data.get("password"), request=request)
user.set_password(request.data.get("password"))
user.save()
except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password", exc=exc)
@ -772,6 +765,9 @@ class UserViewSet(UsedByMixin, ModelViewSet):
response = super().partial_update(request, *args, **kwargs)
instance: User = self.get_object()
if not instance.is_active:
Session.objects.filter(authenticatedsession__user=instance).delete()
sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True)
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
sessions.delete()
LOGGER.debug("Deleted user's sessions", user=instance.username)
return response

View File

@ -20,8 +20,6 @@ from rest_framework.serializers import (
raise_errors_on_nested_writes,
)
from authentik.rbac.permissions import assign_initial_permissions
def is_dict(value: Any):
"""Ensure a value is a dictionary, useful for JSONFields"""
@ -31,14 +29,6 @@ def is_dict(value: Any):
class ModelSerializer(BaseModelSerializer):
def create(self, validated_data):
instance = super().create(validated_data)
request = self.context.get("request")
if request and hasattr(request, "user") and not request.user.is_anonymous:
assign_initial_permissions(request.user, instance)
return instance
def update(self, instance: Model, validated_data):
raise_errors_on_nested_writes("update", self, validated_data)

View File

@ -32,5 +32,5 @@ class AuthentikCoreConfig(ManagedAppConfig):
"name": "authentik Built-in",
"slug": "authentik-built-in",
},
managed=Source.MANAGED_INBUILT,
managed="goauthentik.io/sources/inbuilt",
)

View File

@ -24,15 +24,6 @@ class InbuiltBackend(ModelBackend):
self.set_method("password", request)
return user
async def aauthenticate(
self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
) -> User | None:
user = await super().aauthenticate(request, username=username, password=password, **kwargs)
if not user:
return None
self.set_method("password", request)
return user
def set_method(self, method: str, request: HttpRequest | None, **kwargs):
"""Set method data on current flow, if possbiel"""
if not request:
@ -53,12 +44,13 @@ class TokenBackend(InbuiltBackend):
self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
) -> User | None:
try:
user = User._default_manager.get_by_natural_key(username)
except User.DoesNotExist:
# Run the default password hasher once to reduce the timing
# difference between an existing and a nonexistent user (#20760).
User().set_password(password, request=request)
User().set_password(password)
return None
tokens = Token.filter_not_expired(

View File

@ -58,7 +58,6 @@ class PropertyMappingEvaluator(BaseEvaluator):
self._context["user"] = user
if request:
req.http_request = request
self._context["http_request"] = request
req.context.update(**kwargs)
self._context["request"] = req
self._context.update(**kwargs)

View File

@ -1,15 +0,0 @@
"""Change user type"""
from importlib import import_module
from django.conf import settings
from authentik.tenants.management import TenantCommand
class Command(TenantCommand):
"""Delete all sessions"""
def handle_per_tenant(self, **options):
engine = import_module(settings.SESSION_ENGINE)
engine.SessionStore.clear_expired()

View File

@ -5,7 +5,6 @@ from typing import TextIO
from daphne.management.commands.runserver import Command as RunServer
from daphne.server import Server
from authentik.lib.debug import start_debug_server
from authentik.root.signals import post_startup, pre_startup, startup
@ -14,7 +13,6 @@ class SignalServer(Server):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
start_debug_server()
def ready_callable():
pre_startup.send(sender=self)

View File

@ -17,9 +17,7 @@ from authentik.events.middleware import should_log_model
from authentik.events.models import Event, EventAction
from authentik.events.utils import model_to_dict
def get_banner_text(shell_type="shell") -> str:
return f"""### authentik {shell_type} ({get_full_version()})
BANNER_TEXT = f"""### authentik shell ({get_full_version()})
### Node {platform.node()} | Arch {platform.machine()} | Python {platform.python_version()} """
@ -116,4 +114,4 @@ class Command(BaseCommand):
readline.parse_and_bind("tab: complete")
# Run interactive shell
code.interact(banner=get_banner_text(), local=namespace)
code.interact(banner=BANNER_TEXT, local=namespace)

View File

@ -9,7 +9,6 @@ from django.db import close_old_connections
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
from authentik.lib.debug import start_debug_server
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@ -29,7 +28,10 @@ class Command(BaseCommand):
def handle(self, **options):
LOGGER.debug("Celery options", **options)
close_old_connections()
start_debug_server()
if CONFIG.get_bool("remote_debug"):
import debugpy
debugpy.listen(("0.0.0.0", 6900)) # nosec
worker: Worker = CELERY_APP.Worker(
no_color=False,
quiet=True,

View File

@ -2,14 +2,9 @@
from collections.abc import Callable
from contextvars import ContextVar
from functools import partial
from uuid import uuid4
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured
from django.http import HttpRequest, HttpResponse
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject
from django.utils.translation import override
from sentry_sdk.api import set_tag
from structlog.contextvars import STRUCTLOG_KEY_PREFIX
@ -25,40 +20,6 @@ CTX_HOST = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + "host", default=None)
CTX_AUTH_VIA = ContextVar[str | None](STRUCTLOG_KEY_PREFIX + KEY_AUTH_VIA, default=None)
def get_user(request):
if not hasattr(request, "_cached_user"):
user = None
if (authenticated_session := request.session.get("authenticatedsession", None)) is not None:
user = authenticated_session.user
request._cached_user = user or AnonymousUser()
return request._cached_user
async def aget_user(request):
if not hasattr(request, "_cached_user"):
user = None
if (
authenticated_session := await request.session.aget("authenticatedsession", None)
) is not None:
user = authenticated_session.user
request._cached_user = user or AnonymousUser()
return request._cached_user
class AuthenticationMiddleware(MiddlewareMixin):
def process_request(self, request):
if not hasattr(request, "session"):
raise ImproperlyConfigured(
"The Django authentication middleware requires session "
"middleware to be installed. Edit your MIDDLEWARE setting to "
"insert "
"'authentik.root.middleware.SessionMiddleware' before "
"'authentik.core.middleware.AuthenticationMiddleware'."
)
request.user = SimpleLazyObject(lambda: get_user(request))
request.auser = partial(aget_user, request)
class ImpersonateMiddleware:
"""Middleware to impersonate users"""
@ -81,10 +42,8 @@ class ImpersonateMiddleware:
# Ensure that the user is active, otherwise nothing will work
request.user.is_active = True
if locale_to_set:
with override(locale_to_set):
return self.get_response(request)
return self.get_response(request)
with override(locale_to_set):
return self.get_response(request)
class RequestIDMiddleware:

View File

@ -1,45 +0,0 @@
# Generated by Django 5.0.9 on 2024-11-20 15:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0040_provider_invalidation_flow"),
("authentik_policies", "0011_policybinding_failure_result_and_more"),
]
operations = [
migrations.CreateModel(
name="ApplicationEntitlement",
fields=[
(
"policybindingmodel_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_policies.policybindingmodel",
),
),
("attributes", models.JSONField(blank=True, default=dict)),
("name", models.TextField()),
(
"app",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="authentik_core.application"
),
),
],
options={
"verbose_name": "Application Entitlement",
"verbose_name_plural": "Application Entitlements",
"unique_together": {("app", "name")},
},
bases=("authentik_policies.policybindingmodel", models.Model),
),
]

View File

@ -1,45 +0,0 @@
# Generated by Django 5.0.10 on 2025-01-13 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0041_applicationentitlement"),
]
operations = [
migrations.AddIndex(
model_name="authenticatedsession",
index=models.Index(fields=["expires"], name="authentik_c_expires_08251d_idx"),
),
migrations.AddIndex(
model_name="authenticatedsession",
index=models.Index(fields=["expiring"], name="authentik_c_expirin_9cd839_idx"),
),
migrations.AddIndex(
model_name="authenticatedsession",
index=models.Index(
fields=["expiring", "expires"], name="authentik_c_expirin_195a84_idx"
),
),
migrations.AddIndex(
model_name="authenticatedsession",
index=models.Index(fields=["session_key"], name="authentik_c_session_d0f005_idx"),
),
migrations.AddIndex(
model_name="token",
index=models.Index(fields=["expires"], name="authentik_c_expires_a62b4b_idx"),
),
migrations.AddIndex(
model_name="token",
index=models.Index(fields=["expiring"], name="authentik_c_expirin_a1b838_idx"),
),
migrations.AddIndex(
model_name="token",
index=models.Index(
fields=["expiring", "expires"], name="authentik_c_expirin_ba04d9_idx"
),
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-30 23:55
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0042_authenticatedsession_authentik_c_expires_08251d_idx_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="group",
options={
"permissions": [
("add_user_to_group", "Add user to group"),
("remove_user_from_group", "Remove user from group"),
("enable_group_superuser", "Enable superuser status"),
("disable_group_superuser", "Disable superuser status"),
],
"verbose_name": "Group",
"verbose_name_plural": "Groups",
},
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.0.13 on 2025-04-07 14:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0043_alter_group_options"),
]
operations = [
migrations.AddField(
model_name="usersourceconnection",
name="new_identifier",
field=models.TextField(default=""),
preserve_default=False,
),
]

View File

@ -1,30 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0044_usersourceconnection_new_identifier"),
("authentik_sources_kerberos", "0003_migrate_userkerberossourceconnection_identifier"),
("authentik_sources_oauth", "0009_migrate_useroauthsourceconnection_identifier"),
("authentik_sources_plex", "0005_migrate_userplexsourceconnection_identifier"),
("authentik_sources_saml", "0019_migrate_usersamlsourceconnection_identifier"),
]
operations = [
migrations.RenameField(
model_name="usersourceconnection",
old_name="new_identifier",
new_name="identifier",
),
migrations.AddIndex(
model_name="usersourceconnection",
index=models.Index(fields=["identifier"], name="authentik_c_identif_59226f_idx"),
),
migrations.AddIndex(
model_name="usersourceconnection",
index=models.Index(
fields=["source", "identifier"], name="authentik_c_source__649e04_idx"
),
),
]

View File

@ -1,238 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 12:58
import uuid
import pickle # nosec
from django.core import signing
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.utils.timezone import now, timedelta
from authentik.lib.migrations import progress_bar
from authentik.root.middleware import ClientIPMiddleware
SESSION_CACHE_ALIAS = "default"
class PickleSerializer:
"""
Simple wrapper around pickle to be used in signing.dumps()/loads() and
cache backends.
"""
def __init__(self, protocol=None):
self.protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
def dumps(self, obj):
"""Pickle data to be stored in redis"""
return pickle.dumps(obj, self.protocol)
def loads(self, data):
"""Unpickle data to be loaded from redis"""
return pickle.loads(data) # nosec
def _migrate_session(
apps,
db_alias,
session_key,
session_data,
expires,
):
Session = apps.get_model("authentik_core", "Session")
OldAuthenticatedSession = apps.get_model("authentik_core", "OldAuthenticatedSession")
AuthenticatedSession = apps.get_model("authentik_core", "AuthenticatedSession")
old_auth_session = (
OldAuthenticatedSession.objects.using(db_alias).filter(session_key=session_key).first()
)
args = {
"session_key": session_key,
"expires": expires,
"last_ip": ClientIPMiddleware.default_ip,
"last_user_agent": "",
"session_data": {},
}
for k, v in session_data.items():
if k == "authentik/stages/user_login/last_ip":
args["last_ip"] = v
elif k in ["last_user_agent", "last_used"]:
args[k] = v
elif args in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY]:
pass
else:
args["session_data"][k] = v
if old_auth_session:
args["last_user_agent"] = old_auth_session.last_user_agent
args["last_used"] = old_auth_session.last_used
args["session_data"] = pickle.dumps(args["session_data"])
session = Session.objects.using(db_alias).create(**args)
if old_auth_session:
AuthenticatedSession.objects.using(db_alias).create(
session=session,
user=old_auth_session.user,
)
def migrate_redis_sessions(apps, schema_editor):
from django.core.cache import caches
db_alias = schema_editor.connection.alias
cache = caches[SESSION_CACHE_ALIAS]
# Not a redis cache, skipping
if not hasattr(cache, "keys"):
return
print("\nMigrating Redis sessions to database, this might take a couple of minutes...")
for key, session_data in progress_bar(cache.get_many(cache.keys(f"{KEY_PREFIX}*")).items()):
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=key.removeprefix(KEY_PREFIX),
session_data=session_data,
expires=now() + timedelta(seconds=cache.ttl(key)),
)
def migrate_database_sessions(apps, schema_editor):
DjangoSession = apps.get_model("sessions", "Session")
db_alias = schema_editor.connection.alias
print("\nMigration database sessions, this might take a couple of minutes...")
for django_session in progress_bar(DjangoSession.objects.using(db_alias).all()):
session_data = signing.loads(
django_session.session_data,
salt="django.contrib.sessions.SessionStore",
serializer=PickleSerializer,
)
_migrate_session(
apps=apps,
db_alias=db_alias,
session_key=django_session.session_key,
session_data=session_data,
expires=django_session.expire_date,
)
class Migration(migrations.Migration):
dependencies = [
("sessions", "0001_initial"),
("authentik_core", "0045_rename_new_identifier_usersourceconnection_identifier_and_more"),
("authentik_providers_oauth2", "0027_accesstoken_authentik_p_expires_9f24a5_idx_and_more"),
("authentik_providers_rac", "0006_connectiontoken_authentik_p_expires_91f148_idx_and_more"),
]
operations = [
# Rename AuthenticatedSession to OldAuthenticatedSession
migrations.RenameModel(
old_name="AuthenticatedSession",
new_name="OldAuthenticatedSession",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expires_cf4f72_idx",
old_name="authentik_c_expires_08251d_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expirin_c1f17f_idx",
old_name="authentik_c_expirin_9cd839_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_expirin_e04f5d_idx",
old_name="authentik_c_expirin_195a84_idx",
),
migrations.RenameIndex(
model_name="oldauthenticatedsession",
new_name="authentik_c_session_a44819_idx",
old_name="authentik_c_session_d0f005_idx",
),
migrations.RunSQL(
sql="ALTER INDEX authentik_core_authenticatedsession_user_id_5055b6cf RENAME TO authentik_core_oldauthenticatedsession_user_id_5055b6cf",
reverse_sql="ALTER INDEX authentik_core_oldauthenticatedsession_user_id_5055b6cf RENAME TO authentik_core_authenticatedsession_user_id_5055b6cf",
),
# Create new Session and AuthenticatedSession models
migrations.CreateModel(
name="Session",
fields=[
(
"session_key",
models.CharField(
max_length=40, primary_key=True, serialize=False, verbose_name="session key"
),
),
("expires", models.DateTimeField(default=None, null=True)),
("expiring", models.BooleanField(default=True)),
("session_data", models.BinaryField(verbose_name="session data")),
("last_ip", models.GenericIPAddressField()),
("last_user_agent", models.TextField(blank=True)),
("last_used", models.DateTimeField(auto_now=True)),
],
options={
"default_permissions": [],
"verbose_name": "Session",
"verbose_name_plural": "Sessions",
},
),
migrations.AddIndex(
model_name="session",
index=models.Index(fields=["expires"], name="authentik_c_expires_d2f607_idx"),
),
migrations.AddIndex(
model_name="session",
index=models.Index(fields=["expiring"], name="authentik_c_expirin_7c2cfb_idx"),
),
migrations.AddIndex(
model_name="session",
index=models.Index(
fields=["expiring", "expires"], name="authentik_c_expirin_1ab2e4_idx"
),
),
migrations.AddIndex(
model_name="session",
index=models.Index(
fields=["expires", "session_key"], name="authentik_c_expires_c49143_idx"
),
),
migrations.CreateModel(
name="AuthenticatedSession",
fields=[
(
"session",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
serialize=False,
to="authentik_core.session",
),
),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Authenticated Session",
"verbose_name_plural": "Authenticated Sessions",
},
),
migrations.RunPython(
code=migrate_redis_sessions,
reverse_code=migrations.RunPython.noop,
),
migrations.RunPython(
code=migrate_database_sessions,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 5.0.11 on 2025-01-27 13:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("authentik_core", "0046_session_and_more"),
("authentik_providers_rac", "0007_migrate_session"),
("authentik_providers_oauth2", "0028_migrate_session"),
]
operations = [
migrations.DeleteModel(
name="OldAuthenticatedSession",
),
]

View File

@ -1,7 +1,6 @@
"""authentik core models"""
from datetime import datetime
from enum import StrEnum
from hashlib import sha256
from typing import Any, Optional, Self
from uuid import uuid4
@ -10,7 +9,6 @@ from deepmerge import always_merger
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
from django.db import models
from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP
@ -206,8 +204,6 @@ class Group(SerializerModel, AttributesMixin):
permissions = [
("add_user_to_group", _("Add user to group")),
("remove_user_from_group", _("Remove user from group")),
("enable_group_superuser", _("Enable superuser status")),
("disable_group_superuser", _("Disable superuser status")),
]
def __str__(self):
@ -318,32 +314,6 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
always_merger.merge(final_attributes, self.attributes)
return final_attributes
def app_entitlements(self, app: "Application | None") -> QuerySet["ApplicationEntitlement"]:
"""Get all entitlements this user has for `app`."""
if not app:
return []
all_groups = self.all_groups()
qs = app.applicationentitlement_set.filter(
Q(
Q(bindings__user=self) | Q(bindings__group__in=all_groups),
bindings__negate=False,
)
| Q(
Q(~Q(bindings__user=self), bindings__user__isnull=False)
| Q(~Q(bindings__group__in=all_groups), bindings__group__isnull=False),
bindings__negate=True,
),
bindings__enabled=True,
).order_by("name")
return qs
def app_entitlements_attributes(self, app: "Application | None") -> dict:
"""Get a dictionary containing all merged attributes from app entitlements for `app`."""
final_attributes = {}
for attrs in self.app_entitlements(app).values_list("attributes", flat=True):
always_merger.merge(final_attributes, attrs)
return final_attributes
@property
def serializer(self) -> Serializer:
from authentik.core.api.users import UserSerializer
@ -360,13 +330,13 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
"""superuser == staff user"""
return self.is_superuser # type: ignore
def set_password(self, raw_password, signal=True, sender=None, request=None):
def set_password(self, raw_password, signal=True, sender=None):
if self.pk and signal:
from authentik.core.signals import password_changed
if not sender:
sender = self
password_changed.send(sender=sender, user=self, password=raw_password, request=request)
password_changed.send(sender=sender, user=self, password=raw_password)
self.password_change_date = now()
return super().set_password(raw_password)
@ -603,14 +573,6 @@ class Application(SerializerModel, PolicyBindingModel):
return None
return candidates[-1]
def backchannel_provider_for[T: Provider](self, provider_type: type[T], **kwargs) -> T | None:
"""Get Backchannel provider for a specific type"""
providers = self.backchannel_providers.filter(
**{f"{provider_type._meta.model_name}__isnull": False},
**kwargs,
)
return getattr(providers.first(), provider_type._meta.model_name)
def __str__(self):
return str(self.name)
@ -619,59 +581,23 @@ class Application(SerializerModel, PolicyBindingModel):
verbose_name_plural = _("Applications")
class ApplicationEntitlement(AttributesMixin, SerializerModel, PolicyBindingModel):
"""Application-scoped entitlement to control authorization in an application"""
name = models.TextField()
app = models.ForeignKey(Application, on_delete=models.CASCADE)
class Meta:
verbose_name = _("Application Entitlement")
verbose_name_plural = _("Application Entitlements")
unique_together = (("app", "name"),)
def __str__(self):
return f"Application Entitlement {self.name} for app {self.app_id}"
@property
def serializer(self) -> type[Serializer]:
from authentik.core.api.application_entitlements import ApplicationEntitlementSerializer
return ApplicationEntitlementSerializer
def supported_policy_binding_targets(self):
return ["group", "user"]
class SourceUserMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning users"""
IDENTIFIER = "identifier", _("Use the source-specific identifier")
EMAIL_LINK = (
"email_link",
_(
"Link to a user with identical email address. Can have security implications "
"when a source doesn't validate email addresses."
),
EMAIL_LINK = "email_link", _(
"Link to a user with identical email address. Can have security implications "
"when a source doesn't validate email addresses."
)
EMAIL_DENY = (
"email_deny",
_(
"Use the user's email address, but deny enrollment when the email address already "
"exists."
),
EMAIL_DENY = "email_deny", _(
"Use the user's email address, but deny enrollment when the email address already exists."
)
USERNAME_LINK = (
"username_link",
_(
"Link to a user with identical username. Can have security implications "
"when a username is used with another source."
),
USERNAME_LINK = "username_link", _(
"Link to a user with identical username. Can have security implications "
"when a username is used with another source."
)
USERNAME_DENY = (
"username_deny",
_("Use the user's username, but deny enrollment when the username already exists."),
USERNAME_DENY = "username_deny", _(
"Use the user's username, but deny enrollment when the username already exists."
)
@ -679,24 +605,18 @@ class SourceGroupMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning groups"""
IDENTIFIER = "identifier", _("Use the source-specific identifier")
NAME_LINK = (
"name_link",
_(
"Link to a group with identical name. Can have security implications "
"when a group name is used with another source."
),
NAME_LINK = "name_link", _(
"Link to a group with identical name. Can have security implications "
"when a group name is used with another source."
)
NAME_DENY = (
"name_deny",
_("Use the group name, but deny enrollment when the name already exists."),
NAME_DENY = "name_deny", _(
"Use the group name, but deny enrollment when the name already exists."
)
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
MANAGED_INBUILT = "goauthentik.io/sources/inbuilt"
name = models.TextField(help_text=_("Source's display Name."))
slug = models.SlugField(help_text=_("Internal source name, used in URLs."), unique=True)
@ -747,7 +667,8 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
choices=SourceGroupMatchingModes.choices,
default=SourceGroupMatchingModes.IDENTIFIER,
help_text=_(
"How the source determines if an existing group should be used or a new group created."
"How the source determines if an existing group should be used or "
"a new group created."
),
)
@ -777,17 +698,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
@property
def component(self) -> str:
"""Return component used to edit this object"""
if self.managed == self.MANAGED_INBUILT:
return ""
raise NotImplementedError
@property
def property_mapping_type(self) -> "type[PropertyMapping]":
"""Return property mapping type used by this object"""
if self.managed == self.MANAGED_INBUILT:
from authentik.core.models import PropertyMapping
return PropertyMapping
raise NotImplementedError
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
@ -802,14 +717,10 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a user to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a group to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError
def __str__(self):
@ -840,7 +751,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
source = models.ForeignKey(Source, on_delete=models.CASCADE)
identifier = models.TextField()
objects = InheritanceManager()
@ -854,10 +764,6 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
class Meta:
unique_together = (("user", "source"),)
indexes = (
models.Index(fields=("identifier",)),
models.Index(fields=("source", "identifier")),
)
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
@ -889,11 +795,6 @@ class ExpiringModel(models.Model):
class Meta:
abstract = True
indexes = [
models.Index(fields=["expires"]),
models.Index(fields=["expiring"]),
models.Index(fields=["expiring", "expires"]),
]
def expire_action(self, *args, **kwargs):
"""Handler which is called when this object is expired. By
@ -949,7 +850,7 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
class Meta:
verbose_name = _("Token")
verbose_name_plural = _("Tokens")
indexes = ExpiringModel.Meta.indexes + [
indexes = [
models.Index(fields=["identifier"]),
models.Index(fields=["key"]),
]
@ -1028,75 +929,42 @@ class PropertyMapping(SerializerModel, ManagedModel):
verbose_name_plural = _("Property Mappings")
class Session(ExpiringModel, AbstractBaseSession):
"""User session with extra fields for fast access"""
class AuthenticatedSession(ExpiringModel):
"""Additional session class for authenticated users. Augments the standard django session
to achieve the following:
- Make it queryable by user
- Have a direct connection to user objects
- Allow users to view their own sessions and terminate them
- Save structured and well-defined information.
"""
# Remove upstream field because we're using our own ExpiringModel
expire_date = None
session_data = models.BinaryField(_("session data"))
uuid = models.UUIDField(default=uuid4, primary_key=True)
# Keep in sync with Session.Keys
last_ip = models.GenericIPAddressField()
session_key = models.CharField(max_length=40)
user = models.ForeignKey(User, on_delete=models.CASCADE)
last_ip = models.TextField()
last_user_agent = models.TextField(blank=True)
last_used = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("Session")
verbose_name_plural = _("Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["expires", "session_key"]),
]
default_permissions = []
def __str__(self):
return self.session_key
class Keys(StrEnum):
"""
Keys to be set with the session interface for the fields above to be updated.
If a field is added here that needs to be initialized when the session is initialized,
it must also be reflected in authentik.root.middleware.SessionMiddleware.process_request
and in authentik.core.sessions.SessionStore.__init__
"""
LAST_IP = "last_ip"
LAST_USER_AGENT = "last_user_agent"
LAST_USED = "last_used"
@classmethod
def get_session_store_class(cls):
from authentik.core.sessions import SessionStore
return SessionStore
def get_decoded(self):
raise NotImplementedError
class AuthenticatedSession(SerializerModel):
session = models.OneToOneField(Session, on_delete=models.CASCADE, primary_key=True)
# We use the session as primary key, but we need the API to be able to reference
# this object uniquely without exposing the session key
uuid = models.UUIDField(default=uuid4, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
verbose_name = _("Authenticated Session")
verbose_name_plural = _("Authenticated Sessions")
def __str__(self) -> str:
return f"Authenticated Session {str(self.pk)[:10]}"
return f"Authenticated Session {self.session_key[:10]}"
@staticmethod
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request"""
if not hasattr(request, "session") or not request.session.exists(
request.session.session_key
):
from authentik.root.middleware import ClientIPMiddleware
if not hasattr(request, "session") or not request.session.session_key:
return None
return AuthenticatedSession(
session=Session.objects.filter(session_key=request.session.session_key).first(),
session_key=request.session.session_key,
user=user,
last_ip=ClientIPMiddleware.get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
expires=request.session.get_expiry_date(),
)

View File

@ -1,168 +0,0 @@
"""authentik sessions engine"""
import pickle # nosec
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
from django.contrib.sessions.backends.db import SessionStore as SessionBase
from django.core.exceptions import SuspiciousOperation
from django.utils import timezone
from django.utils.functional import cached_property
from structlog.stdlib import get_logger
from authentik.root.middleware import ClientIPMiddleware
LOGGER = get_logger()
class SessionStore(SessionBase):
def __init__(self, session_key=None, last_ip=None, last_user_agent=""):
super().__init__(session_key)
self._create_kwargs = {
"last_ip": last_ip or ClientIPMiddleware.default_ip,
"last_user_agent": last_user_agent,
}
@classmethod
def get_model_class(cls):
from authentik.core.models import Session
return Session
@cached_property
def model_fields(self):
return [k.value for k in self.model.Keys]
def _get_session_from_db(self):
try:
return (
self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.get(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):
LOGGER.warning(str(exc))
self._session_key = None
async def _aget_session_from_db(self):
try:
return (
await self.model.objects.select_related(
"authenticatedsession",
"authenticatedsession__user",
)
.prefetch_related(
"authenticatedsession__user__groups",
"authenticatedsession__user__user_permissions",
)
.aget(
session_key=self.session_key,
expires__gt=timezone.now(),
)
)
except (self.model.DoesNotExist, SuspiciousOperation) as exc:
if isinstance(exc, SuspiciousOperation):
LOGGER.warning(str(exc))
self._session_key = None
def encode(self, session_dict):
return pickle.dumps(session_dict, protocol=pickle.HIGHEST_PROTOCOL)
def decode(self, session_data):
try:
return pickle.loads(session_data) # nosec
except pickle.PickleError:
# ValueError, unpickling exceptions. If any of these happen, just return an empty
# dictionary (an empty session)
pass
return {}
def load(self):
s = self._get_session_from_db()
if s:
return {
"authenticatedsession": getattr(s, "authenticatedsession", None),
**{k: getattr(s, k) for k in self.model_fields},
**self.decode(s.session_data),
}
else:
return {}
async def aload(self):
s = await self._aget_session_from_db()
if s:
return {
"authenticatedsession": getattr(s, "authenticatedsession", None),
**{k: getattr(s, k) for k in self.model_fields},
**self.decode(s.session_data),
}
else:
return {}
def create_model_instance(self, data):
args = {
"session_key": self._get_or_create_session_key(),
"expires": self.get_expiry_date(),
"session_data": {},
**self._create_kwargs,
}
for k, v in data.items():
# Don't save:
# - unused auth data
# - related models
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
pass
elif k in self.model_fields:
args[k] = v
else:
args["session_data"][k] = v
args["session_data"] = self.encode(args["session_data"])
return self.model(**args)
async def acreate_model_instance(self, data):
args = {
"session_key": await self._aget_or_create_session_key(),
"expires": await self.aget_expiry_date(),
"session_data": {},
**self._create_kwargs,
}
for k, v in data.items():
# Don't save:
# - unused auth data
# - related models
if k in [SESSION_KEY, BACKEND_SESSION_KEY, HASH_SESSION_KEY, "authenticatedsession"]:
pass
elif k in self.model_fields:
args[k] = v
else:
args["session_data"][k] = v
args["session_data"] = self.encode(args["session_data"])
return self.model(**args)
@classmethod
def clear_expired(cls):
cls.get_model_class().objects.filter(expires__lt=timezone.now()).delete()
@classmethod
async def aclear_expired(cls):
await cls.get_model_class().objects.filter(expires__lt=timezone.now()).adelete()
def cycle_key(self):
data = self._session
key = self.session_key
self.create()
self._session_cache = data
if key:
self.delete(key)
if (authenticated_session := data.get("authenticatedsession")) is not None:
authenticated_session.session_id = self.session_key
authenticated_session.save(force_insert=True)

View File

@ -1,10 +1,11 @@
"""authentik core signals"""
from django.contrib.auth.signals import user_logged_in
from django.contrib.auth.signals import user_logged_in, user_logged_out
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.core.signals import Signal
from django.db.models import Model
from django.db.models.signals import post_delete, post_save, pre_save
from django.db.models.signals import post_save, pre_delete, pre_save
from django.dispatch import receiver
from django.http.request import HttpRequest
from structlog.stdlib import get_logger
@ -14,7 +15,6 @@ from authentik.core.models import (
AuthenticatedSession,
BackchannelProvider,
ExpiringModel,
Session,
User,
default_token_duration,
)
@ -49,10 +49,19 @@ def user_logged_in_session(sender, request: HttpRequest, user: User, **_):
session.save()
@receiver(post_delete, sender=AuthenticatedSession)
@receiver(user_logged_out)
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
"""Delete AuthenticatedSession if it exists"""
if not request.session or not request.session.session_key:
return
AuthenticatedSession.objects.filter(session_key=request.session.session_key).delete()
@receiver(pre_delete, sender=AuthenticatedSession)
def authenticated_session_delete(sender: type[Model], instance: "AuthenticatedSession", **_):
"""Delete session when authenticated session is deleted"""
Session.objects.filter(session_key=instance.pk).delete()
cache_key = f"{KEY_PREFIX}{instance.session_key}"
cache.delete(cache_key)
@receiver(pre_save)

View File

@ -35,7 +35,8 @@ from authentik.flows.planner import (
FlowPlanner,
)
from authentik.flows.stage import StageView
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
from authentik.lib.utils.urls import redirect_with_qs
from authentik.lib.views import bad_request_message
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.utils import delete_none_values
@ -46,10 +47,8 @@ from authentik.stages.user_write.stage import PLAN_CONTEXT_USER_PATH
LOGGER = get_logger()
PLAN_CONTEXT_SOURCE_GROUPS = "source_groups"
SESSION_KEY_SOURCE_FLOW_STAGES = "authentik/flows/source_flow_stages"
SESSION_KEY_SOURCE_FLOW_CONTEXT = "authentik/flows/source_flow_context"
SESSION_KEY_OVERRIDE_FLOW_TOKEN = "authentik/flows/source_override_flow_token" # nosec
PLAN_CONTEXT_SOURCE_GROUPS = "source_groups"
class MessageStage(StageView):
@ -220,28 +219,34 @@ class SourceFlowManager:
}
)
flow_context.update(self.policy_context)
flow_context.setdefault(PLAN_CONTEXT_REDIRECT, final_redirect)
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self._logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
plan = token.plan
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
plan.context.update(flow_context)
for stage in self.get_stages_to_append(flow):
plan.append_stage(stage)
if stages:
for stage in stages:
plan.append_stage(stage)
self.request.session[SESSION_KEY_PLAN] = plan
flow_slug = token.flow.slug
token.delete()
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=flow_slug,
)
# Ensure redirect is carried through when user was trying to
# authorize application
final_redirect = self.request.session.get(SESSION_KEY_GET, {}).get(
NEXT_ARG_NAME, "authentik_core:if-user"
)
if PLAN_CONTEXT_REDIRECT not in flow_context:
flow_context[PLAN_CONTEXT_REDIRECT] = final_redirect
if not flow:
# We only check for the flow token here if we don't have a flow, otherwise we rely on
# SESSION_KEY_SOURCE_FLOW_STAGES to delegate the usage of this token and dynamically add
# stages that deal with this token to return to another flow
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self._logger.info(
"Replacing source flow with overridden flow", flow=token.flow.slug
)
plan = token.plan
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
plan.context.update(flow_context)
for stage in self.get_stages_to_append(flow):
plan.append_stage(stage)
if stages:
for stage in stages:
plan.append_stage(stage)
redirect = plan.to_redirect(self.request, token.flow)
token.delete()
return redirect
return bad_request_message(
self.request,
_("Configured flow does not exist."),
@ -260,10 +265,12 @@ class SourceFlowManager:
if stages:
for stage in stages:
plan.append_stage(stage)
for stage in self.request.session.get(SESSION_KEY_SOURCE_FLOW_STAGES, []):
plan.append_stage(stage)
plan.context.update(self.request.session.get(SESSION_KEY_SOURCE_FLOW_CONTEXT, {}))
return plan.to_redirect(self.request, flow)
self.request.session[SESSION_KEY_PLAN] = plan
return redirect_with_qs(
"authentik_core:if-flow",
self.request.GET,
flow_slug=flow.slug,
)
def handle_auth(
self,
@ -299,8 +306,6 @@ class SourceFlowManager:
# When request isn't authenticated we jump straight to auth
if not self.request.user.is_authenticated:
return self.handle_auth(connection)
# When an override flow token exists we actually still use a flow for link
# to continue the existing flow we came from
if SESSION_KEY_OVERRIDE_FLOW_TOKEN in self.request.session:
return self._prepare_flow(None, connection)
connection.save()

View File

@ -2,16 +2,22 @@
from datetime import datetime, timedelta
from django.conf import ImproperlyConfigured
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.contrib.sessions.backends.db import SessionStore as DBSessionStore
from django.core.cache import cache
from django.utils.timezone import now
from structlog.stdlib import get_logger
from authentik.core.models import (
USER_ATTRIBUTE_EXPIRES,
USER_ATTRIBUTE_GENERATED,
AuthenticatedSession,
ExpiringModel,
User,
)
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@ -32,6 +38,38 @@ def clean_expired_models(self: SystemTask):
obj.expire_action()
LOGGER.debug("Expired models", model=cls, amount=amount)
messages.append(f"Expired {amount} {cls._meta.verbose_name_plural}")
# Special case
amount = 0
for session in AuthenticatedSession.objects.all():
match CONFIG.get("session_storage", "cache"):
case "cache":
cache_key = f"{KEY_PREFIX}{session.session_key}"
value = None
try:
value = cache.get(cache_key)
except Exception as exc:
LOGGER.debug("Failed to get session from cache", exc=exc)
if not value:
session.delete()
amount += 1
case "db":
if not (
DBSessionStore.get_model_class()
.objects.filter(session_key=session.session_key, expire_date__gt=now())
.exists()
):
session.delete()
amount += 1
case _:
# Should never happen, as we check for other values in authentik/root/settings.py
raise ImproperlyConfigured(
"Invalid session_storage setting, allowed values are db and cache"
)
LOGGER.debug("Expired sessions", model=AuthenticatedSession, amount=amount)
messages.append(f"Expired {amount} {AuthenticatedSession._meta.verbose_name_plural}")
self.set_status(TaskStatus.SUCCESSFUL, *messages)

View File

@ -9,10 +9,6 @@
versionFamily: "{{ version_family }}",
versionSubdomain: "{{ version_subdomain }}",
build: "{{ build }}",
api: {
base: "{{ base_url }}",
relBase: "{{ base_url_rel }}",
},
};
window.addEventListener("DOMContentLoaded", function () {
{% for message in messages %}

View File

@ -8,15 +8,13 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
{# Darkreader breaks the site regardless of theme as its not compatible with webcomponents, and we default to a dark theme based on preferred colour-scheme #}
<meta name="darkreader-lock">
<title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
<link rel="icon" href="{{ brand.branding_favicon_url }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
<link rel="icon" href="{{ brand.branding_favicon }}">
<link rel="shortcut icon" href="{{ brand.branding_favicon }}">
{% block head_before %}
{% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
<style>{{ brand.branding_custom_css }}</style>
<link rel="stylesheet" type="text/css" href="{% static 'dist/custom.css' %}" data-inject>
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
{% block head %}

View File

@ -4,7 +4,7 @@
{% load i18n %}
{% block head_before %}
<link rel="prefetch" href="{{ request.brand.branding_default_flow_background_url }}" />
<link rel="prefetch" href="/static/dist/assets/images/flow_background.jpg" />
<link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
<link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)">
{% include "base/header_js.html" %}
@ -13,7 +13,7 @@
{% block head %}
<style>
:root {
--ak-flow-background: url("{{ request.brand.branding_default_flow_background_url }}");
--ak-flow-background: url("/static/dist/assets/images/flow_background.jpg");
--pf-c-background-image--BackgroundImage: var(--ak-flow-background);
--pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background);
--pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background);
@ -50,7 +50,7 @@
<div class="ak-login-container">
<main class="pf-c-login__main">
<div class="pf-c-login__main-header pf-c-brand ak-brand">
<img src="{{ brand.branding_logo_url }}" alt="authentik Logo" />
<img src="{{ brand.branding_logo }}" alt="authentik Logo" />
</div>
<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">

View File

@ -1,17 +1,9 @@
"""Test API Utils"""
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import (
HyperlinkedModelSerializer,
)
from rest_framework.serializers import (
ModelSerializer as BaseModelSerializer,
)
from rest_framework.test import APITestCase
from authentik.core.api.utils import ModelSerializer as CustomModelSerializer
from authentik.core.api.utils import is_dict
from authentik.lib.utils.reflection import all_subclasses
class TestAPIUtils(APITestCase):
@ -22,14 +14,3 @@ class TestAPIUtils(APITestCase):
self.assertIsNone(is_dict({}))
with self.assertRaises(ValidationError):
is_dict("foo")
def test_all_serializers_descend_from_custom(self):
"""Test that every serializer we define descends from our own ModelSerializer"""
# Weirdly, there's only one serializer in `rest_framework` which descends from
# ModelSerializer: HyperlinkedModelSerializer
expected = {CustomModelSerializer, HyperlinkedModelSerializer}
actual = set(all_subclasses(BaseModelSerializer)) - set(
all_subclasses(CustomModelSerializer)
)
self.assertEqual(expected, actual)

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