Compare commits

..

14 Commits

Author SHA1 Message Date
2706fc0c4f Fixed note section 2025-06-05 00:01:40 +03:00
10cc63883f Typo 2025-06-04 23:18:37 +03:00
53a6eb2b89 Update website/docs/add-secure-apps/providers/rac/rac_credentials_prompt.md
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
2025-06-04 21:12:19 +01:00
4a174944ff Applied suggestions from Tana 2025-06-04 23:08:35 +03:00
4cca1e517e Update website/docs/add-secure-apps/providers/rac/rac_credentials_prompt.md
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
2025-06-04 21:06:13 +01:00
ec02f30be0 Added to sidebar 2025-06-04 23:04:40 +03:00
09c5dd3c9e A word 2025-06-04 22:49:56 +03:00
d436801768 Merge remote-tracking branch 'origin/main' into website/docs--add-credentials-prompt-for-rac-doc 2025-06-04 22:43:04 +03:00
c15e74e5fc Added connection security type information 2025-06-04 22:42:41 +03:00
d2cd829818 Small wording improvements 2025-06-04 11:21:46 +03:00
1b018f33de Update website/docs/add-secure-apps/providers/rac/rac_credentials_prompt.md
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
2025-06-03 17:02:09 +01:00
93e55fe59a Clarified RAC endpoint sentence based on Tana's suggestion. 2025-06-03 19:01:20 +03:00
e9944fab9d Typo 2025-06-03 17:08:01 +03:00
d81c199a89 Adds document 2025-06-03 17:03:05 +03:00
416 changed files with 4854 additions and 4837 deletions

View File

@ -1,16 +1,16 @@
[bumpversion] [bumpversion]
current_version = 2025.6.1 current_version = 2025.6.0
tag = True tag = True
commit = 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*))? parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
serialize = serialize =
{major}.{minor}.{patch}-{rc_t}{rc_n} {major}.{minor}.{patch}-{rc_t}{rc_n}
{major}.{minor}.{patch} {major}.{minor}.{patch}
message = release: {new_version} message = release: {new_version}
tag_name = version/{new_version} tag_name = version/{new_version}
[bumpversion:part:rc_t] [bumpversion:part:rc_t]
values = values =
rc rc
final final
optional_value = final optional_value = final

View File

@ -5,10 +5,8 @@ dist/**
build/** build/**
build_docs/** build_docs/**
*Dockerfile *Dockerfile
**/*Dockerfile
blueprints/local blueprints/local
.git .git
!gen-ts-api/node_modules !gen-ts-api/node_modules
!gen-ts-api/dist/** !gen-ts-api/dist/**
!gen-go-api/ !gen-go-api/
.venv

View File

@ -100,13 +100,6 @@ updates:
goauthentik: goauthentik:
patterns: patterns:
- "@goauthentik/*" - "@goauthentik/*"
eslint:
patterns:
- "@eslint/*"
- "@typescript-eslint/*"
- "eslint-*"
- "eslint"
- "typescript-eslint"
- package-ecosystem: npm - package-ecosystem: npm
directory: "/lifecycle/aws" directory: "/lifecycle/aws"
schedule: schedule:

View File

@ -41,60 +41,32 @@ jobs:
- name: test - name: test
working-directory: website/ working-directory: website/
run: npm test run: npm test
build-container: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: name: ${{ matrix.job }}
# Needed to upload container images to ghcr.io strategy:
packages: write fail-fast: false
# Needed for attestation matrix:
id-token: write job:
attestations: write - build
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: with:
ref: ${{ github.event.pull_request.head.sha }} node-version-file: website/package.json
- name: Set up QEMU cache: "npm"
uses: docker/setup-qemu-action@v3.6.0 cache-dependency-path: website/package-lock.json
- name: Set up Docker Buildx - working-directory: website/
uses: docker/setup-buildx-action@v3 run: npm ci
- name: prepare variables - name: build
uses: ./.github/actions/docker-push-variables working-directory: website/
id: ev run: npm run ${{ matrix.job }}
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
with:
image-name: ghcr.io/goauthentik/dev-docs
- name: Login to Container Registry
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
id: push
uses: docker/build-push-action@v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: website/Dockerfile
push: ${{ steps.ev.outputs.shouldPush == 'true' }}
platforms: linux/amd64,linux/arm64
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
- 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
ci-website-mark: ci-website-mark:
if: always() if: always()
needs: needs:
- lint - lint
- test - test
- build-container - build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: re-actors/alls-green@release/v1 - uses: re-actors/alls-green@release/v1

View File

@ -20,49 +20,6 @@ jobs:
release: true release: true
registry_dockerhub: true registry_dockerhub: true
registry_ghcr: true registry_ghcr: true
build-docs:
runs-on: ubuntu-latest
permissions:
# Needed to upload container images to ghcr.io
packages: write
# Needed for attestation
id-token: write
attestations: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3.6.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/docs
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker Image
id: push
uses: docker/build-push-action@v6
with:
tags: ${{ steps.ev.outputs.imageTags }}
file: website/Dockerfile
push: true
platforms: linux/amd64,linux/arm64
context: .
- uses: actions/attest-build-provenance@v2
id: attest
if: true
with:
subject-name: ${{ steps.ev.outputs.attestImageNames }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
build-outpost: build-outpost:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
@ -236,6 +193,6 @@ jobs:
SENTRY_ORG: authentik-security-inc SENTRY_ORG: authentik-security-inc
SENTRY_PROJECT: authentik SENTRY_PROJECT: authentik
with: with:
release: authentik@${{ steps.ev.outputs.version }} version: authentik@${{ steps.ev.outputs.version }}
sourcemaps: "./web/dist" sourcemaps: "./web/dist"
url_prefix: "~/static/dist" url_prefix: "~/static/dist"

View File

@ -1,7 +1,26 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# Stage 1: Build webui # Stage 1: Build website
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24-slim AS node-builder FROM --platform=${BUILDPLATFORM} docker.io/library/node:24 AS website-builder
ENV NODE_ENV=production
WORKDIR /work/website
RUN --mount=type=bind,target=/work/website/package.json,src=./website/package.json \
--mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \
--mount=type=cache,id=npm-website,sharing=shared,target=/root/.npm \
npm ci --include=dev
COPY ./website /work/website/
COPY ./blueprints /work/blueprints/
COPY ./schema.yml /work/
COPY ./SECURITY.md /work/
RUN npm run build-bundled
# Stage 2: Build webui
FROM --platform=${BUILDPLATFORM} docker.io/library/node:24 AS web-builder
ARG GIT_BUILD_HASH ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
@ -13,7 +32,7 @@ RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \ --mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \ --mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \ --mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-ak,sharing=shared,target=/root/.npm \ --mount=type=cache,id=npm-web,sharing=shared,target=/root/.npm \
npm ci --include=dev npm ci --include=dev
COPY ./package.json /work COPY ./package.json /work
@ -24,7 +43,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build && \ RUN npm run build && \
npm run build:sfe npm run build:sfe
# Stage 2: Build go proxy # Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
ARG TARGETOS ARG TARGETOS
@ -49,8 +68,8 @@ RUN --mount=type=bind,target=/go/src/goauthentik.io/go.mod,src=./go.mod \
COPY ./cmd /go/src/goauthentik.io/cmd COPY ./cmd /go/src/goauthentik.io/cmd
COPY ./authentik/lib /go/src/goauthentik.io/authentik/lib COPY ./authentik/lib /go/src/goauthentik.io/authentik/lib
COPY ./web/static.go /go/src/goauthentik.io/web/static.go COPY ./web/static.go /go/src/goauthentik.io/web/static.go
COPY --from=node-builder /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt COPY --from=web-builder /work/web/robots.txt /go/src/goauthentik.io/web/robots.txt
COPY --from=node-builder /work/web/security.txt /go/src/goauthentik.io/web/security.txt COPY --from=web-builder /work/web/security.txt /go/src/goauthentik.io/web/security.txt
COPY ./internal /go/src/goauthentik.io/internal COPY ./internal /go/src/goauthentik.io/internal
COPY ./go.mod /go/src/goauthentik.io/go.mod COPY ./go.mod /go/src/goauthentik.io/go.mod
COPY ./go.sum /go/src/goauthentik.io/go.sum COPY ./go.sum /go/src/goauthentik.io/go.sum
@ -61,7 +80,7 @@ RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \ CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server go build -o /go/authentik ./cmd/server
# Stage 3: MaxMind GeoIP # Stage 4: MaxMind GeoIP
FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN" ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
@ -74,10 +93,10 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
mkdir -p /usr/share/GeoIP && \ mkdir -p /usr/share/GeoIP && \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0" /bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv # Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.7.12 AS uv FROM ghcr.io/astral-sh/uv:0.7.10 AS uv
# Stage 5: Base python image # Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.4-slim-bookworm-fips AS python-base FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \ ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \ PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \
@ -90,7 +109,7 @@ WORKDIR /ak-root/
COPY --from=uv /uv /uvx /bin/ COPY --from=uv /uv /uvx /bin/
# Stage 6: Python dependencies # Stage 7: Python dependencies
FROM python-base AS python-deps FROM python-base AS python-deps
ARG TARGETARCH ARG TARGETARCH
@ -125,7 +144,7 @@ RUN --mount=type=bind,target=pyproject.toml,src=pyproject.toml \
--mount=type=cache,target=/root/.cache/uv \ --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-install-project --no-dev uv sync --frozen --no-install-project --no-dev
# Stage 7: Run # Stage 8: Run
FROM python-base AS final-image FROM python-base AS final-image
ARG VERSION ARG VERSION
@ -168,8 +187,9 @@ COPY ./lifecycle/ /lifecycle
COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf COPY ./authentik/sources/kerberos/krb5.conf /etc/krb5.conf
COPY --from=go-builder /go/authentik /bin/authentik 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=node-builder /work/web/dist/ /web/dist/ COPY --from=web-builder /work/web/dist/ /web/dist/
COPY --from=node-builder /work/web/authentik/ /web/authentik/ COPY --from=web-builder /work/web/authentik/ /web/authentik/
COPY --from=website-builder /work/website/build/ /website/help/
COPY --from=geoip /usr/share/GeoIP /geoip COPY --from=geoip /usr/share/GeoIP /geoip
USER 1000 USER 1000

View File

@ -2,7 +2,7 @@
from os import environ from os import environ
__version__ = "2025.6.1" __version__ = "2025.6.0"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -0,0 +1,79 @@
"""authentik administration metrics"""
from datetime import timedelta
from django.db.models.functions import ExtractHour
from drf_spectacular.utils import extend_schema, extend_schema_field
from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import IntegerField, SerializerMethodField
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.core.api.utils import PassiveSerializer
from authentik.events.models import EventAction
class CoordinateSerializer(PassiveSerializer):
"""Coordinates for diagrams"""
x_cord = IntegerField(read_only=True)
y_cord = IntegerField(read_only=True)
class LoginMetricsSerializer(PassiveSerializer):
"""Login Metrics per 1h"""
logins = SerializerMethodField()
logins_failed = SerializerMethodField()
authorizations = SerializerMethodField()
@extend_schema_field(CoordinateSerializer(many=True))
def get_logins(self, _):
"""Get successful logins per 8 hours for the last 7 days"""
user = self.context["user"]
return (
get_objects_for_user(user, "authentik_events.view_event").filter(
action=EventAction.LOGIN
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)
@extend_schema_field(CoordinateSerializer(many=True))
def get_logins_failed(self, _):
"""Get failed logins per 8 hours for the last 7 days"""
user = self.context["user"]
return (
get_objects_for_user(user, "authentik_events.view_event").filter(
action=EventAction.LOGIN_FAILED
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)
@extend_schema_field(CoordinateSerializer(many=True))
def get_authorizations(self, _):
"""Get successful authorizations per 8 hours for the last 7 days"""
user = self.context["user"]
return (
get_objects_for_user(user, "authentik_events.view_event").filter(
action=EventAction.AUTHORIZE_APPLICATION
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)
class AdministrationMetricsViewSet(APIView):
"""Login Metrics per 1h"""
permission_classes = [IsAuthenticated]
@extend_schema(responses={200: LoginMetricsSerializer(many=False)})
def get(self, request: Request) -> Response:
"""Login Metrics per 1h"""
serializer = LoginMetricsSerializer(True)
serializer.context["user"] = request.user
return Response(serializer.data)

View File

@ -1,7 +1,6 @@
"""authentik administration overview""" """authentik administration overview"""
from django.core.cache import cache from django.core.cache import cache
from django_tenants.utils import get_public_schema_name
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from packaging.version import parse from packaging.version import parse
from rest_framework.fields import SerializerMethodField from rest_framework.fields import SerializerMethodField
@ -14,7 +13,6 @@ from authentik import __version__, get_build_hash
from authentik.admin.tasks import VERSION_CACHE_KEY, VERSION_NULL, update_latest_version from authentik.admin.tasks import VERSION_CACHE_KEY, VERSION_NULL, update_latest_version
from authentik.core.api.utils import PassiveSerializer from authentik.core.api.utils import PassiveSerializer
from authentik.outposts.models import Outpost from authentik.outposts.models import Outpost
from authentik.tenants.utils import get_current_tenant
class VersionSerializer(PassiveSerializer): class VersionSerializer(PassiveSerializer):
@ -37,8 +35,6 @@ class VersionSerializer(PassiveSerializer):
def get_version_latest(self, _) -> str: def get_version_latest(self, _) -> str:
"""Get latest version from cache""" """Get latest version from cache"""
if get_current_tenant().schema_name == get_public_schema_name():
return __version__
version_in_cache = cache.get(VERSION_CACHE_KEY) version_in_cache = cache.get(VERSION_CACHE_KEY)
if not version_in_cache: # pragma: no cover if not version_in_cache: # pragma: no cover
update_latest_version.delay() update_latest_version.delay()

View File

@ -14,19 +14,3 @@ class AuthentikAdminConfig(ManagedAppConfig):
label = "authentik_admin" label = "authentik_admin"
verbose_name = "authentik Admin" verbose_name = "authentik Admin"
default = True default = True
@ManagedAppConfig.reconcile_global
def clear_update_notifications(self):
"""Clear update notifications on startup if the notification was for the version
we're running now."""
from packaging.version import parse
from authentik.admin.tasks import LOCAL_VERSION
from authentik.events.models import EventAction, Notification
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
if "new_version" not in notification.event.context:
continue
notification_version = notification.event.context["new_version"]
if LOCAL_VERSION >= parse(notification_version):
notification.delete()

View File

@ -1,7 +1,6 @@
"""authentik admin settings""" """authentik admin settings"""
from celery.schedules import crontab from celery.schedules import crontab
from django_tenants.utils import get_public_schema_name
from authentik.lib.utils.time import fqdn_rand from authentik.lib.utils.time import fqdn_rand
@ -9,7 +8,6 @@ CELERY_BEAT_SCHEDULE = {
"admin_latest_version": { "admin_latest_version": {
"task": "authentik.admin.tasks.update_latest_version", "task": "authentik.admin.tasks.update_latest_version",
"schedule": crontab(minute=fqdn_rand("admin_latest_version"), hour="*"), "schedule": crontab(minute=fqdn_rand("admin_latest_version"), hour="*"),
"tenant_schemas": [get_public_schema_name()],
"options": {"queue": "authentik_scheduled"}, "options": {"queue": "authentik_scheduled"},
} }
} }

View File

@ -1,6 +1,7 @@
"""authentik admin tasks""" """authentik admin tasks"""
from django.core.cache import cache from django.core.cache import cache
from django.db import DatabaseError, InternalError, ProgrammingError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from packaging.version import parse from packaging.version import parse
from requests import RequestException from requests import RequestException
@ -8,7 +9,7 @@ from structlog.stdlib import get_logger
from authentik import __version__, get_build_hash from authentik import __version__, get_build_hash
from authentik.admin.apps import PROM_INFO from authentik.admin.apps import PROM_INFO
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction, Notification
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_http_session from authentik.lib.utils.http import get_http_session
@ -32,6 +33,20 @@ def _set_prom_info():
) )
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError),
)
def clear_update_notifications():
"""Clear update notifications on startup if the notification was for the version
we're running now."""
for notification in Notification.objects.filter(event__action=EventAction.UPDATE_AVAILABLE):
if "new_version" not in notification.event.context:
continue
notification_version = notification.event.context["new_version"]
if LOCAL_VERSION >= parse(notification_version):
notification.delete()
@CELERY_APP.task(bind=True, base=SystemTask) @CELERY_APP.task(bind=True, base=SystemTask)
@prefill_task @prefill_task
def update_latest_version(self: SystemTask): def update_latest_version(self: SystemTask):

View File

@ -36,6 +36,11 @@ class TestAdminAPI(TestCase):
body = loads(response.content) body = loads(response.content)
self.assertEqual(len(body), 0) self.assertEqual(len(body), 0)
def test_metrics(self):
"""Test metrics API"""
response = self.client.get(reverse("authentik_api:admin_metrics"))
self.assertEqual(response.status_code, 200)
def test_apps(self): def test_apps(self):
"""Test apps API""" """Test apps API"""
response = self.client.get(reverse("authentik_api:apps-list")) response = self.client.get(reverse("authentik_api:apps-list"))

View File

@ -1,12 +1,12 @@
"""test admin tasks""" """test admin tasks"""
from django.apps import apps
from django.core.cache import cache from django.core.cache import cache
from django.test import TestCase from django.test import TestCase
from requests_mock import Mocker from requests_mock import Mocker
from authentik.admin.tasks import ( from authentik.admin.tasks import (
VERSION_CACHE_KEY, VERSION_CACHE_KEY,
clear_update_notifications,
update_latest_version, update_latest_version,
) )
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -72,13 +72,12 @@ class TestAdminTasks(TestCase):
def test_clear_update_notifications(self): def test_clear_update_notifications(self):
"""Test clear of previous notification""" """Test clear of previous notification"""
admin_config = apps.get_app_config("authentik_admin")
Event.objects.create( Event.objects.create(
action=EventAction.UPDATE_AVAILABLE, context={"new_version": "99999999.9999999.9999999"} action=EventAction.UPDATE_AVAILABLE, context={"new_version": "99999999.9999999.9999999"}
) )
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"}) Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={"new_version": "1.1.1"})
Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={}) Event.objects.create(action=EventAction.UPDATE_AVAILABLE, context={})
admin_config.clear_update_notifications() clear_update_notifications()
self.assertFalse( self.assertFalse(
Event.objects.filter( Event.objects.filter(
action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1" action=EventAction.UPDATE_AVAILABLE, context__new_version="1.1"

View File

@ -3,6 +3,7 @@
from django.urls import path from django.urls import path
from authentik.admin.api.meta import AppsViewSet, ModelViewSet from authentik.admin.api.meta import AppsViewSet, ModelViewSet
from authentik.admin.api.metrics import AdministrationMetricsViewSet
from authentik.admin.api.system import SystemView from authentik.admin.api.system import SystemView
from authentik.admin.api.version import VersionView from authentik.admin.api.version import VersionView
from authentik.admin.api.version_history import VersionHistoryViewSet from authentik.admin.api.version_history import VersionHistoryViewSet
@ -11,6 +12,11 @@ from authentik.admin.api.workers import WorkerView
api_urlpatterns = [ api_urlpatterns = [
("admin/apps", AppsViewSet, "apps"), ("admin/apps", AppsViewSet, "apps"),
("admin/models", ModelViewSet, "models"), ("admin/models", ModelViewSet, "models"),
path(
"admin/metrics/",
AdministrationMetricsViewSet.as_view(),
name="admin_metrics",
),
path("admin/version/", VersionView.as_view(), name="admin_version"), path("admin/version/", VersionView.as_view(), name="admin_version"),
("admin/version/history", VersionHistoryViewSet, "version_history"), ("admin/version/history", VersionHistoryViewSet, "version_history"),
path("admin/workers/", WorkerView.as_view(), name="admin_workers"), path("admin/workers/", WorkerView.as_view(), name="admin_workers"),

View File

@ -1,13 +1,12 @@
"""authentik API AppConfig""" """authentik API AppConfig"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikAPIConfig(ManagedAppConfig): class AuthentikAPIConfig(AppConfig):
"""authentik API Config""" """authentik API Config"""
name = "authentik.api" name = "authentik.api"
label = "authentik_api" label = "authentik_api"
mountpoint = "api/" mountpoint = "api/"
verbose_name = "authentik API" verbose_name = "authentik API"
default = True

View File

@ -1,14 +0,0 @@
from django.test import TestCase
from authentik.blueprints.apps import ManagedAppConfig
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import get_apps
class TestManagedAppConfig(TestCase):
def test_apps_use_managed_app_config(self):
for app in get_apps():
if app.name.startswith("authentik.enterprise"):
self.assertIn(EnterpriseConfig, app.__class__.__bases__)
else:
self.assertIn(ManagedAppConfig, app.__class__.__bases__)

View File

@ -1,9 +1,9 @@
"""authentik brands app""" """authentik brands app"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikBrandsConfig(ManagedAppConfig): class AuthentikBrandsConfig(AppConfig):
"""authentik Brand app""" """authentik Brand app"""
name = "authentik.brands" name = "authentik.brands"
@ -12,4 +12,3 @@ class AuthentikBrandsConfig(ManagedAppConfig):
mountpoints = { mountpoints = {
"authentik.brands.urls_root": "", "authentik.brands.urls_root": "",
} }
default = True

View File

@ -148,14 +148,3 @@ class TestBrands(APITestCase):
"default_locale": "", "default_locale": "",
}, },
) )
def test_custom_css(self):
"""Test custom_css"""
brand = create_test_brand()
brand.branding_custom_css = """* {
font-family: "Foo bar";
}"""
brand.save()
res = self.client.get(reverse("authentik_core:if-user"))
self.assertEqual(res.status_code, 200)
self.assertIn(brand.branding_custom_css, res.content.decode())

View File

@ -5,8 +5,6 @@ from typing import Any
from django.db.models import F, Q from django.db.models import F, Q
from django.db.models import Value as V from django.db.models import Value as V
from django.http.request import HttpRequest from django.http.request import HttpRequest
from django.utils.html import _json_script_escapes
from django.utils.safestring import mark_safe
from authentik import get_full_version from authentik import get_full_version
from authentik.brands.models import Brand from authentik.brands.models import Brand
@ -34,13 +32,8 @@ def context_processor(request: HttpRequest) -> dict[str, Any]:
"""Context Processor that injects brand object into every template""" """Context Processor that injects brand object into every template"""
brand = getattr(request, "brand", DEFAULT_BRAND) brand = getattr(request, "brand", DEFAULT_BRAND)
tenant = getattr(request, "tenant", Tenant()) tenant = getattr(request, "tenant", Tenant())
# similarly to `json_script` we escape everything HTML-related, however django
# only directly exposes this as a function that also wraps it in a <script> tag
# which we dont want for CSS
brand_css = mark_safe(str(brand.branding_custom_css).translate(_json_script_escapes)) # nosec
return { return {
"brand": brand, "brand": brand,
"brand_css": brand_css,
"footer_links": tenant.footer_links, "footer_links": tenant.footer_links,
"html_meta": {**get_http_meta()}, "html_meta": {**get_http_meta()},
"version": get_full_version(), "version": get_full_version(),

View File

@ -2,9 +2,11 @@
from collections.abc import Iterator from collections.abc import Iterator
from copy import copy from copy import copy
from datetime import timedelta
from django.core.cache import cache from django.core.cache import cache
from django.db.models import QuerySet from django.db.models import QuerySet
from django.db.models.functions import ExtractHour
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
@ -18,6 +20,7 @@ from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.api.pagination import Pagination from authentik.api.pagination import Pagination
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.providers import ProviderSerializer from authentik.core.api.providers import ProviderSerializer
@ -25,6 +28,7 @@ from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Application, User from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.events.models import EventAction
from authentik.lib.utils.file import ( from authentik.lib.utils.file import (
FilePathSerializer, FilePathSerializer,
FileUploadSerializer, FileUploadSerializer,
@ -317,3 +321,18 @@ class ApplicationViewSet(UsedByMixin, ModelViewSet):
"""Set application icon (as URL)""" """Set application icon (as URL)"""
app: Application = self.get_object() app: Application = self.get_object()
return set_file_url(request, app, "meta_icon") return set_file_url(request, app, "meta_icon")
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
@extend_schema(responses={200: CoordinateSerializer(many=True)})
@action(detail=True, pagination_class=None, filter_backends=[])
def metrics(self, request: Request, slug: str):
"""Metrics for application logins"""
app = self.get_object()
return Response(
get_objects_for_user(request.user, "authentik_events.view_event").filter(
action=EventAction.AUTHORIZE_APPLICATION,
context__authorized_application__pk=app.pk.hex,
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)

View File

@ -6,6 +6,7 @@ from typing import Any
from django.contrib.auth import update_session_auth_hash from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.db.models.functions import ExtractHour
from django.db.transaction import atomic from django.db.transaction import atomic
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.urls import reverse_lazy from django.urls import reverse_lazy
@ -51,6 +52,7 @@ from rest_framework.validators import UniqueValidator
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.brands.models import Brand from authentik.brands.models import Brand
from authentik.core.api.used_by import UsedByMixin from authentik.core.api.used_by import UsedByMixin
@ -315,6 +317,53 @@ class SessionUserSerializer(PassiveSerializer):
original = UserSelfSerializer(required=False) original = UserSelfSerializer(required=False)
class UserMetricsSerializer(PassiveSerializer):
"""User Metrics"""
logins = SerializerMethodField()
logins_failed = SerializerMethodField()
authorizations = SerializerMethodField()
@extend_schema_field(CoordinateSerializer(many=True))
def get_logins(self, _):
"""Get successful logins per 8 hours for the last 7 days"""
user = self.context["user"]
request = self.context["request"]
return (
get_objects_for_user(request.user, "authentik_events.view_event").filter(
action=EventAction.LOGIN, user__pk=user.pk
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)
@extend_schema_field(CoordinateSerializer(many=True))
def get_logins_failed(self, _):
"""Get failed logins per 8 hours for the last 7 days"""
user = self.context["user"]
request = self.context["request"]
return (
get_objects_for_user(request.user, "authentik_events.view_event").filter(
action=EventAction.LOGIN_FAILED, context__username=user.username
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)
@extend_schema_field(CoordinateSerializer(many=True))
def get_authorizations(self, _):
"""Get failed logins per 8 hours for the last 7 days"""
user = self.context["user"]
request = self.context["request"]
return (
get_objects_for_user(request.user, "authentik_events.view_event").filter(
action=EventAction.AUTHORIZE_APPLICATION, user__pk=user.pk
)
# 3 data points per day, so 8 hour spans
.get_events_per(timedelta(days=7), ExtractHour, 7 * 3)
)
class UsersFilter(FilterSet): class UsersFilter(FilterSet):
"""Filter for users""" """Filter for users"""
@ -558,6 +607,17 @@ class UserViewSet(UsedByMixin, ModelViewSet):
update_session_auth_hash(self.request, user) update_session_auth_hash(self.request, user)
return Response(status=204) return Response(status=204)
@permission_required("authentik_core.view_user", ["authentik_events.view_event"])
@extend_schema(responses={200: UserMetricsSerializer(many=False)})
@action(detail=True, pagination_class=None, filter_backends=[])
def metrics(self, request: Request, pk: int) -> Response:
"""User metrics per 1h"""
user: User = self.get_object()
serializer = UserMetricsSerializer(instance={})
serializer.context["user"] = user
serializer.context["request"] = request
return Response(serializer.data)
@permission_required("authentik_core.reset_user_password") @permission_required("authentik_core.reset_user_password")
@extend_schema( @extend_schema(
responses={ responses={

View File

@ -16,7 +16,7 @@
{% block head_before %} {% block head_before %}
{% endblock %} {% endblock %}
<link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
<style>{{ brand_css }}</style> <style>{{ brand.branding_custom_css }}</style>
<script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script> <script src="{% versioned_script 'dist/poly-%v.js' %}" type="module"></script>
<script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script> <script src="{% versioned_script 'dist/standalone/loading/index-%v.js' %}" type="module"></script>
{% block head %} {% block head %}

View File

@ -10,7 +10,7 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<ak-message-container alignment="bottom"></ak-message-container> <ak-message-container></ak-message-container>
<ak-interface-admin> <ak-interface-admin>
<ak-loading></ak-loading> <ak-loading></ak-loading>
</ak-interface-admin> </ak-interface-admin>

View File

@ -81,6 +81,22 @@ class TestUsersAPI(APITestCase):
response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"}) response = self.client.get(reverse("authentik_api:user-list"), {"include_groups": "true"})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_metrics(self):
"""Test user's metrics"""
self.client.force_login(self.admin)
response = self.client.get(
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 200)
def test_metrics_denied(self):
"""Test user's metrics (non-superuser)"""
self.client.force_login(self.user)
response = self.client.get(
reverse("authentik_api:user-metrics", kwargs={"pk": self.user.pk})
)
self.assertEqual(response.status_code, 403)
def test_recovery_no_flow(self): def test_recovery_no_flow(self):
"""Test user recovery link (no recovery flow set)""" """Test user recovery link (no recovery flow set)"""
self.client.force_login(self.admin) self.client.force_login(self.admin)

View File

@ -1,36 +1,28 @@
"""Events API Views""" """Events API Views"""
from datetime import timedelta from datetime import timedelta
from json import loads
import django_filters import django_filters
from django.db.models import Count, ExpressionWrapper, F, QuerySet from django.db.models.aggregates import Count
from django.db.models import DateTimeField as DjangoDateTimeField
from django.db.models.fields.json import KeyTextTransform, KeyTransform from django.db.models.fields.json import KeyTextTransform, KeyTransform
from django.db.models.functions import TruncHour from django.db.models.functions import ExtractDay, ExtractHour
from django.db.models.query_utils import Q from django.db.models.query_utils import Q
from django.utils.timezone import now
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema from drf_spectacular.utils import OpenApiParameter, extend_schema
from guardian.shortcuts import get_objects_for_user from guardian.shortcuts import get_objects_for_user
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import ChoiceField, DateTimeField, DictField, IntegerField from rest_framework.fields import DictField, IntegerField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.core.api.object_types import TypeCreateSerializer from authentik.core.api.object_types import TypeCreateSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer from authentik.core.api.utils import ModelSerializer, PassiveSerializer
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
class EventVolumeSerializer(PassiveSerializer):
"""Count of events of action created on day"""
action = ChoiceField(choices=EventAction.choices)
time = DateTimeField()
count = IntegerField()
class EventSerializer(ModelSerializer): class EventSerializer(ModelSerializer):
"""Event Serializer""" """Event Serializer"""
@ -61,7 +53,7 @@ class EventsFilter(django_filters.FilterSet):
"""Filter for events""" """Filter for events"""
username = django_filters.CharFilter( username = django_filters.CharFilter(
field_name="user", label="Username", method="filter_username" field_name="user", lookup_expr="username", label="Username"
) )
context_model_pk = django_filters.CharFilter( context_model_pk = django_filters.CharFilter(
field_name="context", field_name="context",
@ -86,19 +78,12 @@ class EventsFilter(django_filters.FilterSet):
field_name="action", field_name="action",
lookup_expr="icontains", lookup_expr="icontains",
) )
actions = django_filters.MultipleChoiceFilter(
field_name="action",
choices=EventAction.choices,
)
brand_name = django_filters.CharFilter( brand_name = django_filters.CharFilter(
field_name="brand", field_name="brand",
lookup_expr="name", lookup_expr="name",
label="Brand name", label="Brand name",
) )
def filter_username(self, queryset, name, value):
return queryset.filter(Q(user__username=value) | Q(context__username=value))
def filter_context_model_pk(self, queryset, name, value): def filter_context_model_pk(self, queryset, name, value):
"""Because we store the PK as UUID.hex, """Because we store the PK as UUID.hex,
we need to remove the dashes that a client may send. We can't use a we need to remove the dashes that a client may send. We can't use a
@ -171,37 +156,45 @@ class EventViewSet(ModelViewSet):
return Response(EventTopPerUserSerializer(instance=events, many=True).data) return Response(EventTopPerUserSerializer(instance=events, many=True).data)
@extend_schema( @extend_schema(
responses={200: EventVolumeSerializer(many=True)}, responses={200: CoordinateSerializer(many=True)},
parameters=[
OpenApiParameter(
"history_days",
type=OpenApiTypes.NUMBER,
location=OpenApiParameter.QUERY,
required=False,
default=7,
),
],
) )
@action(detail=False, methods=["GET"], pagination_class=None) @action(detail=False, methods=["GET"], pagination_class=None)
def volume(self, request: Request) -> Response: def volume(self, request: Request) -> Response:
"""Get event volume for specified filters and timeframe""" """Get event volume for specified filters and timeframe"""
queryset: QuerySet[Event] = self.filter_queryset(self.get_queryset()) queryset = self.filter_queryset(self.get_queryset())
delta = timedelta(days=7) return Response(queryset.get_events_per(timedelta(days=7), ExtractHour, 7 * 3))
time_delta = request.query_params.get("history_days", 7)
if time_delta: @extend_schema(
delta = timedelta(days=min(int(time_delta), 60)) responses={200: CoordinateSerializer(many=True)},
filters=[],
parameters=[
OpenApiParameter(
"action",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
),
OpenApiParameter(
"query",
type=OpenApiTypes.STR,
location=OpenApiParameter.QUERY,
required=False,
),
],
)
@action(detail=False, methods=["GET"], pagination_class=None)
def per_month(self, request: Request):
"""Get the count of events per month"""
filtered_action = request.query_params.get("action", EventAction.LOGIN)
try:
query = loads(request.query_params.get("query", "{}"))
except ValueError:
return Response(status=400)
return Response( return Response(
queryset.filter(created__gte=now() - delta) get_objects_for_user(request.user, "authentik_events.view_event")
.annotate(hour=TruncHour("created")) .filter(action=filtered_action)
.annotate( .filter(**query)
time=ExpressionWrapper( .get_events_per(timedelta(weeks=4), ExtractDay, 30)
F("hour") - (F("hour__hour") % 6) * timedelta(hours=1),
output_field=DjangoDateTimeField(),
)
)
.values("time", "action")
.annotate(count=Count("pk"))
.order_by("time", "action")
) )
@extend_schema(responses={200: TypeCreateSerializer(many=True)}) @extend_schema(responses={200: TypeCreateSerializer(many=True)})

View File

@ -1,5 +1,7 @@
"""authentik events models""" """authentik events models"""
import time
from collections import Counter
from datetime import timedelta from datetime import timedelta
from difflib import get_close_matches from difflib import get_close_matches
from functools import lru_cache from functools import lru_cache
@ -9,6 +11,11 @@ from uuid import uuid4
from django.apps import apps from django.apps import apps
from django.db import connection, models from django.db import connection, models
from django.db.models import Count, ExpressionWrapper, F
from django.db.models.fields import DurationField
from django.db.models.functions import Extract
from django.db.models.manager import Manager
from django.db.models.query import QuerySet
from django.http import HttpRequest from django.http import HttpRequest
from django.http.request import QueryDict from django.http.request import QueryDict
from django.utils.timezone import now from django.utils.timezone import now
@ -117,6 +124,60 @@ class EventAction(models.TextChoices):
CUSTOM_PREFIX = "custom_" CUSTOM_PREFIX = "custom_"
class EventQuerySet(QuerySet):
"""Custom events query set with helper functions"""
def get_events_per(
self,
time_since: timedelta,
extract: Extract,
data_points: int,
) -> list[dict[str, int]]:
"""Get event count by hour in the last day, fill with zeros"""
_now = now()
max_since = timedelta(days=60)
# Allow maximum of 60 days to limit load
if time_since.total_seconds() > max_since.total_seconds():
time_since = max_since
date_from = _now - time_since
result = (
self.filter(created__gte=date_from)
.annotate(age=ExpressionWrapper(_now - F("created"), output_field=DurationField()))
.annotate(age_interval=extract("age"))
.values("age_interval")
.annotate(count=Count("pk"))
.order_by("age_interval")
)
data = Counter({int(d["age_interval"]): d["count"] for d in result})
results = []
interval_delta = time_since / data_points
for interval in range(1, -data_points, -1):
results.append(
{
"x_cord": time.mktime((_now + (interval_delta * interval)).timetuple()) * 1000,
"y_cord": data[interval * -1],
}
)
return results
class EventManager(Manager):
"""Custom helper methods for Events"""
def get_queryset(self) -> QuerySet:
"""use custom queryset"""
return EventQuerySet(self.model, using=self._db)
def get_events_per(
self,
time_since: timedelta,
extract: Extract,
data_points: int,
) -> list[dict[str, int]]:
"""Wrap method from queryset"""
return self.get_queryset().get_events_per(time_since, extract, data_points)
class Event(SerializerModel, ExpiringModel): class Event(SerializerModel, ExpiringModel):
"""An individual Audit/Metrics/Notification/Error Event""" """An individual Audit/Metrics/Notification/Error Event"""
@ -132,6 +193,8 @@ class Event(SerializerModel, ExpiringModel):
# Shadow the expires attribute from ExpiringModel to override the default duration # Shadow the expires attribute from ExpiringModel to override the default duration
expires = models.DateTimeField(default=default_event_duration) expires = models.DateTimeField(default=default_event_duration)
objects = EventManager()
@staticmethod @staticmethod
def _get_app_from_request(request: HttpRequest) -> str: def _get_app_from_request(request: HttpRequest) -> str:
if not isinstance(request, HttpRequest): if not isinstance(request, HttpRequest):

View File

@ -37,9 +37,6 @@ class WebsocketMessageInstruction(IntEnum):
# Provider specific message # Provider specific message
PROVIDER_SPECIFIC = 3 PROVIDER_SPECIFIC = 3
# Session ended
SESSION_END = 4
@dataclass(slots=True) @dataclass(slots=True)
class WebsocketMessage: class WebsocketMessage:
@ -148,14 +145,6 @@ class OutpostConsumer(JsonWebsocketConsumer):
asdict(WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE)) asdict(WebsocketMessage(instruction=WebsocketMessageInstruction.TRIGGER_UPDATE))
) )
def event_session_end(self, event):
"""Event handler which is called when a session is ended"""
self.send_json(
asdict(
WebsocketMessage(instruction=WebsocketMessageInstruction.SESSION_END, args=event)
)
)
def event_provider_specific(self, event): def event_provider_specific(self, event):
"""Event handler which can be called by provider-specific """Event handler which can be called by provider-specific
implementations to send specific messages to the outpost""" implementations to send specific messages to the outpost"""

View File

@ -1,24 +1,17 @@
"""authentik outpost signals""" """authentik outpost signals"""
from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache from django.core.cache import cache
from django.db.models import Model from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.http import HttpRequest
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.brands.models import Brand from authentik.brands.models import Brand
from authentik.core.models import AuthenticatedSession, Provider, User from authentik.core.models import Provider
from authentik.crypto.models import CertificateKeyPair from authentik.crypto.models import CertificateKeyPair
from authentik.lib.utils.reflection import class_to_path from authentik.lib.utils.reflection import class_to_path
from authentik.outposts.models import Outpost, OutpostServiceConnection from authentik.outposts.models import Outpost, OutpostServiceConnection
from authentik.outposts.tasks import ( from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save
CACHE_KEY_OUTPOST_DOWN,
outpost_controller,
outpost_post_save,
outpost_session_end,
)
LOGGER = get_logger() LOGGER = get_logger()
UPDATE_TRIGGERING_MODELS = ( UPDATE_TRIGGERING_MODELS = (
@ -80,17 +73,3 @@ def pre_delete_cleanup(sender, instance: Outpost, **_):
instance.user.delete() instance.user.delete()
cache.set(CACHE_KEY_OUTPOST_DOWN % instance.pk.hex, instance) cache.set(CACHE_KEY_OUTPOST_DOWN % instance.pk.hex, instance)
outpost_controller.delay(instance.pk.hex, action="down", from_cache=True) outpost_controller.delay(instance.pk.hex, action="down", from_cache=True)
@receiver(user_logged_out)
def logout_revoke_direct(sender: type[User], request: HttpRequest, **_):
"""Catch logout by direct logout and forward to providers"""
if not request.session or not request.session.session_key:
return
outpost_session_end.delay(request.session.session_key)
@receiver(pre_delete, sender=AuthenticatedSession)
def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted"""
outpost_session_end.delay(instance.session.session_key)

View File

@ -1,6 +1,5 @@
"""outpost tasks""" """outpost tasks"""
from hashlib import sha256
from os import R_OK, access from os import R_OK, access
from pathlib import Path from pathlib import Path
from socket import gethostname from socket import gethostname
@ -50,11 +49,6 @@ LOGGER = get_logger()
CACHE_KEY_OUTPOST_DOWN = "goauthentik.io/outposts/teardown/%s" CACHE_KEY_OUTPOST_DOWN = "goauthentik.io/outposts/teardown/%s"
def hash_session_key(session_key: str) -> str:
"""Hash the session key for sending session end signals"""
return sha256(session_key.encode("ascii")).hexdigest()
def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None: def controller_for_outpost(outpost: Outpost) -> type[BaseController] | None:
"""Get a controller for the outpost, when a service connection is defined""" """Get a controller for the outpost, when a service connection is defined"""
if not outpost.service_connection: if not outpost.service_connection:
@ -295,20 +289,3 @@ def outpost_connection_discovery(self: SystemTask):
url=unix_socket_path, url=unix_socket_path,
) )
self.set_status(TaskStatus.SUCCESSFUL, *messages) self.set_status(TaskStatus.SUCCESSFUL, *messages)
@CELERY_APP.task()
def outpost_session_end(session_id: str):
"""Update outpost instances connected to a single outpost"""
layer = get_channel_layer()
hashed_session_id = hash_session_key(session_id)
for outpost in Outpost.objects.all():
LOGGER.info("Sending session end signal to outpost", outpost=outpost)
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
async_to_sync(layer.group_send)(
group,
{
"type": "event.session.end",
"session_id": hashed_session_id,
},
)

View File

@ -1,12 +1,11 @@
"""Authentik policy dummy app config""" """Authentik policy dummy app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPolicyDummyConfig(ManagedAppConfig): class AuthentikPolicyDummyConfig(AppConfig):
"""Authentik policy_dummy app config""" """Authentik policy_dummy app config"""
name = "authentik.policies.dummy" name = "authentik.policies.dummy"
label = "authentik_policies_dummy" label = "authentik_policies_dummy"
verbose_name = "authentik Policies.Dummy" verbose_name = "authentik Policies.Dummy"
default = True

View File

@ -1,12 +1,11 @@
"""authentik Event Matcher policy app config""" """authentik Event Matcher policy app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPoliciesEventMatcherConfig(ManagedAppConfig): class AuthentikPoliciesEventMatcherConfig(AppConfig):
"""authentik Event Matcher policy app config""" """authentik Event Matcher policy app config"""
name = "authentik.policies.event_matcher" name = "authentik.policies.event_matcher"
label = "authentik_policies_event_matcher" label = "authentik_policies_event_matcher"
verbose_name = "authentik Policies.Event Matcher" verbose_name = "authentik Policies.Event Matcher"
default = True

View File

@ -1,12 +1,11 @@
"""Authentik policy_expiry app config""" """Authentik policy_expiry app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPolicyExpiryConfig(ManagedAppConfig): class AuthentikPolicyExpiryConfig(AppConfig):
"""Authentik policy_expiry app config""" """Authentik policy_expiry app config"""
name = "authentik.policies.expiry" name = "authentik.policies.expiry"
label = "authentik_policies_expiry" label = "authentik_policies_expiry"
verbose_name = "authentik Policies.Expiry" verbose_name = "authentik Policies.Expiry"
default = True

View File

@ -1,12 +1,11 @@
"""Authentik policy_expression app config""" """Authentik policy_expression app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPolicyExpressionConfig(ManagedAppConfig): class AuthentikPolicyExpressionConfig(AppConfig):
"""Authentik policy_expression app config""" """Authentik policy_expression app config"""
name = "authentik.policies.expression" name = "authentik.policies.expression"
label = "authentik_policies_expression" label = "authentik_policies_expression"
verbose_name = "authentik Policies.Expression" verbose_name = "authentik Policies.Expression"
default = True

View File

@ -1,12 +1,11 @@
"""Authentik policy geoip app config""" """Authentik policy geoip app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPolicyGeoIPConfig(ManagedAppConfig): class AuthentikPolicyGeoIPConfig(AppConfig):
"""Authentik policy_geoip app config""" """Authentik policy_geoip app config"""
name = "authentik.policies.geoip" name = "authentik.policies.geoip"
label = "authentik_policies_geoip" label = "authentik_policies_geoip"
verbose_name = "authentik Policies.GeoIP" verbose_name = "authentik Policies.GeoIP"
default = True

View File

@ -1,12 +1,11 @@
"""authentik Password policy app config""" """authentik Password policy app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikPoliciesPasswordConfig(ManagedAppConfig): class AuthentikPoliciesPasswordConfig(AppConfig):
"""authentik Password policy app config""" """authentik Password policy app config"""
name = "authentik.policies.password" name = "authentik.policies.password"
label = "authentik_policies_password" label = "authentik_policies_password"
verbose_name = "authentik Policies.Password" verbose_name = "authentik Policies.Password"
default = True

View File

@ -1,12 +1,11 @@
"""authentik ldap provider app config""" """authentik ldap provider app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikProviderLDAPConfig(ManagedAppConfig): class AuthentikProviderLDAPConfig(AppConfig):
"""authentik ldap provider app config""" """authentik ldap provider app config"""
name = "authentik.providers.ldap" name = "authentik.providers.ldap"
label = "authentik_providers_ldap" label = "authentik_providers_ldap"
verbose_name = "authentik Providers.LDAP" verbose_name = "authentik Providers.LDAP"
default = True

View File

@ -10,11 +10,3 @@ class AuthentikProviderProxyConfig(ManagedAppConfig):
label = "authentik_providers_proxy" label = "authentik_providers_proxy"
verbose_name = "authentik Providers.Proxy" verbose_name = "authentik Providers.Proxy"
default = True default = True
@ManagedAppConfig.reconcile_tenant
def proxy_set_defaults(self):
from authentik.providers.proxy.models import ProxyProvider
for provider in ProxyProvider.objects.all():
provider.set_oauth_defaults()
provider.save()

View File

@ -0,0 +1,23 @@
"""Proxy provider signals"""
from django.contrib.auth.signals import user_logged_out
from django.db.models.signals import pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User
from authentik.providers.proxy.tasks import proxy_on_logout
@receiver(user_logged_out)
def logout_proxy_revoke_direct(sender: type[User], request: HttpRequest, **_):
"""Catch logout by direct logout and forward to proxy providers"""
if not request.session or not request.session.session_key:
return
proxy_on_logout.delay(request.session.session_key)
@receiver(pre_delete, sender=AuthenticatedSession)
def logout_proxy_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted"""
proxy_on_logout.delay(instance.session.session_key)

View File

@ -0,0 +1,38 @@
"""proxy provider tasks"""
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.db import DatabaseError, InternalError, ProgrammingError
from authentik.outposts.consumer import OUTPOST_GROUP
from authentik.outposts.models import Outpost, OutpostType
from authentik.providers.oauth2.id_token import hash_session_key
from authentik.providers.proxy.models import ProxyProvider
from authentik.root.celery import CELERY_APP
@CELERY_APP.task(
throws=(DatabaseError, ProgrammingError, InternalError),
)
def proxy_set_defaults():
"""Ensure correct defaults are set for all providers"""
for provider in ProxyProvider.objects.all():
provider.set_oauth_defaults()
provider.save()
@CELERY_APP.task()
def proxy_on_logout(session_id: str):
"""Update outpost instances connected to a single outpost"""
layer = get_channel_layer()
hashed_session_id = hash_session_key(session_id)
for outpost in Outpost.objects.filter(type=OutpostType.PROXY):
group = OUTPOST_GROUP % {"outpost_pk": str(outpost.pk)}
async_to_sync(layer.group_send)(
group,
{
"type": "event.provider.specific",
"sub_type": "logout",
"session_id": hashed_session_id,
},
)

View File

@ -1,12 +1,11 @@
"""authentik radius provider app config""" """authentik radius provider app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikProviderRadiusConfig(ManagedAppConfig): class AuthentikProviderRadiusConfig(AppConfig):
"""authentik radius provider app config""" """authentik radius provider app config"""
name = "authentik.providers.radius" name = "authentik.providers.radius"
label = "authentik_providers_radius" label = "authentik_providers_radius"
verbose_name = "authentik Providers.Radius" verbose_name = "authentik Providers.Radius"
default = True

View File

@ -1,13 +1,12 @@
"""authentik SAML IdP app config""" """authentik SAML IdP app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikProviderSAMLConfig(ManagedAppConfig): class AuthentikProviderSAMLConfig(AppConfig):
"""authentik SAML IdP app config""" """authentik SAML IdP app config"""
name = "authentik.providers.saml" name = "authentik.providers.saml"
label = "authentik_providers_saml" label = "authentik_providers_saml"
verbose_name = "authentik Providers.SAML" verbose_name = "authentik Providers.SAML"
mountpoint = "application/saml/" mountpoint = "application/saml/"
default = True

View File

@ -1,29 +1,12 @@
"""test decorators api""" """test decorators api"""
from django.urls import reverse
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
from rest_framework.decorators import action
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from rest_framework.viewsets import ModelViewSet
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_user from authentik.core.tests.utils import create_test_user
from authentik.lib.generators import generate_id from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import get_request
from authentik.rbac.decorators import permission_required
class MVS(ModelViewSet):
queryset = Application.objects.all()
lookup_field = "slug"
@permission_required("authentik_core.view_application", ["authentik_events.view_event"])
@action(detail=True, pagination_class=None, filter_backends=[])
def test(self, request: Request, slug: str):
self.get_object()
return Response(status=200)
class TestAPIDecorators(APITestCase): class TestAPIDecorators(APITestCase):
@ -35,33 +18,41 @@ class TestAPIDecorators(APITestCase):
def test_obj_perm_denied(self): def test_obj_perm_denied(self):
"""Test object perm denied""" """Test object perm denied"""
request = get_request("", user=self.user) self.client.force_login(self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id()) app = Application.objects.create(name=generate_id(), slug=generate_id())
response = MVS.as_view({"get": "test"})(request, slug=app.slug) response = self.client.get(
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_obj_perm_global(self): def test_obj_perm_global(self):
"""Test object perm successful (global)""" """Test object perm successful (global)"""
assign_perm("authentik_core.view_application", self.user) assign_perm("authentik_core.view_application", self.user)
assign_perm("authentik_events.view_event", self.user) assign_perm("authentik_events.view_event", self.user)
self.client.force_login(self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id()) app = Application.objects.create(name=generate_id(), slug=generate_id())
request = get_request("", user=self.user) response = self.client.get(
response = MVS.as_view({"get": "test"})(request, slug=app.slug) reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
self.assertEqual(response.status_code, 200, response.data) )
self.assertEqual(response.status_code, 200)
def test_obj_perm_scoped(self): def test_obj_perm_scoped(self):
"""Test object perm successful (scoped)""" """Test object perm successful (scoped)"""
assign_perm("authentik_events.view_event", self.user) assign_perm("authentik_events.view_event", self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id()) app = Application.objects.create(name=generate_id(), slug=generate_id())
assign_perm("authentik_core.view_application", self.user, app) assign_perm("authentik_core.view_application", self.user, app)
request = get_request("", user=self.user) self.client.force_login(self.user)
response = MVS.as_view({"get": "test"})(request, slug=app.slug) response = self.client.get(
reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def test_other_perm_denied(self): def test_other_perm_denied(self):
"""Test other perm denied""" """Test other perm denied"""
self.client.force_login(self.user)
app = Application.objects.create(name=generate_id(), slug=generate_id()) app = Application.objects.create(name=generate_id(), slug=generate_id())
assign_perm("authentik_core.view_application", self.user, app) assign_perm("authentik_core.view_application", self.user, app)
request = get_request("", user=self.user) response = self.client.get(
response = MVS.as_view({"get": "test"})(request, slug=app.slug) reverse("authentik_api:application-metrics", kwargs={"slug": app.slug})
)
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)

View File

@ -1,13 +1,12 @@
"""authentik Recovery app config""" """authentik Recovery app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikRecoveryConfig(ManagedAppConfig): class AuthentikRecoveryConfig(AppConfig):
"""authentik Recovery app config""" """authentik Recovery app config"""
name = "authentik.recovery" name = "authentik.recovery"
label = "authentik_recovery" label = "authentik_recovery"
verbose_name = "authentik Recovery" verbose_name = "authentik Recovery"
mountpoint = "recovery/" mountpoint = "recovery/"
default = True

View File

@ -98,7 +98,13 @@ def _get_startup_tasks_default_tenant() -> list[Callable]:
def _get_startup_tasks_all_tenants() -> list[Callable]: def _get_startup_tasks_all_tenants() -> list[Callable]:
"""Get all tasks to be run on startup for all tenants""" """Get all tasks to be run on startup for all tenants"""
return [] from authentik.admin.tasks import clear_update_notifications
from authentik.providers.proxy.tasks import proxy_set_defaults
return [
clear_update_notifications,
proxy_set_defaults,
]
@worker_ready.connect @worker_ready.connect

View File

@ -7,7 +7,6 @@ from unittest import TestCase
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.test.runner import DiscoverRunner from django.test.runner import DiscoverRunner
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG from authentik.lib.config import CONFIG
from authentik.lib.sentry import sentry_init from authentik.lib.sentry import sentry_init
@ -23,7 +22,6 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.logger = get_logger().bind(runner="pytest")
self.args = [] self.args = []
if self.failfast: if self.failfast:
@ -36,33 +34,22 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
if kwargs.get("no_capture", False): if kwargs.get("no_capture", False):
self.args.append("--capture=no") self.args.append("--capture=no")
self._setup_test_environment()
def _setup_test_environment(self):
"""Configure test environment settings"""
settings.TEST = True settings.TEST = True
settings.CELERY["task_always_eager"] = True settings.CELERY["task_always_eager"] = True
CONFIG.set("events.context_processors.geoip", "tests/GeoLite2-City-Test.mmdb")
# Test-specific configuration CONFIG.set("events.context_processors.asn", "tests/GeoLite2-ASN-Test.mmdb")
test_config = { CONFIG.set("blueprints_dir", "./blueprints")
"events.context_processors.geoip": "tests/GeoLite2-City-Test.mmdb", CONFIG.set(
"events.context_processors.asn": "tests/GeoLite2-ASN-Test.mmdb", "outposts.container_image_base",
"blueprints_dir": "./blueprints", f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}",
"outposts.container_image_base": f"ghcr.io/goauthentik/dev-%(type)s:{get_docker_tag()}", )
"tenants.enabled": False, CONFIG.set("tenants.enabled", False)
"outposts.disable_embedded_outpost": False, CONFIG.set("outposts.disable_embedded_outpost", False)
"error_reporting.sample_rate": 0, CONFIG.set("error_reporting.sample_rate", 0)
"error_reporting.environment": "testing", CONFIG.set("error_reporting.environment", "testing")
"error_reporting.send_pii": True, CONFIG.set("error_reporting.send_pii", True)
}
for key, value in test_config.items():
CONFIG.set(key, value)
sentry_init() sentry_init()
self.logger.debug("Test environment configured")
# Send startup signals
pre_startup.send(sender=self, mode="test") pre_startup.send(sender=self, mode="test")
startup.send(sender=self, mode="test") startup.send(sender=self, mode="test")
post_startup.send(sender=self, mode="test") post_startup.send(sender=self, mode="test")
@ -85,21 +72,7 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
help="Disable any capturing of stdout/stderr during tests.", help="Disable any capturing of stdout/stderr during tests.",
) )
def _validate_test_label(self, label: str) -> bool: def run_tests(self, test_labels, extra_tests=None, **kwargs):
"""Validate test label format"""
if not label:
return False
# Check for invalid characters, but allow forward slashes and colons
# for paths and pytest markers
invalid_chars = set('\\*?"<>|')
if any(c in label for c in invalid_chars):
self.logger.error("Invalid characters in test label", label=label)
return False
return True
def run_tests(self, test_labels: list[str], extra_tests=None, **kwargs):
"""Run pytest and return the exitcode. """Run pytest and return the exitcode.
It translates some of Django's test command option to pytest's. It translates some of Django's test command option to pytest's.
@ -109,17 +82,10 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
The extra_tests argument has been deprecated since Django 5.x The extra_tests argument has been deprecated since Django 5.x
It is kept for compatibility with PyCharm's Django test runner. It is kept for compatibility with PyCharm's Django test runner.
""" """
if not test_labels:
self.logger.error("No test files specified")
return 1
for label in test_labels: for label in test_labels:
if not self._validate_test_label(label):
return 1
valid_label_found = False valid_label_found = False
label_as_path = os.path.abspath(label) label_as_path = os.path.abspath(label)
# File path has been specified # File path has been specified
if os.path.exists(label_as_path): if os.path.exists(label_as_path):
self.args.append(label_as_path) self.args.append(label_as_path)
@ -127,30 +93,24 @@ class PytestTestRunner(DiscoverRunner): # pragma: no cover
elif "::" in label: elif "::" in label:
self.args.append(label) self.args.append(label)
valid_label_found = True valid_label_found = True
# Convert dotted module path to file_path::class::method
else: else:
# Check if the label is a dotted module path
path_pieces = label.split(".") path_pieces = label.split(".")
# Check whether only class or class and method are specified
for i in range(-1, -3, -1): for i in range(-1, -3, -1):
try: path = os.path.join(*path_pieces[:i]) + ".py"
path = os.path.join(*path_pieces[:i]) + ".py" label_as_path = os.path.abspath(path)
if os.path.exists(path): if os.path.exists(label_as_path):
if i < -1: path_method = label_as_path + "::" + "::".join(path_pieces[i:])
path_method = path + "::" + "::".join(path_pieces[i:]) self.args.append(path_method)
self.args.append(path_method) valid_label_found = True
else: break
self.args.append(path)
valid_label_found = True
break
except (TypeError, IndexError):
continue
if not valid_label_found: if not valid_label_found:
self.logger.error("Test file not found", label=label) raise RuntimeError(
return 1 f"One of the test labels: {label!r}, "
f"is not supported. Use a dotted module name or "
f"path instead."
)
self.logger.info("Running tests", test_files=self.args) return pytest.main(self.args)
try:
return pytest.main(self.args)
except Exception as e:
self.logger.error("Error running tests", error=str(e), test_files=self.args)
return 1

View File

@ -1,12 +1,11 @@
"""authentik plex config""" """authentik plex config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikSourcePlexConfig(ManagedAppConfig): class AuthentikSourcePlexConfig(AppConfig):
"""authentik source plex config""" """authentik source plex config"""
name = "authentik.sources.plex" name = "authentik.sources.plex"
label = "authentik_sources_plex" label = "authentik_sources_plex"
verbose_name = "authentik Sources.Plex" verbose_name = "authentik Sources.Plex"
default = True

View File

@ -1,12 +1,11 @@
"""Authenticator""" """Authenticator"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageAuthenticatorConfig(ManagedAppConfig): class AuthentikStageAuthenticatorConfig(AppConfig):
"""Authenticator App config""" """Authenticator App config"""
name = "authentik.stages.authenticator" name = "authentik.stages.authenticator"
label = "authentik_stages_authenticator" label = "authentik_stages_authenticator"
verbose_name = "authentik Stages.Authenticator" verbose_name = "authentik Stages.Authenticator"
default = True

View File

@ -1,12 +1,11 @@
"""SMS""" """SMS"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageAuthenticatorSMSConfig(ManagedAppConfig): class AuthentikStageAuthenticatorSMSConfig(AppConfig):
"""SMS App config""" """SMS App config"""
name = "authentik.stages.authenticator_sms" name = "authentik.stages.authenticator_sms"
label = "authentik_stages_authenticator_sms" label = "authentik_stages_authenticator_sms"
verbose_name = "authentik Stages.Authenticator.SMS" verbose_name = "authentik Stages.Authenticator.SMS"
default = True

View File

@ -1,12 +1,11 @@
"""TOTP""" """TOTP"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageAuthenticatorTOTPConfig(ManagedAppConfig): class AuthentikStageAuthenticatorTOTPConfig(AppConfig):
"""TOTP App config""" """TOTP App config"""
name = "authentik.stages.authenticator_totp" name = "authentik.stages.authenticator_totp"
label = "authentik_stages_authenticator_totp" label = "authentik_stages_authenticator_totp"
verbose_name = "authentik Stages.Authenticator.TOTP" verbose_name = "authentik Stages.Authenticator.TOTP"
default = True

View File

@ -1,12 +1,11 @@
"""Authenticator Validation Stage""" """Authenticator Validation Stage"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageAuthenticatorValidateConfig(ManagedAppConfig): class AuthentikStageAuthenticatorValidateConfig(AppConfig):
"""Authenticator Validation Stage""" """Authenticator Validation Stage"""
name = "authentik.stages.authenticator_validate" name = "authentik.stages.authenticator_validate"
label = "authentik_stages_authenticator_validate" label = "authentik_stages_authenticator_validate"
verbose_name = "authentik Stages.Authenticator.Validate" verbose_name = "authentik Stages.Authenticator.Validate"
default = True

View File

@ -1,12 +1,11 @@
"""authentik captcha app""" """authentik captcha app"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageCaptchaConfig(ManagedAppConfig): class AuthentikStageCaptchaConfig(AppConfig):
"""authentik captcha app""" """authentik captcha app"""
name = "authentik.stages.captcha" name = "authentik.stages.captcha"
label = "authentik_stages_captcha" label = "authentik_stages_captcha"
verbose_name = "authentik Stages.Captcha" verbose_name = "authentik Stages.Captcha"
default = True

View File

@ -1,12 +1,11 @@
"""authentik consent app""" """authentik consent app"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageConsentConfig(ManagedAppConfig): class AuthentikStageConsentConfig(AppConfig):
"""authentik consent app""" """authentik consent app"""
name = "authentik.stages.consent" name = "authentik.stages.consent"
label = "authentik_stages_consent" label = "authentik_stages_consent"
verbose_name = "authentik Stages.Consent" verbose_name = "authentik Stages.Consent"
default = True

View File

@ -1,12 +1,11 @@
"""authentik deny stage app config""" """authentik deny stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageDenyConfig(ManagedAppConfig): class AuthentikStageDenyConfig(AppConfig):
"""authentik deny stage config""" """authentik deny stage config"""
name = "authentik.stages.deny" name = "authentik.stages.deny"
label = "authentik_stages_deny" label = "authentik_stages_deny"
verbose_name = "authentik Stages.Deny" verbose_name = "authentik Stages.Deny"
default = True

View File

@ -1,12 +1,11 @@
"""authentik dummy stage config""" """authentik dummy stage config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageDummyConfig(ManagedAppConfig): class AuthentikStageDummyConfig(AppConfig):
"""authentik dummy stage config""" """authentik dummy stage config"""
name = "authentik.stages.dummy" name = "authentik.stages.dummy"
label = "authentik_stages_dummy" label = "authentik_stages_dummy"
verbose_name = "authentik Stages.Dummy" verbose_name = "authentik Stages.Dummy"
default = True

View File

@ -100,11 +100,9 @@ def send_mail(
# Because we use the Message-ID as UID for the task, manually assign it # Because we use the Message-ID as UID for the task, manually assign it
message_object.extra_headers["Message-ID"] = message_id message_object.extra_headers["Message-ID"] = message_id
# Add the logo if it is used in the email body (we can't add it in the # Add the logo (we can't add it in the previous message since MIMEImage
# previous message since MIMEImage can't be converted to json) # can't be converted to json)
body = get_email_body(message_object) message_object.attach(logo_data())
if "cid:logo" in body:
message_object.attach(logo_data())
if ( if (
message_object.to message_object.to

View File

@ -96,7 +96,7 @@
<table width="100%" style="background-color: #FFFFFF; border-spacing: 0; margin-top: 15px;"> <table width="100%" style="background-color: #FFFFFF; border-spacing: 0; margin-top: 15px;">
<tr height="80"> <tr height="80">
<td align="center" style="padding: 20px 0;"> <td align="center" style="padding: 20px 0;">
<img src="{% block logo_url %}cid:logo{% endblock %}" border="0=" alt="authentik logo" class="flexibleImage logo"> <img src="{% block logo_url %}cid:logo.png{% endblock %}" border="0=" alt="authentik logo" class="flexibleImage logo">
</td> </td>
</tr> </tr>
{% block content %} {% block content %}

View File

@ -19,8 +19,7 @@ def logo_data() -> MIMEImage:
path = Path("web/dist/assets/icons/icon_left_brand.png") path = Path("web/dist/assets/icons/icon_left_brand.png")
with open(path, "rb") as _logo_file: with open(path, "rb") as _logo_file:
logo = MIMEImage(_logo_file.read()) logo = MIMEImage(_logo_file.read())
logo.add_header("Content-ID", "<logo>") logo.add_header("Content-ID", "logo.png")
logo.add_header("Content-Disposition", "inline", filename="logo.png")
return logo return logo

View File

@ -1,12 +1,11 @@
"""authentik identification stage app config""" """authentik identification stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageIdentificationConfig(ManagedAppConfig): class AuthentikStageIdentificationConfig(AppConfig):
"""authentik identification stage config""" """authentik identification stage config"""
name = "authentik.stages.identification" name = "authentik.stages.identification"
label = "authentik_stages_identification" label = "authentik_stages_identification"
verbose_name = "authentik Stages.Identification" verbose_name = "authentik Stages.Identification"
default = True

View File

@ -1,12 +1,11 @@
"""authentik invitation stage app config""" """authentik invitation stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageInvitationConfig(ManagedAppConfig): class AuthentikStageInvitationConfig(AppConfig):
"""authentik invitation stage config""" """authentik invitation stage config"""
name = "authentik.stages.invitation" name = "authentik.stages.invitation"
label = "authentik_stages_invitation" label = "authentik_stages_invitation"
verbose_name = "authentik Stages.Invitation" verbose_name = "authentik Stages.Invitation"
default = True

View File

@ -1,12 +1,11 @@
"""authentik core app config""" """authentik core app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStagePasswordConfig(ManagedAppConfig): class AuthentikStagePasswordConfig(AppConfig):
"""authentik password stage config""" """authentik password stage config"""
name = "authentik.stages.password" name = "authentik.stages.password"
label = "authentik_stages_password" label = "authentik_stages_password"
verbose_name = "authentik Stages.Password" verbose_name = "authentik Stages.Password"
default = True

View File

@ -1,12 +1,11 @@
"""authentik prompt stage app config""" """authentik prompt stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStagePromptConfig(ManagedAppConfig): class AuthentikStagePromptConfig(AppConfig):
"""authentik prompt stage config""" """authentik prompt stage config"""
name = "authentik.stages.prompt" name = "authentik.stages.prompt"
label = "authentik_stages_prompt" label = "authentik_stages_prompt"
verbose_name = "authentik Stages.Prompt" verbose_name = "authentik Stages.Prompt"
default = True

View File

@ -1,12 +1,11 @@
"""authentik redirect app""" """authentik redirect app"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageRedirectConfig(ManagedAppConfig): class AuthentikStageRedirectConfig(AppConfig):
"""authentik redirect app""" """authentik redirect app"""
name = "authentik.stages.redirect" name = "authentik.stages.redirect"
label = "authentik_stages_redirect" label = "authentik_stages_redirect"
verbose_name = "authentik Stages.Redirect" verbose_name = "authentik Stages.Redirect"
default = True

View File

@ -1,12 +1,11 @@
"""authentik delete stage app config""" """authentik delete stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageUserDeleteConfig(ManagedAppConfig): class AuthentikStageUserDeleteConfig(AppConfig):
"""authentik delete stage config""" """authentik delete stage config"""
name = "authentik.stages.user_delete" name = "authentik.stages.user_delete"
label = "authentik_stages_user_delete" label = "authentik_stages_user_delete"
verbose_name = "authentik Stages.User Delete" verbose_name = "authentik Stages.User Delete"
default = True

View File

@ -1,12 +1,11 @@
"""authentik login stage app config""" """authentik login stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageUserLoginConfig(ManagedAppConfig): class AuthentikStageUserLoginConfig(AppConfig):
"""authentik login stage config""" """authentik login stage config"""
name = "authentik.stages.user_login" name = "authentik.stages.user_login"
label = "authentik_stages_user_login" label = "authentik_stages_user_login"
verbose_name = "authentik Stages.User Login" verbose_name = "authentik Stages.User Login"
default = True

View File

@ -1,12 +1,11 @@
"""authentik logout stage app config""" """authentik logout stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageUserLogoutConfig(ManagedAppConfig): class AuthentikStageUserLogoutConfig(AppConfig):
"""authentik logout stage config""" """authentik logout stage config"""
name = "authentik.stages.user_logout" name = "authentik.stages.user_logout"
label = "authentik_stages_user_logout" label = "authentik_stages_user_logout"
verbose_name = "authentik Stages.User Logout" verbose_name = "authentik Stages.User Logout"
default = True

View File

@ -1,12 +1,11 @@
"""authentik write stage app config""" """authentik write stage app config"""
from authentik.blueprints.apps import ManagedAppConfig from django.apps import AppConfig
class AuthentikStageUserWriteConfig(ManagedAppConfig): class AuthentikStageUserWriteConfig(AppConfig):
"""authentik write stage config""" """authentik write stage config"""
name = "authentik.stages.user_write" name = "authentik.stages.user_write"
label = "authentik_stages_user_write" label = "authentik_stages_user_write"
verbose_name = "authentik Stages.User Write" verbose_name = "authentik Stages.User Write"
default = True

View File

@ -2,7 +2,7 @@
"$schema": "http://json-schema.org/draft-07/schema", "$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://goauthentik.io/blueprints/schema.json", "$id": "https://goauthentik.io/blueprints/schema.json",
"type": "object", "type": "object",
"title": "authentik 2025.6.1 Blueprint schema", "title": "authentik 2025.6.0 Blueprint schema",
"required": [ "required": [
"version", "version",
"entries" "entries"

View File

@ -2,13 +2,17 @@ package main
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"goauthentik.io/internal/common" "goauthentik.io/internal/common"
"goauthentik.io/internal/config"
"goauthentik.io/internal/constants" "goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/ak/entrypoint" "goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/ak/healthcheck" "goauthentik.io/internal/outpost/ak/healthcheck"
"goauthentik.io/internal/outpost/ldap" "goauthentik.io/internal/outpost/ldap"
) )
@ -21,15 +25,65 @@ Required environment variables:
- AUTHENTIK_INSECURE: Skip SSL Certificate verification` - AUTHENTIK_INSECURE: Skip SSL Certificate verification`
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Long: helpMessage, Long: helpMessage,
Version: constants.FullVersion(), Version: constants.FullVersion(),
PersistentPreRun: common.PreRun, PersistentPreRun: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error { log.SetLevel(log.DebugLevel)
err := entrypoint.OutpostMain("authentik.outpost.ldap", ldap.NewServer) log.SetFormatter(&log.JSONFormatter{
if err != nil { FieldMap: log.FieldMap{
log.FieldKeyMsg: "event",
log.FieldKeyTime: "timestamp",
},
DisableHTMLEscape: true,
})
},
Run: func(cmd *cobra.Command, args []string) {
debug.EnableDebugServer()
akURL := config.Get().AuthentikHost
if akURL == "" {
fmt.Println("env AUTHENTIK_HOST not set!")
fmt.Println(helpMessage) fmt.Println(helpMessage)
os.Exit(1)
}
akToken := config.Get().AuthentikToken
if akToken == "" {
fmt.Println("env AUTHENTIK_TOKEN not set!")
fmt.Println(helpMessage)
os.Exit(1)
}
akURLActual, err := url.Parse(akURL)
if err != nil {
fmt.Println(err)
fmt.Println(helpMessage)
os.Exit(1)
}
ex := common.Init()
defer common.Defer()
go func() {
for {
<-ex
os.Exit(0)
}
}()
ac := ak.NewAPIController(*akURLActual, akToken)
if ac == nil {
os.Exit(1)
}
defer ac.Shutdown()
ac.Server = ldap.NewServer(ac)
err = ac.Start()
if err != nil {
log.WithError(err).Panic("Failed to run server")
}
for {
<-ex
} }
return err
}, },
} }

View File

@ -2,13 +2,17 @@ package main
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"goauthentik.io/internal/common" "goauthentik.io/internal/common"
"goauthentik.io/internal/config"
"goauthentik.io/internal/constants" "goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/ak/entrypoint" "goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/ak/healthcheck" "goauthentik.io/internal/outpost/ak/healthcheck"
"goauthentik.io/internal/outpost/proxyv2" "goauthentik.io/internal/outpost/proxyv2"
) )
@ -24,15 +28,65 @@ Optionally, you can set these:
- AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST` - AUTHENTIK_HOST_BROWSER: URL to use in the browser, when it differs from AUTHENTIK_HOST`
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Long: helpMessage, Long: helpMessage,
Version: constants.FullVersion(), Version: constants.FullVersion(),
PersistentPreRun: common.PreRun, PersistentPreRun: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error { log.SetLevel(log.DebugLevel)
err := entrypoint.OutpostMain("authentik.outpost.proxy", proxyv2.NewProxyServer) log.SetFormatter(&log.JSONFormatter{
if err != nil { FieldMap: log.FieldMap{
log.FieldKeyMsg: "event",
log.FieldKeyTime: "timestamp",
},
DisableHTMLEscape: true,
})
},
Run: func(cmd *cobra.Command, args []string) {
debug.EnableDebugServer()
akURL := config.Get().AuthentikHost
if akURL == "" {
fmt.Println("env AUTHENTIK_HOST not set!")
fmt.Println(helpMessage) fmt.Println(helpMessage)
os.Exit(1)
}
akToken := config.Get().AuthentikToken
if akToken == "" {
fmt.Println("env AUTHENTIK_TOKEN not set!")
fmt.Println(helpMessage)
os.Exit(1)
}
akURLActual, err := url.Parse(akURL)
if err != nil {
fmt.Println(err)
fmt.Println(helpMessage)
os.Exit(1)
}
ex := common.Init()
defer common.Defer()
go func() {
for {
<-ex
os.Exit(0)
}
}()
ac := ak.NewAPIController(*akURLActual, akToken)
if ac == nil {
os.Exit(1)
}
defer ac.Shutdown()
ac.Server = proxyv2.NewProxyServer(ac)
err = ac.Start()
if err != nil {
log.WithError(err).Panic("Failed to run server")
}
for {
<-ex
} }
return err
}, },
} }

View File

@ -2,13 +2,16 @@ package main
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"goauthentik.io/internal/common" "goauthentik.io/internal/common"
"goauthentik.io/internal/constants" "goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/ak/entrypoint" "goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/ak/healthcheck" "goauthentik.io/internal/outpost/ak/healthcheck"
"goauthentik.io/internal/outpost/rac" "goauthentik.io/internal/outpost/rac"
) )
@ -21,15 +24,65 @@ Required environment variables:
- AUTHENTIK_INSECURE: Skip SSL Certificate verification` - AUTHENTIK_INSECURE: Skip SSL Certificate verification`
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Long: helpMessage, Long: helpMessage,
Version: constants.FullVersion(), Version: constants.FullVersion(),
PersistentPreRun: common.PreRun, PersistentPreRun: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error { log.SetLevel(log.DebugLevel)
err := entrypoint.OutpostMain("authentik.outpost.rac", rac.NewServer) log.SetFormatter(&log.JSONFormatter{
if err != nil { FieldMap: log.FieldMap{
log.FieldKeyMsg: "event",
log.FieldKeyTime: "timestamp",
},
DisableHTMLEscape: true,
})
},
Run: func(cmd *cobra.Command, args []string) {
debug.EnableDebugServer()
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
if !found {
fmt.Println("env AUTHENTIK_HOST not set!")
fmt.Println(helpMessage) fmt.Println(helpMessage)
os.Exit(1)
}
akToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
if !found {
fmt.Println("env AUTHENTIK_TOKEN not set!")
fmt.Println(helpMessage)
os.Exit(1)
}
akURLActual, err := url.Parse(akURL)
if err != nil {
fmt.Println(err)
fmt.Println(helpMessage)
os.Exit(1)
}
ex := common.Init()
defer common.Defer()
go func() {
for {
<-ex
os.Exit(0)
}
}()
ac := ak.NewAPIController(*akURLActual, akToken)
if ac == nil {
os.Exit(1)
}
defer ac.Shutdown()
ac.Server = rac.NewServer(ac)
err = ac.Start()
if err != nil {
log.WithError(err).Panic("Failed to run server")
}
for {
<-ex
} }
return err
}, },
} }

View File

@ -2,13 +2,16 @@ package main
import ( import (
"fmt" "fmt"
"net/url"
"os" "os"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"goauthentik.io/internal/common" "goauthentik.io/internal/common"
"goauthentik.io/internal/constants" "goauthentik.io/internal/constants"
"goauthentik.io/internal/outpost/ak/entrypoint" "goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak"
"goauthentik.io/internal/outpost/ak/healthcheck" "goauthentik.io/internal/outpost/ak/healthcheck"
"goauthentik.io/internal/outpost/radius" "goauthentik.io/internal/outpost/radius"
) )
@ -21,15 +24,65 @@ Required environment variables:
- AUTHENTIK_INSECURE: Skip SSL Certificate verification` - AUTHENTIK_INSECURE: Skip SSL Certificate verification`
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Long: helpMessage, Long: helpMessage,
Version: constants.FullVersion(), Version: constants.FullVersion(),
PersistentPreRun: common.PreRun, PersistentPreRun: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error { log.SetLevel(log.DebugLevel)
err := entrypoint.OutpostMain("authentik.outpost.radius", radius.NewServer) log.SetFormatter(&log.JSONFormatter{
if err != nil { FieldMap: log.FieldMap{
log.FieldKeyMsg: "event",
log.FieldKeyTime: "timestamp",
},
DisableHTMLEscape: true,
})
},
Run: func(cmd *cobra.Command, args []string) {
debug.EnableDebugServer()
akURL, found := os.LookupEnv("AUTHENTIK_HOST")
if !found {
fmt.Println("env AUTHENTIK_HOST not set!")
fmt.Println(helpMessage) fmt.Println(helpMessage)
os.Exit(1)
}
akToken, found := os.LookupEnv("AUTHENTIK_TOKEN")
if !found {
fmt.Println("env AUTHENTIK_TOKEN not set!")
fmt.Println(helpMessage)
os.Exit(1)
}
akURLActual, err := url.Parse(akURL)
if err != nil {
fmt.Println(err)
fmt.Println(helpMessage)
os.Exit(1)
}
ex := common.Init()
defer common.Defer()
go func() {
for {
<-ex
os.Exit(0)
}
}()
ac := ak.NewAPIController(*akURLActual, akToken)
if ac == nil {
os.Exit(1)
}
defer ac.Shutdown()
ac.Server = radius.NewServer(ac)
err = ac.Start()
if err != nil {
log.WithError(err).Panic("Failed to run server")
}
for {
<-ex
} }
return err
}, },
} }

View File

@ -22,12 +22,21 @@ import (
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
Use: "authentik", Use: "authentik",
Short: "Start authentik instance", Short: "Start authentik instance",
Version: constants.FullVersion(), Version: constants.FullVersion(),
PersistentPreRun: common.PreRun, PersistentPreRun: func(cmd *cobra.Command, args []string) {
log.SetLevel(log.DebugLevel)
log.SetFormatter(&log.JSONFormatter{
FieldMap: log.FieldMap{
log.FieldKeyMsg: "event",
log.FieldKeyTime: "timestamp",
},
DisableHTMLEscape: true,
})
},
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
debug.EnableDebugServer("authentik.core") debug.EnableDebugServer()
l := log.WithField("logger", "authentik.root") l := log.WithField("logger", "authentik.root")
if config.Get().ErrorReporting.Enabled { if config.Get().ErrorReporting.Enabled {
@ -90,7 +99,7 @@ func attemptProxyStart(ws *web.WebServer, u *url.URL) {
}) })
srv := proxyv2.NewProxyServer(ac) srv := proxyv2.NewProxyServer(ac)
ws.ProxyServer = srv.(*proxyv2.ProxyServer) ws.ProxyServer = srv
ac.Server = srv ac.Server = srv
l.Debug("attempting to start outpost") l.Debug("attempting to start outpost")
err := ac.StartBackgroundTasks() err := ac.StartBackgroundTasks()

View File

@ -31,7 +31,7 @@ services:
volumes: volumes:
- redis:/data - redis:/data
server: server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.1} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.0}
restart: unless-stopped restart: unless-stopped
command: server command: server
environment: environment:
@ -55,7 +55,7 @@ services:
redis: redis:
condition: service_healthy condition: service_healthy
worker: worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.1} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.6.0}
restart: unless-stopped restart: unless-stopped
command: worker command: worker
environment: environment:

10
go.mod
View File

@ -4,7 +4,6 @@ go 1.24.0
require ( require (
beryju.io/ldap v0.1.0 beryju.io/ldap v0.1.0
github.com/avast/retry-go/v4 v4.6.1
github.com/coreos/go-oidc/v3 v3.14.1 github.com/coreos/go-oidc/v3 v3.14.1
github.com/getsentry/sentry-go v0.33.0 github.com/getsentry/sentry-go v0.33.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
@ -17,22 +16,21 @@ require (
github.com/gorilla/securecookie v1.1.2 github.com/gorilla/securecookie v1.1.2
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/grafana/pyroscope-go v1.2.2
github.com/jellydator/ttlcache/v3 v3.3.0 github.com/jellydator/ttlcache/v3 v3.3.0
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484 github.com/nmcclain/asn1-ber v0.0.0-20170104154839-2661553a0484
github.com/pires/go-proxyproto v0.8.1 github.com/pires/go-proxyproto v0.8.1
github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_golang v1.22.0
github.com/redis/go-redis/v9 v9.10.0 github.com/redis/go-redis/v9 v9.9.0
github.com/sethvargo/go-envconfig v1.3.0 github.com/sethvargo/go-envconfig v1.3.0
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2 github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025061.2 goauthentik.io/api/v3 v3.2025041.4
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.30.0 golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.15.0 golang.org/x/sync v0.14.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
layeh.com/radius v0.0.0-20210819152912-ad72663a72ab layeh.com/radius v0.0.0-20210819152912-ad72663a72ab
) )
@ -60,10 +58,8 @@ require (
github.com/go-openapi/strfmt v0.23.0 // indirect github.com/go-openapi/strfmt v0.23.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/validate v0.24.0 // indirect github.com/go-openapi/validate v0.24.0 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid v1.3.1 // indirect

20
go.sum
View File

@ -41,8 +41,6 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
@ -180,10 +178,6 @@ github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2e
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/pyroscope-go v1.2.2 h1:uvKCyZMD724RkaCEMrSTC38Yn7AnFe8S2wiAIYdDPCE=
github.com/grafana/pyroscope-go v1.2.2/go.mod h1:zzT9QXQAp2Iz2ZdS216UiV8y9uXJYQiGE1q8v1FyhqU=
github.com/grafana/pyroscope-go/godeltaprof v0.1.8 h1:iwOtYXeeVSAeYefJNaxDytgjKtUuKQbJqgAIjlnicKg=
github.com/grafana/pyroscope-go/godeltaprof v0.1.8/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@ -251,8 +245,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= github.com/redis/go-redis/v9 v9.9.0 h1:URbPQ4xVQSQhZ27WMQVmZSo3uT3pL+4IdHVcYq2nVfM=
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/redis/go-redis/v9 v9.9.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
@ -268,8 +262,6 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@ -298,8 +290,8 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025061.2 h1:bKmrl82Gz6J8lz3f+QIH9g+MEkl3MvkMXF34GktesA0= goauthentik.io/api/v3 v3.2025041.4 h1:cGqzWYnUHrWDoaXWDpIL/kWnX9sFrIhkYDye0P0OEAo=
goauthentik.io/api/v3 v3.2025061.2/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw= goauthentik.io/api/v3 v3.2025041.4/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
@ -384,8 +376,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@ -1,17 +0,0 @@
package common
import (
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
func PreRun(cmd *cobra.Command, args []string) {
log.SetLevel(log.DebugLevel)
log.SetFormatter(&log.JSONFormatter{
FieldMap: log.FieldMap{
log.FieldKeyMsg: "event",
log.FieldKeyTime: "timestamp",
},
DisableHTMLEscape: true,
})
}

View File

@ -33,4 +33,4 @@ func UserAgent() string {
return fmt.Sprintf("authentik@%s", FullVersion()) return fmt.Sprintf("authentik@%s", FullVersion())
} }
const VERSION = "2025.6.1" const VERSION = "2025.6.0"

View File

@ -5,24 +5,19 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/pprof" "net/http/pprof"
"os"
"runtime"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/grafana/pyroscope-go"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"goauthentik.io/internal/config" "goauthentik.io/internal/config"
"goauthentik.io/internal/utils/web" "goauthentik.io/internal/utils/web"
) )
var l = log.WithField("logger", "authentik.debugger.go") func EnableDebugServer() {
l := log.WithField("logger", "authentik.go_debugger")
func EnableDebugServer(appName string) {
if !config.Get().Debug { if !config.Get().Debug {
return return
} }
h := mux.NewRouter() h := mux.NewRouter()
enablePyroscope(appName)
h.HandleFunc("/debug/pprof/", pprof.Index) h.HandleFunc("/debug/pprof/", pprof.Index)
h.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) h.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
h.HandleFunc("/debug/pprof/profile", pprof.Profile) h.HandleFunc("/debug/pprof/profile", pprof.Profile)
@ -59,38 +54,3 @@ func EnableDebugServer(appName string) {
} }
}() }()
} }
func enablePyroscope(appName string) {
p, pok := os.LookupEnv("AUTHENTIK_PYROSCOPE_HOST")
if !pok {
return
}
l.Debug("Enabling pyroscope")
runtime.SetMutexProfileFraction(5)
runtime.SetBlockProfileRate(5)
hostname, err := os.Hostname()
if err != nil {
panic(err)
}
_, err = pyroscope.Start(pyroscope.Config{
ApplicationName: appName,
ServerAddress: p,
Logger: pyroscope.StandardLogger,
Tags: map[string]string{"hostname": hostname},
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
pyroscope.ProfileGoroutines,
pyroscope.ProfileMutexCount,
pyroscope.ProfileMutexDuration,
pyroscope.ProfileBlockCount,
pyroscope.ProfileBlockDuration,
},
})
if err != nil {
panic(err)
}
}

View File

@ -13,7 +13,6 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/avast/retry-go/v4"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@ -26,6 +25,8 @@ import (
"goauthentik.io/internal/utils/web" "goauthentik.io/internal/utils/web"
) )
type WSHandler func(ctx context.Context, args map[string]interface{})
const ConfigLogLevel = "log_level" const ConfigLogLevel = "log_level"
// APIController main controller which connects to the authentik api via http and ws // APIController main controller which connects to the authentik api via http and ws
@ -42,11 +43,12 @@ type APIController struct {
reloadOffset time.Duration reloadOffset time.Duration
eventConn *websocket.Conn wsConn *websocket.Conn
lastWsReconnect time.Time lastWsReconnect time.Time
wsIsReconnecting bool wsIsReconnecting bool
eventHandlers []EventHandler wsBackoffMultiplier int
refreshHandlers []func() wsHandlers []WSHandler
refreshHandlers []func()
instanceUUID uuid.UUID instanceUUID uuid.UUID
} }
@ -81,19 +83,20 @@ func NewAPIController(akURL url.URL, token string) *APIController {
// Because we don't know the outpost UUID, we simply do a list and pick the first // Because we don't know the outpost UUID, we simply do a list and pick the first
// The service account this token belongs to should only have access to a single outpost // The service account this token belongs to should only have access to a single outpost
outposts, _ := retry.DoWithData[*api.PaginatedOutpostList]( var outposts *api.PaginatedOutpostList
func() (*api.PaginatedOutpostList, error) { var err error
outposts, _, err := apiClient.OutpostsApi.OutpostsInstancesList(context.Background()).Execute() for {
return outposts, err outposts, _, err = apiClient.OutpostsApi.OutpostsInstancesList(context.Background()).Execute()
},
retry.Attempts(0), if err == nil {
retry.Delay(time.Second*3), break
retry.OnRetry(func(attempt uint, err error) { }
log.WithError(err).Error("Failed to fetch outpost configuration, retrying in 3 seconds")
}), log.WithError(err).Error("Failed to fetch outpost configuration, retrying in 3 seconds")
) time.Sleep(time.Second * 3)
}
if len(outposts.Results) < 1 { if len(outposts.Results) < 1 {
log.Panic("No outposts found with given token, ensure the given token corresponds to an authenitk Outpost") panic("No outposts found with given token, ensure the given token corresponds to an authenitk Outpost")
} }
outpost := outposts.Results[0] outpost := outposts.Results[0]
@ -116,25 +119,22 @@ func NewAPIController(akURL url.URL, token string) *APIController {
token: token, token: token,
logger: log, logger: log,
reloadOffset: time.Duration(rand.Intn(10)) * time.Second, reloadOffset: time.Duration(rand.Intn(10)) * time.Second,
instanceUUID: uuid.New(), instanceUUID: uuid.New(),
Outpost: outpost, Outpost: outpost,
eventHandlers: []EventHandler{}, wsHandlers: []WSHandler{},
refreshHandlers: make([]func(), 0), wsBackoffMultiplier: 1,
refreshHandlers: make([]func(), 0),
} }
ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset") ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset")
err = ac.initEvent(akURL, outpost.Pk) err = ac.initWS(akURL, outpost.Pk)
if err != nil { if err != nil {
go ac.recentEvents() go ac.reconnectWS()
} }
ac.configureRefreshSignal() ac.configureRefreshSignal()
return ac return ac
} }
func (a *APIController) Log() *log.Entry {
return a.logger
}
// Start Starts all handlers, non-blocking // Start Starts all handlers, non-blocking
func (a *APIController) Start() error { func (a *APIController) Start() error {
err := a.Server.Refresh() err := a.Server.Refresh()
@ -196,7 +196,7 @@ func (a *APIController) OnRefresh() error {
return err return err
} }
func (a *APIController) getEventPingArgs() map[string]interface{} { func (a *APIController) getWebsocketPingArgs() map[string]interface{} {
args := map[string]interface{}{ args := map[string]interface{}{
"version": constants.VERSION, "version": constants.VERSION,
"buildHash": constants.BUILD(""), "buildHash": constants.BUILD(""),
@ -222,12 +222,12 @@ func (a *APIController) StartBackgroundTasks() error {
"build": constants.BUILD(""), "build": constants.BUILD(""),
}).Set(1) }).Set(1)
go func() { go func() {
a.logger.Debug("Starting Event Handler...") a.logger.Debug("Starting WS Handler...")
a.startEventHandler() a.startWSHandler()
}() }()
go func() { go func() {
a.logger.Debug("Starting Event health notifier...") a.logger.Debug("Starting WS Health notifier...")
a.startEventHealth() a.startWSHealth()
}() }()
go func() { go func() {
a.logger.Debug("Starting Interval updater...") a.logger.Debug("Starting Interval updater...")

View File

@ -1,37 +0,0 @@
package ak
import (
"context"
"github.com/mitchellh/mapstructure"
)
type EventKind int
const (
// Code used to acknowledge a previous message
EventKindAck EventKind = 0
// Code used to send a healthcheck keepalive
EventKindHello EventKind = 1
// Code received to trigger a config update
EventKindTriggerUpdate EventKind = 2
// Code received to trigger some provider specific function
EventKindProviderSpecific EventKind = 3
// Code received to identify the end of a session
EventKindSessionEnd EventKind = 4
)
type EventHandler func(ctx context.Context, msg Event) error
type Event struct {
Instruction EventKind `json:"instruction"`
Args interface{} `json:"args"`
}
func (wm Event) ArgsAs(out interface{}) error {
return mapstructure.Decode(wm.Args, out)
}
type EventArgsSessionEnd struct {
SessionID string `mapstructure:"session_id"`
}

View File

@ -11,7 +11,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/avast/retry-go/v4"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"goauthentik.io/internal/config" "goauthentik.io/internal/config"
@ -31,7 +30,7 @@ func (ac *APIController) getWebsocketURL(akURL url.URL, outpostUUID string, quer
return wsUrl return wsUrl
} }
func (ac *APIController) initEvent(akURL url.URL, outpostUUID string) error { func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
query := akURL.Query() query := akURL.Query()
query.Set("instance_uuid", ac.instanceUUID.String()) query.Set("instance_uuid", ac.instanceUUID.String())
@ -58,19 +57,19 @@ func (ac *APIController) initEvent(akURL url.URL, outpostUUID string) error {
return err return err
} }
ac.eventConn = ws ac.wsConn = ws
// Send hello message with our version // Send hello message with our version
msg := Event{ msg := websocketMessage{
Instruction: EventKindHello, Instruction: WebsocketInstructionHello,
Args: ac.getEventPingArgs(), Args: ac.getWebsocketPingArgs(),
} }
err = ws.WriteJSON(msg) err = ws.WriteJSON(msg)
if err != nil { if err != nil {
ac.logger.WithField("logger", "authentik.outpost.events").WithError(err).Warning("Failed to hello to authentik") ac.logger.WithField("logger", "authentik.outpost.ak-ws").WithError(err).Warning("Failed to hello to authentik")
return err return err
} }
ac.lastWsReconnect = time.Now() ac.lastWsReconnect = time.Now()
ac.logger.WithField("logger", "authentik.outpost.events").WithField("outpost", outpostUUID).Info("Successfully connected websocket") ac.logger.WithField("logger", "authentik.outpost.ak-ws").WithField("outpost", outpostUUID).Info("Successfully connected websocket")
return nil return nil
} }
@ -78,19 +77,19 @@ func (ac *APIController) initEvent(akURL url.URL, outpostUUID string) error {
func (ac *APIController) Shutdown() { func (ac *APIController) Shutdown() {
// Cleanly close the connection by sending a close message and then // Cleanly close the connection by sending a close message and then
// waiting (with timeout) for the server to close the connection. // waiting (with timeout) for the server to close the connection.
err := ac.eventConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) err := ac.wsConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
if err != nil { if err != nil {
ac.logger.WithError(err).Warning("failed to write close message") ac.logger.WithError(err).Warning("failed to write close message")
return return
} }
err = ac.eventConn.Close() err = ac.wsConn.Close()
if err != nil { if err != nil {
ac.logger.WithError(err).Warning("failed to close websocket") ac.logger.WithError(err).Warning("failed to close websocket")
} }
ac.logger.Info("finished shutdown") ac.logger.Info("finished shutdown")
} }
func (ac *APIController) recentEvents() { func (ac *APIController) reconnectWS() {
if ac.wsIsReconnecting { if ac.wsIsReconnecting {
return return
} }
@ -101,47 +100,46 @@ func (ac *APIController) recentEvents() {
Path: strings.ReplaceAll(ac.Client.GetConfig().Servers[0].URL, "api/v3", ""), Path: strings.ReplaceAll(ac.Client.GetConfig().Servers[0].URL, "api/v3", ""),
} }
attempt := 1 attempt := 1
_ = retry.Do( for {
func() error { q := u.Query()
q := u.Query() q.Set("attempt", strconv.Itoa(attempt))
q.Set("attempt", strconv.Itoa(attempt)) u.RawQuery = q.Encode()
u.RawQuery = q.Encode() err := ac.initWS(u, ac.Outpost.Pk)
err := ac.initEvent(u, ac.Outpost.Pk) attempt += 1
attempt += 1 if err != nil {
if err != nil { ac.logger.Infof("waiting %d seconds to reconnect", ac.wsBackoffMultiplier)
return err time.Sleep(time.Duration(ac.wsBackoffMultiplier) * time.Second)
ac.wsBackoffMultiplier = ac.wsBackoffMultiplier * 2
// Limit to 300 seconds (5m)
if ac.wsBackoffMultiplier >= 300 {
ac.wsBackoffMultiplier = 300
} }
} else {
ac.wsIsReconnecting = false ac.wsIsReconnecting = false
return nil ac.wsBackoffMultiplier = 1
}, return
retry.Delay(1*time.Second), }
retry.MaxDelay(5*time.Minute), }
retry.DelayType(retry.BackOffDelay),
retry.Attempts(0),
retry.OnRetry(func(attempt uint, err error) {
ac.logger.Infof("waiting %d seconds to reconnect", attempt)
}),
)
} }
func (ac *APIController) startEventHandler() { func (ac *APIController) startWSHandler() {
logger := ac.logger.WithField("loop", "event-handler") logger := ac.logger.WithField("loop", "ws-handler")
for { for {
var wsMsg Event var wsMsg websocketMessage
if ac.eventConn == nil { if ac.wsConn == nil {
go ac.recentEvents() go ac.reconnectWS()
time.Sleep(time.Second * 5) time.Sleep(time.Second * 5)
continue continue
} }
err := ac.eventConn.ReadJSON(&wsMsg) err := ac.wsConn.ReadJSON(&wsMsg)
if err != nil { if err != nil {
ConnectionStatus.With(prometheus.Labels{ ConnectionStatus.With(prometheus.Labels{
"outpost_name": ac.Outpost.Name, "outpost_name": ac.Outpost.Name,
"outpost_type": ac.Server.Type(), "outpost_type": ac.Server.Type(),
"uuid": ac.instanceUUID.String(), "uuid": ac.instanceUUID.String(),
}).Set(0) }).Set(0)
logger.WithError(err).Warning("event read error") logger.WithError(err).Warning("ws read error")
go ac.recentEvents() go ac.reconnectWS()
time.Sleep(time.Second * 5) time.Sleep(time.Second * 5)
continue continue
} }
@ -151,8 +149,7 @@ func (ac *APIController) startEventHandler() {
"uuid": ac.instanceUUID.String(), "uuid": ac.instanceUUID.String(),
}).Set(1) }).Set(1)
switch wsMsg.Instruction { switch wsMsg.Instruction {
case EventKindAck: case WebsocketInstructionTriggerUpdate:
case EventKindTriggerUpdate:
time.Sleep(ac.reloadOffset) time.Sleep(ac.reloadOffset)
logger.Debug("Got update trigger...") logger.Debug("Got update trigger...")
err := ac.OnRefresh() err := ac.OnRefresh()
@ -167,33 +164,30 @@ func (ac *APIController) startEventHandler() {
"build": constants.BUILD(""), "build": constants.BUILD(""),
}).SetToCurrentTime() }).SetToCurrentTime()
} }
default: case WebsocketInstructionProviderSpecific:
for _, h := range ac.eventHandlers { for _, h := range ac.wsHandlers {
err := h(context.Background(), wsMsg) h(context.Background(), wsMsg.Args)
if err != nil {
ac.logger.WithError(err).Warning("failed to run event handler")
}
} }
} }
} }
} }
func (ac *APIController) startEventHealth() { func (ac *APIController) startWSHealth() {
ticker := time.NewTicker(time.Second * 10) ticker := time.NewTicker(time.Second * 10)
for ; true; <-ticker.C { for ; true; <-ticker.C {
if ac.eventConn == nil { if ac.wsConn == nil {
go ac.recentEvents() go ac.reconnectWS()
time.Sleep(time.Second * 5) time.Sleep(time.Second * 5)
continue continue
} }
err := ac.SendEventHello(map[string]interface{}{}) err := ac.SendWSHello(map[string]interface{}{})
if err != nil { if err != nil {
ac.logger.WithField("loop", "event-health").WithError(err).Warning("event write error") ac.logger.WithField("loop", "ws-health").WithError(err).Warning("ws write error")
go ac.recentEvents() go ac.reconnectWS()
time.Sleep(time.Second * 5) time.Sleep(time.Second * 5)
continue continue
} else { } else {
ac.logger.WithField("loop", "event-health").Trace("hello'd") ac.logger.WithField("loop", "ws-health").Trace("hello'd")
ConnectionStatus.With(prometheus.Labels{ ConnectionStatus.With(prometheus.Labels{
"outpost_name": ac.Outpost.Name, "outpost_name": ac.Outpost.Name,
"outpost_type": ac.Server.Type(), "outpost_type": ac.Server.Type(),
@ -236,19 +230,19 @@ func (ac *APIController) startIntervalUpdater() {
} }
} }
func (a *APIController) AddEventHandler(handler EventHandler) { func (a *APIController) AddWSHandler(handler WSHandler) {
a.eventHandlers = append(a.eventHandlers, handler) a.wsHandlers = append(a.wsHandlers, handler)
} }
func (a *APIController) SendEventHello(args map[string]interface{}) error { func (a *APIController) SendWSHello(args map[string]interface{}) error {
allArgs := a.getEventPingArgs() allArgs := a.getWebsocketPingArgs()
for key, value := range args { for key, value := range args {
allArgs[key] = value allArgs[key] = value
} }
aliveMsg := Event{ aliveMsg := websocketMessage{
Instruction: EventKindHello, Instruction: WebsocketInstructionHello,
Args: allArgs, Args: allArgs,
} }
err := a.eventConn.WriteJSON(aliveMsg) err := a.wsConn.WriteJSON(aliveMsg)
return err return err
} }

View File

@ -0,0 +1,19 @@
package ak
type websocketInstruction int
const (
// WebsocketInstructionAck Code used to acknowledge a previous message
WebsocketInstructionAck websocketInstruction = 0
// WebsocketInstructionHello Code used to send a healthcheck keepalive
WebsocketInstructionHello websocketInstruction = 1
// WebsocketInstructionTriggerUpdate Code received to trigger a config update
WebsocketInstructionTriggerUpdate websocketInstruction = 2
// WebsocketInstructionProviderSpecific Code received to trigger some provider specific function
WebsocketInstructionProviderSpecific websocketInstruction = 3
)
type websocketMessage struct {
Instruction websocketInstruction `json:"instruction"`
Args map[string]interface{} `json:"args"`
}

View File

@ -15,7 +15,7 @@ func URLMustParse(u string) *url.URL {
return ur return ur
} }
func TestEventWebsocketURL(t *testing.T) { func TestWebsocketURL(t *testing.T) {
u := URLMustParse("http://localhost:9000?foo=bar") u := URLMustParse("http://localhost:9000?foo=bar")
uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77" uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77"
ac := &APIController{} ac := &APIController{}
@ -23,7 +23,7 @@ func TestEventWebsocketURL(t *testing.T) {
assert.Equal(t, "ws://localhost:9000/ws/outpost/23470845-7263-4fe3-bd79-ec1d7bf77d77/?foo=bar", nu.String()) assert.Equal(t, "ws://localhost:9000/ws/outpost/23470845-7263-4fe3-bd79-ec1d7bf77d77/?foo=bar", nu.String())
} }
func TestEventWebsocketURL_Query(t *testing.T) { func TestWebsocketURL_Query(t *testing.T) {
u := URLMustParse("http://localhost:9000?foo=bar") u := URLMustParse("http://localhost:9000?foo=bar")
uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77" uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77"
ac := &APIController{} ac := &APIController{}
@ -33,7 +33,7 @@ func TestEventWebsocketURL_Query(t *testing.T) {
assert.Equal(t, "ws://localhost:9000/ws/outpost/23470845-7263-4fe3-bd79-ec1d7bf77d77/?bar=baz&foo=bar", nu.String()) assert.Equal(t, "ws://localhost:9000/ws/outpost/23470845-7263-4fe3-bd79-ec1d7bf77d77/?bar=baz&foo=bar", nu.String())
} }
func TestEventWebsocketURL_Subpath(t *testing.T) { func TestWebsocketURL_Subpath(t *testing.T) {
u := URLMustParse("http://localhost:9000/foo/bar/") u := URLMustParse("http://localhost:9000/foo/bar/")
uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77" uuid := "23470845-7263-4fe3-bd79-ec1d7bf77d77"
ac := &APIController{} ac := &APIController{}

View File

@ -1,51 +0,0 @@
package entrypoint
import (
"errors"
"net/url"
"os"
"goauthentik.io/internal/common"
"goauthentik.io/internal/config"
"goauthentik.io/internal/debug"
"goauthentik.io/internal/outpost/ak"
)
func OutpostMain(appName string, server func(ac *ak.APIController) ak.Outpost) error {
debug.EnableDebugServer(appName)
akURL := config.Get().AuthentikHost
if akURL == "" {
return errors.New("environment variable `AUTHENTIK_HOST` not set")
}
akToken := config.Get().AuthentikToken
if akToken == "" {
return errors.New("environment variable `AUTHENTIK_TOKEN` not set")
}
akURLActual, err := url.Parse(akURL)
if err != nil {
return err
}
ex := common.Init()
defer common.Defer()
ac := ak.NewAPIController(*akURLActual, akToken)
if ac == nil {
os.Exit(1)
}
defer ac.Shutdown()
ac.Server = server(ac)
err = ac.Start()
if err != nil {
ac.Log().WithError(err).Panic("Failed to run server")
return err
}
for {
<-ex
return nil
}
}

View File

@ -48,20 +48,20 @@ func doGlobalSetup(outpost api.Outpost, globalConfig *api.Config) {
if globalConfig.ErrorReporting.Enabled { if globalConfig.ErrorReporting.Enabled {
if !initialSetup { if !initialSetup {
l.WithField("env", globalConfig.ErrorReporting.Environment).Debug("Error reporting enabled") l.WithField("env", globalConfig.ErrorReporting.Environment).Debug("Error reporting enabled")
err := sentry.Init(sentry.ClientOptions{ }
Dsn: globalConfig.ErrorReporting.SentryDsn, err := sentry.Init(sentry.ClientOptions{
Environment: globalConfig.ErrorReporting.Environment, Dsn: globalConfig.ErrorReporting.SentryDsn,
EnableTracing: true, Environment: globalConfig.ErrorReporting.Environment,
TracesSampler: sentryutils.SamplerFunc(float64(globalConfig.ErrorReporting.TracesSampleRate)), EnableTracing: true,
Release: fmt.Sprintf("authentik@%s", constants.VERSION), TracesSampler: sentryutils.SamplerFunc(float64(globalConfig.ErrorReporting.TracesSampleRate)),
HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgentOutpost(), http.DefaultTransport), Release: fmt.Sprintf("authentik@%s", constants.VERSION),
IgnoreErrors: []string{ HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgentOutpost(), http.DefaultTransport),
http.ErrAbortHandler.Error(), IgnoreErrors: []string{
}, http.ErrAbortHandler.Error(),
}) },
if err != nil { })
l.WithField("env", globalConfig.ErrorReporting.Environment).WithError(err).Warning("Failed to initialise sentry") if err != nil {
} l.WithField("env", globalConfig.ErrorReporting.Environment).WithError(err).Warning("Failed to initialise sentry")
} }
} }

View File

@ -55,10 +55,11 @@ func MockAK(outpost api.Outpost, globalConfig api.Config) *APIController {
token: token, token: token,
logger: log, logger: log,
reloadOffset: time.Duration(rand.Intn(10)) * time.Second, reloadOffset: time.Duration(rand.Intn(10)) * time.Second,
instanceUUID: uuid.New(), instanceUUID: uuid.New(),
Outpost: outpost, Outpost: outpost,
refreshHandlers: make([]func(), 0), wsBackoffMultiplier: 1,
refreshHandlers: make([]func(), 0),
} }
ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset") ac.logger.WithField("offset", ac.reloadOffset.String()).Debug("HA Reload offset")
return ac return ac

View File

@ -127,7 +127,7 @@ func (fe *FlowExecutor) getAnswer(stage StageComponent) string {
return "" return ""
} }
func (fe *FlowExecutor) SessionCookie() *http.Cookie { func (fe *FlowExecutor) GetSession() *http.Cookie {
return fe.session return fe.session
} }

View File

@ -1,19 +0,0 @@
package flow
import "github.com/golang-jwt/jwt/v5"
type SessionCookieClaims struct {
jwt.Claims
SessionID string `json:"sid"`
Authenticated bool `json:"authenticated"`
}
func (fe *FlowExecutor) Session() *jwt.Token {
sc := fe.SessionCookie()
if sc == nil {
return nil
}
t, _, _ := jwt.NewParser().ParseUnverified(sc.Value, &SessionCookieClaims{})
return t
}

View File

@ -38,14 +38,7 @@ func (ls *LDAPServer) Bind(bindDN string, bindPW string, conn net.Conn) (ldap.LD
username, err := instance.binder.GetUsername(bindDN) username, err := instance.binder.GetUsername(bindDN)
if err == nil { if err == nil {
selectedApp = instance.GetAppSlug() selectedApp = instance.GetAppSlug()
c, err := instance.binder.Bind(username, req) return instance.binder.Bind(username, req)
if c == ldap.LDAPResultSuccess {
f := instance.GetFlags(req.BindDN)
ls.connectionsSync.Lock()
ls.connections[f.SessionID()] = conn
ls.connectionsSync.Unlock()
}
return c, err
} else { } else {
req.Log().WithError(err).Debug("Username not for instance") req.Log().WithError(err).Debug("Username not for instance")
} }

View File

@ -27,9 +27,8 @@ func (db *DirectBinder) Bind(username string, req *bind.Request) (ldap.LDAPResul
passed, err := fe.Execute() passed, err := fe.Execute()
flags := flags.UserFlags{ flags := flags.UserFlags{
Session: fe.SessionCookie(), Session: fe.GetSession(),
SessionJWT: fe.Session(), UserPk: flags.InvalidUserPK,
UserPk: flags.InvalidUserPK,
} }
// only set flags if we don't have flags for this DN yet // only set flags if we don't have flags for this DN yet
// as flags are only checked during the bind, we can remember whether a certain DN // as flags are only checked during the bind, we can remember whether a certain DN

View File

@ -1,20 +0,0 @@
package ldap
import "net"
func (ls *LDAPServer) Close(dn string, conn net.Conn) error {
ls.connectionsSync.Lock()
defer ls.connectionsSync.Unlock()
key := ""
for k, c := range ls.connections {
if c == conn {
key = k
break
}
}
if key == "" {
return nil
}
delete(ls.connections, key)
return nil
}

View File

@ -1,30 +1,16 @@
package flags package flags
import ( import (
"crypto/sha256"
"encoding/hex"
"net/http" "net/http"
"github.com/golang-jwt/jwt/v5"
"goauthentik.io/api/v3" "goauthentik.io/api/v3"
"goauthentik.io/internal/outpost/flow"
) )
const InvalidUserPK = -1 const InvalidUserPK = -1
type UserFlags struct { type UserFlags struct {
UserInfo *api.User UserInfo *api.User
UserPk int32 UserPk int32
CanSearch bool CanSearch bool
Session *http.Cookie Session *http.Cookie
SessionJWT *jwt.Token
}
func (uf UserFlags) SessionID() string {
if uf.SessionJWT == nil {
return ""
}
h := sha256.New()
h.Write([]byte(uf.SessionJWT.Claims.(*flow.SessionCookieClaims).SessionID))
return hex.EncodeToString(h.Sum(nil))
} }

View File

@ -18,26 +18,21 @@ import (
) )
type LDAPServer struct { type LDAPServer struct {
s *ldap.Server s *ldap.Server
log *log.Entry log *log.Entry
ac *ak.APIController ac *ak.APIController
cs *ak.CryptoStore cs *ak.CryptoStore
defaultCert *tls.Certificate defaultCert *tls.Certificate
providers []*ProviderInstance providers []*ProviderInstance
connections map[string]net.Conn
connectionsSync sync.Mutex
} }
func NewServer(ac *ak.APIController) ak.Outpost { func NewServer(ac *ak.APIController) *LDAPServer {
ls := &LDAPServer{ ls := &LDAPServer{
log: log.WithField("logger", "authentik.outpost.ldap"), log: log.WithField("logger", "authentik.outpost.ldap"),
ac: ac, ac: ac,
cs: ak.NewCryptoStore(ac.Client.CryptoApi), cs: ak.NewCryptoStore(ac.Client.CryptoApi),
providers: []*ProviderInstance{}, providers: []*ProviderInstance{},
connections: map[string]net.Conn{},
connectionsSync: sync.Mutex{},
} }
ac.AddEventHandler(ls.handleWSSessionEnd)
s := ldap.NewServer() s := ldap.NewServer()
s.EnforceLDAP = true s.EnforceLDAP = true
@ -55,7 +50,6 @@ func NewServer(ac *ak.APIController) ak.Outpost {
s.BindFunc("", ls) s.BindFunc("", ls)
s.UnbindFunc("", ls) s.UnbindFunc("", ls)
s.SearchFunc("", ls) s.SearchFunc("", ls)
s.CloseFunc("", ls)
return ls return ls
} }
@ -123,23 +117,3 @@ func (ls *LDAPServer) TimerFlowCacheExpiry(ctx context.Context) {
p.binder.TimerFlowCacheExpiry(ctx) p.binder.TimerFlowCacheExpiry(ctx)
} }
} }
func (ls *LDAPServer) handleWSSessionEnd(ctx context.Context, msg ak.Event) error {
if msg.Instruction != ak.EventKindSessionEnd {
return nil
}
mmsg := ak.EventArgsSessionEnd{}
err := msg.ArgsAs(&mmsg)
if err != nil {
return err
}
ls.connectionsSync.Lock()
defer ls.connectionsSync.Unlock()
ls.log.Info("Disconnecting session due to session end event")
conn, ok := ls.connections[mmsg.SessionID]
if !ok {
return nil
}
delete(ls.connections, mmsg.SessionID)
return conn.Close()
}

View File

@ -44,40 +44,38 @@ func (ds *DirectSearcher) SearchSubschema(req *search.Request) (ldap.ServerSearc
{ {
Name: "attributeTypes", Name: "attributeTypes",
Values: []string{ Values: []string{
"( 0.9.2342.19200300.100.1.1 NAME 'uid' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 0.9.2342.19200300.100.1.3 NAME 'mail' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 0.9.2342.19200300.100.1.41 NAME 'mobile' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.2.102 NAME 'memberOf' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' NO-USER-MODIFICATION )",
"( 1.2.840.113556.1.2.13 NAME 'displayName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.2.131 NAME 'co' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.2.141 NAME 'department' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.2.146 NAME 'company' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.1 NAME 'name' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE NO-USER-MODIFICATION )",
"( 1.2.840.113556.1.4.221 NAME 'sAMAccountName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.261 NAME 'division' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.44 NAME 'homeDirectory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.750 NAME 'groupType' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.782 NAME 'objectCategory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' SINGLE-VALUE )",
"( 1.3.6.1.1.1.1.0 NAME 'uidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
"( 1.3.6.1.1.1.1.1 NAME 'gidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
"( 1.3.6.1.1.1.1.12 NAME 'memberUid' SYNTAX '1.3.6.1.4.1.1466.115.121.1.26' )",
"( 2.5.18.1 NAME 'createTimestamp' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION )",
"( 2.5.18.2 NAME 'modifyTimestamp' SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION )",
"( 2.5.21.2 NAME 'dITContentRules' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
"( 2.5.21.5 NAME 'attributeTypes' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
"( 2.5.21.6 NAME 'objectClasses' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
"( 2.5.4.0 NAME 'objectClass' SYNTAX '1.3.6.1.4.1.1466.115.121.1.38' NO-USER-MODIFICATION )", "( 2.5.4.0 NAME 'objectClass' SYNTAX '1.3.6.1.4.1.1466.115.121.1.38' NO-USER-MODIFICATION )",
"( 2.5.4.4 NAME 'sn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 2.5.4.3 NAME 'cn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 2.5.4.6 NAME 'c' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 2.5.4.7 NAME 'l' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 2.5.4.10 NAME 'o' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )", "( 2.5.4.10 NAME 'o' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )",
"( 2.5.4.11 NAME 'ou' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )", "( 2.5.4.11 NAME 'ou' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )",
"( 2.5.4.12 NAME 'title' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", "( 2.5.4.12 NAME 'title' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 2.5.4.13 NAME 'description' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )", "( 2.5.4.13 NAME 'description' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' )",
"( 2.5.4.20 NAME 'telephoneNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", "( 2.5.4.20 NAME 'telephoneNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 2.5.4.3 NAME 'cn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 2.5.4.31 NAME 'member' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' )", "( 2.5.4.31 NAME 'member' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' )",
"( 2.5.4.4 NAME 'sn' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 2.5.4.42 NAME 'givenName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", "( 2.5.4.42 NAME 'givenName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 2.5.4.6 NAME 'c' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", "( 2.5.21.2 NAME 'dITContentRules' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
"( 2.5.4.7 NAME 'l' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )", "( 2.5.21.5 NAME 'attributeTypes' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
"( 2.5.21.6 NAME 'objectClasses' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' NO-USER-MODIFICATION )",
"( 0.9.2342.19200300.100.1.1 NAME 'uid' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 0.9.2342.19200300.100.1.3 NAME 'mail' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 0.9.2342.19200300.100.1.41 NAME 'mobile' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.2.13 NAME 'displayName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.2.146 NAME 'company' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.2.102 NAME 'memberOf' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' NO-USER-MODIFICATION )",
"( 1.2.840.113556.1.2.131 NAME 'co' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.2.141 NAME 'department' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.1 NAME 'name' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE NO-USER-MODIFICATION )",
"( 1.2.840.113556.1.4.44 NAME 'homeDirectory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.221 NAME 'sAMAccountName' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.261 NAME 'division' SYNTAX '1.3.6.1.4.1.1466.115.121.1.15' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.750 NAME 'groupType' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
"( 1.2.840.113556.1.4.782 NAME 'objectCategory' SYNTAX '1.3.6.1.4.1.1466.115.121.1.12' SINGLE-VALUE )",
"( 1.3.6.1.1.1.1.0 NAME 'uidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
"( 1.3.6.1.1.1.1.1 NAME 'gidNumber' SYNTAX '1.3.6.1.4.1.1466.115.121.1.27' SINGLE-VALUE )",
"( 1.3.6.1.1.1.1.12 NAME 'memberUid' SYNTAX '1.3.6.1.4.1.1466.115.121.1.26' )",
// Custom attributes // Custom attributes
// Temporarily use 1.3.6.1.4.1.26027.1.1 as a base // Temporarily use 1.3.6.1.4.1.26027.1.1 as a base

View File

@ -53,14 +53,6 @@ func NewRequest(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (*Re
if err != nil && len(searchReq.Filter) > 0 { if err != nil && len(searchReq.Filter) > 0 {
l.WithError(err).WithField("objectClass", filterOC).Warning("invalid filter object class") l.WithError(err).WithField("objectClass", filterOC).Warning("invalid filter object class")
} }
// Handle comma-separated attributes
normalizedAttributes := normalizeAttributes(searchReq.Attributes)
if len(normalizedAttributes) != len(searchReq.Attributes) {
// Create a copy of the search request with normalized attributes
searchReq.Attributes = normalizedAttributes
}
return &Request{ return &Request{
SearchRequest: searchReq, SearchRequest: searchReq,
BindDN: bindDN, BindDN: bindDN,
@ -72,31 +64,6 @@ func NewRequest(bindDN string, searchReq ldap.SearchRequest, conn net.Conn) (*Re
}, span }, span
} }
// normalizeAttributes handles the case where attributes might be passed as comma-separated strings
// rather than as individual array elements
func normalizeAttributes(attributes []string) []string {
if len(attributes) == 0 {
return attributes
}
result := make([]string, 0, len(attributes))
for _, attr := range attributes {
if strings.Contains(attr, ",") {
// Split comma-separated attributes and add them individually
parts := strings.Split(attr, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
} else {
result = append(result, attr)
}
}
return result
}
func (r *Request) Context() context.Context { func (r *Request) Context() context.Context {
return r.ctx return r.ctx
} }

View File

@ -1,98 +0,0 @@
package search
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNormalizeAttributes(t *testing.T) {
tests := []struct {
name string
input []string
expectedOutput []string
}{
{
name: "Empty input",
input: []string{},
expectedOutput: []string{},
},
{
name: "No commas",
input: []string{"uid", "cn", "sn"},
expectedOutput: []string{"uid", "cn", "sn"},
},
{
name: "Single comma-separated string",
input: []string{"uid,cn,sn"},
expectedOutput: []string{"uid", "cn", "sn"},
},
{
name: "Mixed input",
input: []string{"uid,cn", "sn"},
expectedOutput: []string{"uid", "cn", "sn"},
},
{
name: "With spaces",
input: []string{"uid, cn, sn"},
expectedOutput: []string{"uid", "cn", "sn"},
},
{
name: "Empty parts",
input: []string{"uid,, cn"},
expectedOutput: []string{"uid", "cn"},
},
{
name: "Single element",
input: []string{"uid"},
expectedOutput: []string{"uid"},
},
{
name: "Only commas",
input: []string{",,,"},
expectedOutput: []string{},
},
{
name: "Multiple comma-separated attributes",
input: []string{"uid,cn", "sn,mail", "givenName"},
expectedOutput: []string{"uid", "cn", "sn", "mail", "givenName"},
},
{
name: "Case preservation",
input: []string{"uid,CN,sAMAccountName"},
expectedOutput: []string{"uid", "CN", "sAMAccountName"},
},
{
name: "Leading and trailing spaces",
input: []string{" uid , cn , sn "},
expectedOutput: []string{"uid", "cn", "sn"},
},
{
name: "Real-world LDAP attribute examples",
input: []string{"objectClass,memberOf,mail", "sAMAccountName,userPrincipalName"},
expectedOutput: []string{"objectClass", "memberOf", "mail", "sAMAccountName", "userPrincipalName"},
},
{
name: "Jira-style attribute format",
input: []string{"uid,cn,sn"},
expectedOutput: []string{"uid", "cn", "sn"},
},
{
name: "Single string with single attribute",
input: []string{"cn"},
expectedOutput: []string{"cn"},
},
{
name: "Mix of standard and operational attributes",
input: []string{"uid,+", "createTimestamp"},
expectedOutput: []string{"uid", "+", "createTimestamp"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeAttributes(tt.input)
assert.Equal(t, tt.expectedOutput, result)
})
}
}

View File

@ -5,7 +5,6 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/tls" "crypto/tls"
"encoding/gob" "encoding/gob"
"encoding/hex"
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
@ -119,8 +118,8 @@ func NewApplication(p api.ProxyOutpostConfig, c *http.Client, server Server, old
mux := mux.NewRouter() mux := mux.NewRouter()
// Save cookie name, based on hashed client ID // Save cookie name, based on hashed client ID
hs := sha256.Sum256([]byte(*p.ClientId)) h := sha256.New()
bs := hex.EncodeToString(hs[:]) bs := string(h.Sum([]byte(*p.ClientId)))
sessionName := fmt.Sprintf("authentik_proxy_%s", bs[:8]) sessionName := fmt.Sprintf("authentik_proxy_%s", bs[:8])
// When HOST_BROWSER is set, use that as Host header for token requests to make the issuer match // When HOST_BROWSER is set, use that as Host header for token requests to make the issuer match

View File

@ -3,7 +3,6 @@ package application
type ProxyClaims struct { type ProxyClaims struct {
UserAttributes map[string]interface{} `json:"user_attributes"` UserAttributes map[string]interface{} `json:"user_attributes"`
BackendOverride string `json:"backend_override"` BackendOverride string `json:"backend_override"`
HostHeader string `json:"host_header"`
IsSuperuser bool `json:"is_superuser"` IsSuperuser bool `json:"is_superuser"`
} }

View File

@ -74,18 +74,13 @@ func (a *Application) proxyModifyRequest(ou *url.URL) func(req *http.Request) {
r.URL.Scheme = ou.Scheme r.URL.Scheme = ou.Scheme
r.URL.Host = ou.Host r.URL.Host = ou.Host
claims := a.getClaimsFromSession(r) claims := a.getClaimsFromSession(r)
if claims != nil && claims.Proxy != nil { if claims != nil && claims.Proxy != nil && claims.Proxy.BackendOverride != "" {
if claims.Proxy.BackendOverride != "" { u, err := url.Parse(claims.Proxy.BackendOverride)
u, err := url.Parse(claims.Proxy.BackendOverride) if err != nil {
if err != nil { a.log.WithField("backend_override", claims.Proxy.BackendOverride).WithError(err).Warning("failed parse user backend override")
a.log.WithField("backend_override", claims.Proxy.BackendOverride).WithError(err).Warning("failed parse user backend override") } else {
} else { r.URL.Scheme = u.Scheme
r.URL.Scheme = u.Scheme r.URL.Host = u.Host
r.URL.Host = u.Host
}
}
if claims.Proxy.HostHeader != "" {
r.Host = claims.Proxy.HostHeader
} }
} }
a.log.WithField("upstream_url", r.URL.String()).Trace("final upstream url") a.log.WithField("upstream_url", r.URL.String()).Trace("final upstream url")

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