Compare commits

..

4 Commits

Author SHA1 Message Date
13c8cbf03a fix rac tests 2025-03-28 12:55:14 -03:00
1776981f29 Add schema.yml 2025-03-27 18:18:28 -03:00
5a4df95011 Fix tests, add more tests 2025-03-27 18:17:28 -03:00
f2927e5725 first approach 2025-03-27 18:03:25 -03:00
1219 changed files with 13504 additions and 63749 deletions

View File

@ -1,5 +1,5 @@
[bumpversion]
current_version = 2025.4.0
current_version = 2025.2.2
tag = True
commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
@ -17,8 +17,6 @@ optional_value = final
[bumpversion:file:pyproject.toml]
[bumpversion:file:uv.lock]
[bumpversion:file:package.json]
[bumpversion:file:docker-compose.yml]

View File

@ -48,7 +48,7 @@ runs:
- name: Generate config
shell: uv run python {0}
run: |
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
from yaml import safe_dump
with open("local.env.yml", "w") as _config:

View File

@ -118,15 +118,3 @@ updates:
prefix: "core:"
labels:
- dependencies
- package-ecosystem: docker-compose
directories:
# - /scripts # Maybe
- /tests/e2e
schedule:
interval: daily
time: "04:00"
open-pull-requests-limit: 10
commit-message:
prefix: "core:"
labels:
- dependencies

View File

@ -30,6 +30,7 @@ jobs:
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
cache: "poetry"
- name: Generate API Client
run: make gen-client-py
- name: Publish package

View File

@ -70,18 +70,22 @@ jobs:
- name: checkout stable
run: |
# Copy current, latest config to local
cp authentik/common/config/default.yml local.env.yml
cp -R .github ..
# Temporarly comment the .github backup while migrating to uv
cp authentik/lib/default.yml local.env.yml
# cp -R .github ..
cp -R scripts ..
git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
rm -rf .github/ scripts/
mv ../.github ../scripts .
# rm -rf .github/ scripts/
# mv ../.github ../scripts .
rm -rf scripts/
mv ../scripts .
- name: Setup authentik env (stable)
uses: ./.github/actions/setup
with:
postgresql_version: ${{ matrix.psql }}
continue-on-error: true
- name: run migrations to stable
run: uv run python -m lifecycle.migrate
run: poetry run python -m lifecycle.migrate
- name: checkout current code
run: |
set -x

View File

@ -29,7 +29,7 @@ jobs:
- name: Generate API
run: make gen-client-go
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v7
with:
version: latest
args: --timeout 5000s --verbose

View File

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

5
.gitignore vendored
View File

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

View File

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

View File

@ -16,7 +16,7 @@
],
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifierEnding": "index",
"typescript.tsdk": "./node_modules/typescript/lib",
"typescript.tsdk": "./web/node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true,
"yaml.schemas": {
"./blueprints/schema.json": "blueprints/**/*.yaml"
@ -30,5 +30,7 @@
}
],
"go.testFlags": ["-count=1"],
"github-actions.workflows.pinned.workflows": [".github/workflows/ci-main.yml"]
"github-actions.workflows.pinned.workflows": [
".github/workflows/ci-main.yml"
]
}

View File

@ -23,8 +23,6 @@ docker-compose.yml @goauthentik/infrastructure
Makefile @goauthentik/infrastructure
.editorconfig @goauthentik/infrastructure
CODEOWNERS @goauthentik/infrastructure
# Web packages
packages/ @goauthentik/frontend
# Web
web/ @goauthentik/frontend
tests/wdio/ @goauthentik/frontend

View File

@ -43,7 +43,7 @@ COPY ./gen-ts-api /work/web/node_modules/@goauthentik/api
RUN npm run build
# Stage 3: Build go proxy
FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.24-bookworm AS go-builder
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/oss/go/microsoft/golang:1.23-fips-bookworm AS go-builder
ARG TARGETOS
ARG TARGETARCH
@ -76,7 +76,7 @@ COPY ./go.sum /go/src/goauthentik.io/go.sum
RUN --mount=type=cache,sharing=locked,target=/go/pkg/mod \
--mount=type=cache,id=go-build-$TARGETARCH$TARGETVARIANT,sharing=locked,target=/root/.cache/go-build \
if [ "$TARGETARCH" = "arm64" ]; then export CC=aarch64-linux-gnu-gcc && export CC_FOR_TARGET=gcc-aarch64-linux-gnu; fi && \
CGO_ENABLED=1 GOFIPS140=latest GOARM="${TARGETVARIANT#v}" \
CGO_ENABLED=1 GOEXPERIMENT="systemcrypto" GOFLAGS="-tags=requirefips" GOARM="${TARGETVARIANT#v}" \
go build -o /go/authentik ./cmd/server
# Stage 4: MaxMind GeoIP
@ -85,17 +85,18 @@ FROM --platform=${BUILDPLATFORM} ghcr.io/maxmind/geoipupdate:v7.1.0 AS geoip
ENV GEOIPUPDATE_EDITION_IDS="GeoLite2-City GeoLite2-ASN"
ENV GEOIPUPDATE_VERBOSE="1"
ENV GEOIPUPDATE_ACCOUNT_ID_FILE="/run/secrets/GEOIPUPDATE_ACCOUNT_ID"
ENV GEOIPUPDATE_LICENSE_KEY_FILE="/run/secrets/GEOIPUPDATE_LICENSE_KEY"
USER root
RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
--mount=type=secret,id=GEOIPUPDATE_LICENSE_KEY \
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 "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.7.3 AS uv
FROM ghcr.io/astral-sh/uv:0.6.10 AS uv
# Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.3-slim-bookworm-fips AS python-base
FROM ghcr.io/goauthentik/fips-python:3.12.8-slim-bookworm-fips AS python-base
ENV VENV_PATH="/ak-root/.venv" \
PATH="/lifecycle:/ak-root/.venv/bin:$PATH" \

View File

@ -12,9 +12,9 @@ GEN_API_TS = "gen-ts-api"
GEN_API_PY = "gen-py-api"
GEN_API_GO = "gen-go-api"
pg_user := $(shell uv run python -m authentik.common.config postgresql.user 2>/dev/null)
pg_host := $(shell uv run python -m authentik.common.config postgresql.host 2>/dev/null)
pg_name := $(shell uv run python -m authentik.common.config postgresql.name 2>/dev/null)
pg_user := $(shell uv run python -m authentik.lib.config postgresql.user 2>/dev/null)
pg_host := $(shell uv run python -m authentik.lib.config postgresql.host 2>/dev/null)
pg_name := $(shell uv run python -m authentik.lib.config postgresql.name 2>/dev/null)
all: lint-fix lint test gen web ## Lint, build, and test everything

View File

@ -42,4 +42,4 @@ See [SECURITY.md](SECURITY.md)
## Adoption and Contributions
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [contribution guide](https://docs.goauthentik.io/docs/developer-docs?utm_source=github).
Your organization uses authentik? We'd love to add your logo to the readme and our website! Email us @ hello@goauthentik.io or open a GitHub Issue/PR! For more information on how to contribute to authentik, please refer to our [CONTRIBUTING.md file](./CONTRIBUTING.md).

View File

@ -20,8 +20,8 @@ Even if the issue is not a CVE, we still greatly appreciate your help in hardeni
| Version | Supported |
| --------- | --------- |
| 2024.12.x | ✅ |
| 2025.2.x | ✅ |
| 2025.4.x | ✅ |
## Reporting a Vulnerability

View File

@ -2,7 +2,7 @@
from os import environ
__version__ = "2025.4.0"
__version__ = "2025.2.2"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -7,8 +7,8 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.common.utils.reflection import get_apps
from authentik.core.api.utils import PassiveSerializer
from authentik.lib.utils.reflection import get_apps
from authentik.policies.event_matcher.models import model_choices

View File

@ -7,7 +7,6 @@ from sys import version as python_version
from typing import TypedDict
from cryptography.hazmat.backends.openssl.backend import backend
from django.apps import apps
from django.conf import settings
from django.utils.timezone import now
from django.views.debug import SafeExceptionReporterFilter
@ -18,10 +17,12 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from authentik import get_full_version
from authentik.common.config import CONFIG
from authentik.common.utils.reflection import get_env
from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.license import LicenseKey
from authentik.lib.config import CONFIG
from authentik.lib.utils.reflection import get_env
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
from authentik.rbac.permissions import HasPermission
@ -102,12 +103,6 @@ class SystemInfoSerializer(PassiveSerializer):
def get_embedded_outpost_host(self, request: Request) -> str:
"""Get the FQDN configured on the embedded outpost"""
if not apps.is_installed("authentik.outposts"):
return ""
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts.exists(): # pragma: no cover
return ""

View File

@ -2,7 +2,7 @@
from celery.schedules import crontab
from authentik.common.utils.time import fqdn_rand
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"admin_latest_version": {

View File

@ -9,10 +9,10 @@ from structlog.stdlib import get_logger
from authentik import __version__, get_build_hash
from authentik.admin.apps import PROM_INFO
from authentik.common.config import CONFIG
from authentik.common.utils.http import get_http_session
from authentik.events.models import Event, EventAction, Notification
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.lib.config import CONFIG
from authentik.lib.utils.http import get_http_session
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()

View File

@ -8,7 +8,7 @@ from django.urls import reverse
from authentik import __version__
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Group, User
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
class TestAdminAPI(TestCase):

View File

@ -9,8 +9,8 @@ from authentik.admin.tasks import (
clear_update_notifications,
update_latest_version,
)
from authentik.common.config import CONFIG
from authentik.events.models import Event, EventAction
from authentik.lib.config import CONFIG
RESPONSE_VALID = {
"$schema": "https://version.goauthentik.io/schema.json",

View File

@ -1,16 +1,19 @@
"""API Authentication"""
from hmac import compare_digest
from typing import Any
from django.conf import settings
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from rest_framework.authentication import BaseAuthentication, get_authorization_header
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.request import Request
from structlog.stdlib import get_logger
from authentik.common.oauth.constants import SCOPE_AUTHENTIK_API
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import Token, TokenIntents, User
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
LOGGER = get_logger()
@ -65,9 +68,28 @@ def auth_user_lookup(raw_header: bytes) -> User | None:
raise AuthenticationFailed("Token invalid/expired")
CTX_AUTH_VIA.set("jwt")
return jwt_token.user
# then try to auth via secret key (for embedded outpost/etc)
user = token_secret_key(auth_credentials)
if user:
CTX_AUTH_VIA.set("secret_key")
return user
raise AuthenticationFailed("Token invalid/expired")
def token_secret_key(value: str) -> User | None:
"""Check if the token is the secret key
and return the service account for the managed outpost"""
from authentik.outposts.apps import MANAGED_OUTPOST
if not compare_digest(value, settings.SECRET_KEY):
return None
outposts = Outpost.objects.filter(managed=MANAGED_OUTPOST)
if not outposts:
return None
outpost = outposts.first()
return outpost.user
class TokenAuthentication(BaseAuthentication):
"""Token-based authentication using HTTP Bearer authentication"""

View File

@ -54,7 +54,7 @@ def create_component(generator: SchemaGenerator, name, schema, type_=ResolvedCom
return component
def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs):
def postprocess_schema_responses(result, generator: SchemaGenerator, **kwargs): # noqa: W0613
"""Workaround to set a default response for endpoints.
Workaround suggested at
<https://github.com/tfranzel/drf-spectacular/issues/119#issuecomment-656970357>

View File

@ -3,15 +3,19 @@
import json
from base64 import b64encode
from django.conf import settings
from django.test import TestCase
from django.utils import timezone
from rest_framework.exceptions import AuthenticationFailed
from authentik.api.authentication import bearer_auth
from authentik.common.oauth.constants import SCOPE_AUTHENTIK_API
from authentik.core.models import Token, TokenIntents
from authentik.blueprints.tests import reconcile_app
from authentik.core.models import Token, TokenIntents, User, UserTypes
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
from authentik.outposts.apps import MANAGED_OUTPOST
from authentik.outposts.models import Outpost
from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API
from authentik.providers.oauth2.models import AccessToken, OAuth2Provider
@ -48,6 +52,21 @@ class TestAPIAuth(TestCase):
with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {token.key}".encode())
@reconcile_app("authentik_outposts")
def test_managed_outpost_fail(self):
"""Test managed outpost"""
outpost = Outpost.objects.filter(managed=MANAGED_OUTPOST).first()
outpost.user.delete()
outpost.delete()
with self.assertRaises(AuthenticationFailed):
bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
@reconcile_app("authentik_outposts")
def test_managed_outpost_success(self):
"""Test managed outpost"""
user: User = bearer_auth(f"Bearer {settings.SECRET_KEY}".encode())
self.assertEqual(user.type, UserTypes.INTERNAL_SERVICE_ACCOUNT)
def test_jwt_valid(self):
"""Test valid JWT"""
provider = OAuth2Provider.objects.create(

View File

@ -19,9 +19,9 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from authentik.common.config import CONFIG
from authentik.core.api.utils import PassiveSerializer
from authentik.events.context_processors.base import get_context_processors
from authentik.lib.config import CONFIG
capabilities = Signal()

View File

@ -11,7 +11,7 @@ from structlog.stdlib import get_logger
from authentik.api.v3.config import ConfigView
from authentik.api.views import APIBrowserView
from authentik.common.utils.reflection import get_apps
from authentik.lib.utils.reflection import get_apps
LOGGER = get_logger()

View File

@ -7,7 +7,7 @@ from rest_framework.exceptions import ValidationError
from rest_framework.fields import CharField, DateTimeField
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ListSerializer
from rest_framework.serializers import ListSerializer, ModelSerializer
from rest_framework.viewsets import ModelViewSet
from authentik.blueprints.models import BlueprintInstance
@ -15,7 +15,7 @@ from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.oci import OCI_PREFIX
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_find_dict
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import JSONDictField, ModelSerializer, PassiveSerializer
from authentik.core.api.utils import JSONDictField, PassiveSerializer
from authentik.rbac.decorators import permission_required

View File

@ -12,8 +12,8 @@ from structlog.stdlib import get_logger
from yaml import load
from authentik.blueprints.v1.common import BlueprintLoader, EntryInvalidError
from authentik.common.utils.errors import exception_to_string
from authentik.core.management.commands.shell import get_banner_text
from authentik.lib.utils.errors import exception_to_string
LOGGER = get_logger()

View File

@ -15,7 +15,7 @@ from authentik import __version__
from authentik.blueprints.v1.common import BlueprintEntryDesiredState
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT, is_model_allowed
from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry
from authentik.common.models import SerializerModel
from authentik.lib.models import SerializerModel
LOGGER = get_logger()

View File

@ -11,7 +11,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from yaml import load
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_SYSTEM
from authentik.common.config import CONFIG
from authentik.lib.config import CONFIG
def check_blueprint_v1_file(BlueprintInstance: type, db_alias, path: Path):

View File

@ -2,7 +2,7 @@
from django.db import migrations, models
from authentik.common.migrations import fallback_names
from authentik.lib.migrations import fallback_names
class Migration(migrations.Migration):

View File

@ -10,14 +10,14 @@ from rest_framework.serializers import Serializer
from structlog import get_logger
from authentik.blueprints.v1.oci import OCI_PREFIX, BlueprintOCIClient, OCIException
from authentik.common.config import CONFIG
from authentik.common.exceptions import NotReportedException
from authentik.common.models import CreatedUpdatedModel, SerializerModel
from authentik.lib.config import CONFIG
from authentik.lib.models import CreatedUpdatedModel, SerializerModel
from authentik.lib.sentry import SentryIgnoredException
LOGGER = get_logger()
class BlueprintRetrievalFailed(NotReportedException):
class BlueprintRetrievalFailed(SentryIgnoredException):
"""Error raised when we are unable to fetch the blueprint contents, whether it be HTTP files
not being accessible or local files not being readable"""

View File

@ -2,7 +2,7 @@
from celery.schedules import crontab
from authentik.common.utils.time import fqdn_rand
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"blueprints_v1_discover": {

View File

@ -3,7 +3,7 @@
from django.test import TestCase
from authentik.blueprints.models import BlueprintInstance, BlueprintRetrievalFailed
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
class TestModels(TestCase):

View File

@ -6,7 +6,7 @@ from django.apps import apps
from django.test import TestCase
from authentik.blueprints.v1.importer import is_model_allowed
from authentik.common.models import SerializerModel
from authentik.lib.models import SerializerModel
from authentik.providers.oauth2.models import RefreshToken

View File

@ -6,10 +6,10 @@ from django.test import TransactionTestCase
from authentik.blueprints.v1.exporter import FlowExporter
from authentik.blueprints.v1.importer import Importer, transaction_rollback
from authentik.common.tests import load_fixture
from authentik.core.models import Group
from authentik.crypto.generators import generate_id
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding
from authentik.sources.oauth.models import OAuthSource

View File

@ -7,8 +7,8 @@ from django.urls import reverse
from rest_framework.test import APITestCase
from yaml import dump
from authentik.common.config import CONFIG
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.config import CONFIG
TMP = mkdtemp("authentik-blueprints")

View File

@ -3,11 +3,11 @@
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.common.tests import load_fixture
from authentik.core.models import Application, Token, User
from authentik.core.tests.utils import create_test_admin_user
from authentik.crypto.generators import generate_id
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
from authentik.sources.oauth.models import OAuthSource

View File

@ -3,9 +3,9 @@
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.common.tests import load_fixture
from authentik.crypto.generators import generate_id
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
class TestBlueprintsV1Conditions(TransactionTestCase):

View File

@ -4,10 +4,10 @@ from django.test import TransactionTestCase
from guardian.shortcuts import get_perms
from authentik.blueprints.v1.importer import Importer
from authentik.common.tests import load_fixture
from authentik.core.models import User
from authentik.crypto.generators import generate_id
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
from authentik.rbac.models import Role

View File

@ -3,9 +3,9 @@
from django.test import TransactionTestCase
from authentik.blueprints.v1.importer import Importer
from authentik.common.tests import load_fixture
from authentik.crypto.generators import generate_id
from authentik.flows.models import Flow
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import load_fixture
class TestBlueprintsV1State(TransactionTestCase):

View File

@ -8,8 +8,8 @@ from yaml import dump
from authentik.blueprints.models import BlueprintInstance, BlueprintInstanceStatus
from authentik.blueprints.v1.tasks import apply_blueprint, blueprints_discovery, blueprints_find
from authentik.common.config import CONFIG
from authentik.crypto.generators import generate_id
from authentik.lib.config import CONFIG
from authentik.lib.generators import generate_id
TMP = mkdtemp("authentik-blueprints")

View File

@ -19,8 +19,8 @@ from rest_framework.fields import Field
from rest_framework.serializers import Serializer
from yaml import SafeDumper, SafeLoader, ScalarNode, SequenceNode
from authentik.common.exceptions import NotReportedException
from authentik.common.models import SerializerModel
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.models import PolicyBindingModel
@ -164,7 +164,9 @@ class BlueprintEntry:
"""Get the blueprint model, with yaml tags resolved if present"""
return str(self.tag_resolver(self.model, blueprint))
def get_permissions(self, blueprint: "Blueprint") -> Generator[BlueprintEntryPermission]:
def get_permissions(
self, blueprint: "Blueprint"
) -> Generator[BlueprintEntryPermission, None, None]:
"""Get permissions of this entry, with all yaml tags resolved"""
for perm in self.permissions:
yield BlueprintEntryPermission(
@ -661,7 +663,7 @@ class BlueprintLoader(SafeLoader):
self.add_constructor("!AtIndex", AtIndex)
class EntryInvalidError(NotReportedException):
class EntryInvalidError(SentryIgnoredException):
"""Error raised when an entry is invalid"""
entry_model: str | None

View File

@ -8,11 +8,14 @@ from dacite.config import Config
from dacite.core import from_dict
from dacite.exceptions import DaciteError
from deepmerge import always_merger
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError
from django.db.models import Model
from django.db.models.query_utils import Q
from django.db.transaction import atomic
from django.db.utils import IntegrityError
from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from rest_framework.exceptions import ValidationError
from rest_framework.serializers import BaseSerializer, Serializer
@ -28,26 +31,117 @@ from authentik.blueprints.v1.common import (
EntryInvalidError,
)
from authentik.blueprints.v1.meta.registry import BaseMetaModel, registry
from authentik.common.exceptions import NotReportedException
from authentik.common.models import SerializerModel, excluded_models
from authentik.common.utils.reflection import get_apps
from authentik.core.models import User
from authentik.core.models import (
AuthenticatedSession,
GroupSourceConnection,
PropertyMapping,
Provider,
Source,
User,
UserSourceConnection,
)
from authentik.enterprise.license import LicenseKey
from authentik.enterprise.models import LicenseUsage
from authentik.enterprise.providers.google_workspace.models import (
GoogleWorkspaceProviderGroup,
GoogleWorkspaceProviderUser,
)
from authentik.enterprise.providers.microsoft_entra.models import (
MicrosoftEntraProviderGroup,
MicrosoftEntraProviderUser,
)
from authentik.enterprise.providers.ssf.models import StreamEvent
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import (
EndpointDevice,
EndpointDeviceConnection,
)
from authentik.events.logs import LogEvent, capture_logs
from authentik.events.models import SystemTask
from authentik.events.utils import cleanse_dict
from authentik.flows.models import FlowToken, Stage
from authentik.lib.models import SerializerModel
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.reflection import get_apps
from authentik.outposts.models import OutpostServiceConnection
from authentik.policies.models import Policy, PolicyBindingModel
from authentik.policies.reputation.models import Reputation
from authentik.providers.oauth2.models import (
AccessToken,
AuthorizationCode,
DeviceToken,
RefreshToken,
)
from authentik.providers.rac.models import ConnectionToken
from authentik.providers.scim.models import SCIMProviderGroup, SCIMProviderUser
from authentik.rbac.models import Role
from authentik.sources.scim.models import SCIMSourceGroup, SCIMSourceUser
from authentik.stages.authenticator_webauthn.models import WebAuthnDeviceType
from authentik.tenants.models import Tenant
# Context set when the serializer is created in a blueprint context
# Update website/docs/customize/blueprints/v1/models.md when used
SERIALIZER_CONTEXT_BLUEPRINT = "blueprint_entry"
def excluded_models() -> list[type[Model]]:
"""Return a list of all excluded models that shouldn't be exposed via API
or other means (internal only, base classes, non-used objects, etc)"""
from django.contrib.auth.models import Group as DjangoGroup
from django.contrib.auth.models import User as DjangoUser
return (
# Django only classes
DjangoUser,
DjangoGroup,
ContentType,
Permission,
UserObjectPermission,
# Base classes
Provider,
Source,
PropertyMapping,
UserSourceConnection,
GroupSourceConnection,
Stage,
OutpostServiceConnection,
Policy,
PolicyBindingModel,
# Classes that have other dependencies
AuthenticatedSession,
# Classes which are only internally managed
# FIXME: these shouldn't need to be explicitly listed, but rather based off of a mixin
FlowToken,
LicenseUsage,
SCIMProviderGroup,
SCIMProviderUser,
Tenant,
SystemTask,
ConnectionToken,
AuthorizationCode,
AccessToken,
RefreshToken,
Reputation,
WebAuthnDeviceType,
SCIMSourceUser,
SCIMSourceGroup,
GoogleWorkspaceProviderUser,
GoogleWorkspaceProviderGroup,
MicrosoftEntraProviderUser,
MicrosoftEntraProviderGroup,
EndpointDevice,
EndpointDeviceConnection,
DeviceToken,
StreamEvent,
)
def is_model_allowed(model: type[Model]) -> bool:
"""Check if model is allowed"""
return model not in excluded_models() and issubclass(model, SerializerModel | BaseMetaModel)
class DoRollback(NotReportedException):
class DoRollback(SentryIgnoredException):
"""Exception to trigger a rollback"""

View File

@ -16,14 +16,14 @@ from requests.exceptions import RequestException
from structlog import get_logger
from structlog.stdlib import BoundLogger
from authentik.common.exceptions import NotReportedException
from authentik.common.utils.http import authentik_user_agent
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.utils.http import authentik_user_agent
OCI_MEDIA_TYPE = "application/vnd.goauthentik.blueprint.v1+yaml"
OCI_PREFIX = "oci://"
class OCIException(NotReportedException):
class OCIException(SentryIgnoredException):
"""OCI-related errors"""

View File

@ -30,11 +30,11 @@ from authentik.blueprints.v1.common import BlueprintLoader, BlueprintMetadata, E
from authentik.blueprints.v1.importer import Importer
from authentik.blueprints.v1.labels import LABEL_AUTHENTIK_INSTANTIATE
from authentik.blueprints.v1.oci import OCI_PREFIX
from authentik.common.config import CONFIG
from authentik.events.logs import capture_logs
from authentik.events.models import TaskStatus
from authentik.events.system_tasks import SystemTask, prefill_task
from authentik.events.utils import sanitize_dict
from authentik.lib.config import CONFIG
from authentik.root.celery import CELERY_APP
from authentik.tenants.models import Tenant

View File

@ -5,7 +5,7 @@ import uuid
import django.db.models.deletion
from django.db import migrations, models
import authentik.common.utils.time
import authentik.lib.utils.time
class Migration(migrations.Migration):
@ -104,7 +104,7 @@ class Migration(migrations.Migration):
"Events will be deleted after this duration.(Format:"
" weeks=3;days=2;hours=3,seconds=2)."
),
validators=[authentik.common.utils.time.timedelta_string_validator],
validators=[authentik.lib.utils.time.timedelta_string_validator],
),
),
migrations.AddField(

View File

@ -16,7 +16,7 @@ def migrate_custom_css(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
if not path.exists():
return
css = path.read_text()
Brand.objects.using(db_alias).all().update(branding_custom_css=css)
Brand.objects.using(db_alias).update(branding_custom_css=css)
class Migration(migrations.Migration):

View File

@ -8,10 +8,10 @@ from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.common.config import CONFIG
from authentik.common.models import SerializerModel
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow
from authentik.lib.config import CONFIG
from authentik.lib.models import SerializerModel
LOGGER = get_logger()

View File

@ -7,7 +7,7 @@ from authentik.brands.api import Themes
from authentik.brands.models import Brand
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_brand
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.providers.saml.models import SAMLProvider

View File

@ -1,7 +0,0 @@
class AuthentikException(Exception):
"""Base class for authentik exceptions"""
class NotReportedException(AuthentikException):
"""Exception base class for all errors that are suppressed,
and not sent to any kind of monitoring."""

View File

@ -1,6 +0,0 @@
from authentik.common.exceptions import NotReportedException
class ControlFlowException(NotReportedException):
"""Exceptions used to control the flow from exceptions, not reported as a warning/
error in logs"""

View File

@ -1 +0,0 @@
LDAP_DISTINGUISHED_NAME = "distinguishedName"

View File

@ -1,10 +0,0 @@
from rest_framework.fields import CharField
from authentik.core.api.utils import PassiveSerializer
class SAMLMetadataSerializer(PassiveSerializer):
"""SAML Provider Metadata serializer"""
metadata = CharField(read_only=True)
download_url = CharField(read_only=True, required=False)

View File

@ -1,19 +0,0 @@
"""Test HTTP Helpers"""
from django.test import RequestFactory, TestCase
from authentik.common.views import bad_request_message
from authentik.core.tests.utils import create_test_admin_user
class TestViews(TestCase):
"""Test Views Helpers"""
def setUp(self) -> None:
self.user = create_test_admin_user()
self.factory = RequestFactory()
def test_bad_request_message(self):
"""test bad_request_message"""
request = self.factory.get("/")
self.assertEqual(bad_request_message(request, "foo").status_code, 400)

View File

@ -23,18 +23,18 @@ from structlog.stdlib import get_logger
from authentik.admin.api.metrics import CoordinateSerializer
from authentik.api.pagination import Pagination
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.common.utils.file import (
FilePathSerializer,
FileUploadSerializer,
set_file,
set_file_url,
)
from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import ModelSerializer
from authentik.core.models import Application, User
from authentik.events.logs import LogEventSerializer, capture_logs
from authentik.events.models import EventAction
from authentik.lib.utils.file import (
FilePathSerializer,
FileUploadSerializer,
set_file,
set_file_url,
)
from authentik.policies.api.exec import PolicyTestResultSerializer
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import CACHE_PREFIX, PolicyResult
@ -46,7 +46,7 @@ LOGGER = get_logger()
def user_app_cache_key(user_pk: str, page_number: int | None = None) -> str:
"""Cache key where application list for user is saved"""
key = f"{CACHE_PREFIX}app_access/{user_pk}"
key = f"{CACHE_PREFIX}/app_access/{user_pk}"
if page_number:
key += f"/{page_number}"
return key

View File

@ -5,7 +5,6 @@ from typing import TypedDict
from rest_framework import mixins
from rest_framework.fields import SerializerMethodField
from rest_framework.request import Request
from rest_framework.serializers import CharField, DateTimeField, IPAddressField
from rest_framework.viewsets import GenericViewSet
from ua_parser import user_agent_parser
@ -55,11 +54,6 @@ class UserAgentDict(TypedDict):
class AuthenticatedSessionSerializer(ModelSerializer):
"""AuthenticatedSession Serializer"""
expires = DateTimeField(source="session.expires", read_only=True)
last_ip = IPAddressField(source="session.last_ip", read_only=True)
last_user_agent = CharField(source="session.last_user_agent", read_only=True)
last_used = DateTimeField(source="session.last_used", read_only=True)
current = SerializerMethodField()
user_agent = SerializerMethodField()
geo_ip = SerializerMethodField()
@ -68,19 +62,19 @@ class AuthenticatedSessionSerializer(ModelSerializer):
def get_current(self, instance: AuthenticatedSession) -> bool:
"""Check if session is currently active session"""
request: Request = self.context["request"]
return request._request.session.session_key == instance.session.session_key
return request._request.session.session_key == instance.session_key
def get_user_agent(self, instance: AuthenticatedSession) -> UserAgentDict:
"""Get parsed user agent"""
return user_agent_parser.Parse(instance.session.last_user_agent)
return user_agent_parser.Parse(instance.last_user_agent)
def get_geo_ip(self, instance: AuthenticatedSession) -> GeoIPDict | None: # pragma: no cover
"""Get GeoIP Data"""
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.session.last_ip)
return GEOIP_CONTEXT_PROCESSOR.city_dict(instance.last_ip)
def get_asn(self, instance: AuthenticatedSession) -> ASNDict | None: # pragma: no cover
"""Get ASN Data"""
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.session.last_ip)
return ASN_CONTEXT_PROCESSOR.asn_dict(instance.last_ip)
class Meta:
model = AuthenticatedSession
@ -96,7 +90,6 @@ class AuthenticatedSessionSerializer(ModelSerializer):
"last_used",
"expires",
]
extra_args = {"uuid": {"read_only": True}}
class AuthenticatedSessionViewSet(
@ -108,10 +101,9 @@ class AuthenticatedSessionViewSet(
):
"""AuthenticatedSession Viewset"""
lookup_field = "uuid"
queryset = AuthenticatedSession.objects.select_related("session").all()
queryset = AuthenticatedSession.objects.all()
serializer_class = AuthenticatedSessionSerializer
search_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
filterset_fields = ["user__username", "session__last_ip", "session__last_user_agent"]
search_fields = ["user__username", "last_ip", "last_user_agent"]
filterset_fields = ["user__username", "last_ip", "last_user_agent"]
ordering = ["user__username"]
owner_field = "user"

View File

@ -9,9 +9,9 @@ from rest_framework.fields import (
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.common.utils.reflection import all_subclasses
from authentik.core.api.utils import PassiveSerializer
from authentik.enterprise.apps import EnterpriseConfig
from authentik.lib.utils.reflection import all_subclasses
class TypeCreateSerializer(PassiveSerializer):

View File

@ -22,7 +22,6 @@ from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from authentik.blueprints.api import ManagedSerializer
from authentik.common.utils.errors import exception_to_string
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import (
@ -34,6 +33,7 @@ from authentik.core.expression.evaluator import PropertyMappingEvaluator
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.models import Group, PropertyMapping, User
from authentik.events.utils import sanitize_item
from authentik.lib.utils.errors import exception_to_string
from authentik.policies.api.exec import PolicyTestSerializer
from authentik.rbac.decorators import permission_required

View File

@ -6,7 +6,7 @@ from django.utils.translation import gettext_lazy as _
from django_filters.filters import BooleanFilter
from django_filters.filterset import FilterSet
from rest_framework import mixins
from rest_framework.fields import ReadOnlyField, SerializerMethodField
from rest_framework.fields import SerializerMethodField
from rest_framework.viewsets import GenericViewSet
from authentik.core.api.object_types import TypesMixin
@ -18,10 +18,10 @@ from authentik.core.models import Provider
class ProviderSerializer(ModelSerializer, MetaNameSerializer):
"""Provider Serializer"""
assigned_application_slug = ReadOnlyField(source="application.slug")
assigned_application_name = ReadOnlyField(source="application.name")
assigned_backchannel_application_slug = ReadOnlyField(source="backchannel_application.slug")
assigned_backchannel_application_name = ReadOnlyField(source="backchannel_application.name")
assigned_application_slug = SerializerMethodField()
assigned_application_name = SerializerMethodField()
assigned_backchannel_application_slug = SerializerMethodField()
assigned_backchannel_application_name = SerializerMethodField()
component = SerializerMethodField()
@ -31,6 +31,38 @@ class ProviderSerializer(ModelSerializer, MetaNameSerializer):
return ""
return obj.component
def get_assigned_application_slug(self, obj: Provider) -> str:
"""Get application slug, return empty string if no application exists"""
try:
return obj.application.slug
except Provider.application.RelatedObjectDoesNotExist:
return ""
def get_assigned_application_name(self, obj: Provider) -> str:
"""Get application name, return empty string if no application exists"""
try:
return obj.application.name
except Provider.application.RelatedObjectDoesNotExist:
return ""
def get_assigned_backchannel_application_slug(self, obj: Provider) -> str:
"""Get backchannel application slug.
Returns an empty string if no backchannel application exists.
"""
if not obj.backchannel_application:
return ""
return obj.backchannel_application.slug or ""
def get_assigned_backchannel_application_name(self, obj: Provider) -> str:
"""Get backchannel application name.
Returns an empty string if no backchannel application exists.
"""
if not obj.backchannel_application:
return ""
return obj.backchannel_application.name or ""
class Meta:
model = Provider
fields = [

View File

@ -14,17 +14,17 @@ from rest_framework.viewsets import GenericViewSet
from structlog.stdlib import get_logger
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.common.utils.file import (
FilePathSerializer,
FileUploadSerializer,
set_file,
set_file_url,
)
from authentik.core.api.object_types import TypesMixin
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.utils import MetaNameSerializer, ModelSerializer
from authentik.core.models import GroupSourceConnection, Source, UserSourceConnection
from authentik.core.types import UserSettingSerializer
from authentik.lib.utils.file import (
FilePathSerializer,
FileUploadSerializer,
set_file,
set_file_url,
)
from authentik.policies.engine import PolicyEngine
from authentik.rbac.decorators import permission_required
@ -179,13 +179,10 @@ class UserSourceConnectionSerializer(SourceSerializer):
"user",
"source",
"source_obj",
"identifier",
"created",
"last_updated",
]
extra_kwargs = {
"created": {"read_only": True},
"last_updated": {"read_only": True},
}
@ -202,7 +199,7 @@ class UserSourceConnectionViewSet(
queryset = UserSourceConnection.objects.all()
serializer_class = UserSourceConnectionSerializer
filterset_fields = ["user", "source__slug"]
search_fields = ["user__username", "source__slug", "identifier"]
search_fields = ["source__slug"]
ordering = ["source__slug", "pk"]
owner_field = "user"
@ -221,11 +218,9 @@ class GroupSourceConnectionSerializer(SourceSerializer):
"source_obj",
"identifier",
"created",
"last_updated",
]
extra_kwargs = {
"created": {"read_only": True},
"last_updated": {"read_only": True},
}
@ -242,5 +237,6 @@ class GroupSourceConnectionViewSet(
queryset = GroupSourceConnection.objects.all()
serializer_class = GroupSourceConnectionSerializer
filterset_fields = ["group", "source__slug"]
search_fields = ["group__name", "source__slug", "identifier"]
search_fields = ["source__slug"]
ordering = ["source__slug", "pk"]
owner_field = "user"

View File

@ -14,7 +14,6 @@ from rest_framework.viewsets import ModelViewSet
from authentik.blueprints.api import ManagedSerializer
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.common.utils.time import timedelta_from_string
from authentik.core.api.used_by import UsedByMixin
from authentik.core.api.users import UserSerializer
from authentik.core.api.utils import ModelSerializer, PassiveSerializer
@ -28,6 +27,7 @@ from authentik.core.models import (
)
from authentik.events.models import Event, EventAction
from authentik.events.utils import model_to_dict
from authentik.lib.utils.time import timedelta_from_string
from authentik.rbac.decorators import permission_required

View File

@ -20,10 +20,10 @@ from authentik.blueprints.v1.common import (
KeyOf,
)
from authentik.blueprints.v1.importer import Importer
from authentik.common.utils.reflection import all_subclasses
from authentik.core.api.applications import ApplicationSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import Application, Provider
from authentik.lib.utils.reflection import all_subclasses
from authentik.policies.api.bindings import PolicyBindingSerializer

View File

@ -6,6 +6,8 @@ from typing import Any
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.models import Permission
from django.contrib.sessions.backends.cache import KEY_PREFIX
from django.core.cache import cache
from django.db.models.functions import ExtractHour
from django.db.transaction import atomic
from django.db.utils import IntegrityError
@ -62,7 +64,6 @@ from authentik.core.api.utils import (
ModelSerializer,
PassiveSerializer,
)
from authentik.core.avatars import get_avatar
from authentik.core.middleware import (
SESSION_KEY_IMPERSONATE_ORIGINAL_USER,
SESSION_KEY_IMPERSONATE_USER,
@ -70,8 +71,8 @@ from authentik.core.middleware import (
from authentik.core.models import (
USER_ATTRIBUTE_TOKEN_EXPIRING,
USER_PATH_SERVICE_ACCOUNT,
AuthenticatedSession,
Group,
Session,
Token,
TokenIntents,
User,
@ -82,6 +83,7 @@ from authentik.flows.exceptions import FlowNonApplicableException
from authentik.flows.models import FlowToken
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
from authentik.flows.views.executor import QS_KEY_TOKEN
from authentik.lib.avatars import get_avatar
from authentik.rbac.decorators import permission_required
from authentik.rbac.models import get_permission_choices
from authentik.stages.email.models import EmailStage
@ -224,7 +226,6 @@ class UserSerializer(ModelSerializer):
"name",
"is_active",
"last_login",
"date_joined",
"is_superuser",
"groups",
"groups_obj",
@ -239,7 +240,6 @@ class UserSerializer(ModelSerializer):
]
extra_kwargs = {
"name": {"allow_blank": True},
"date_joined": {"read_only": True},
"password_change_date": {"read_only": True},
}
@ -373,7 +373,7 @@ class UsersFilter(FilterSet):
method="filter_attributes",
)
is_superuser = BooleanFilter(field_name="ak_groups", method="filter_is_superuser")
is_superuser = BooleanFilter(field_name="ak_groups", lookup_expr="is_superuser")
uuid = UUIDFilter(field_name="uuid")
path = CharFilter(field_name="path")
@ -391,11 +391,6 @@ class UsersFilter(FilterSet):
queryset=Group.objects.all().order_by("name"),
)
def filter_is_superuser(self, queryset, name, value):
if value:
return queryset.filter(ak_groups__is_superuser=True).distinct()
return queryset.exclude(ak_groups__is_superuser=True).distinct()
def filter_attributes(self, queryset, name, value):
"""Filter attributes by query args"""
try:
@ -772,6 +767,9 @@ class UserViewSet(UsedByMixin, ModelViewSet):
response = super().partial_update(request, *args, **kwargs)
instance: User = self.get_object()
if not instance.is_active:
Session.objects.filter(authenticatedsession__user=instance).delete()
sessions = AuthenticatedSession.objects.filter(user=instance)
session_ids = sessions.values_list("session_key", flat=True)
cache.delete_many(f"{KEY_PREFIX}{session}" for session in session_ids)
sessions.delete()
LOGGER.debug("Deleted user's sessions", user=instance.username)
return response

View File

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

View File

@ -24,15 +24,6 @@ class InbuiltBackend(ModelBackend):
self.set_method("password", request)
return user
async def aauthenticate(
self, request: HttpRequest, username: str | None, password: str | None, **kwargs: Any
) -> User | None:
user = await super().aauthenticate(request, username=username, password=password, **kwargs)
if not user:
return None
self.set_method("password", request)
return user
def set_method(self, method: str, request: HttpRequest | None, **kwargs):
"""Set method data on current flow, if possbiel"""
if not request:

View File

@ -7,11 +7,11 @@ from django.db.models import Model
from django.http import HttpRequest
from prometheus_client import Histogram
from authentik.common.expression.evaluator import BaseEvaluator
from authentik.common.utils.errors import exception_to_string
from authentik.core.expression.exceptions import SkipObjectException
from authentik.core.models import User
from authentik.events.models import Event, EventAction
from authentik.lib.expression.evaluator import BaseEvaluator
from authentik.lib.utils.errors import exception_to_string
from authentik.policies.types import PolicyRequest
PROPERTY_MAPPING_TIME = Histogram(

View File

@ -1,10 +1,10 @@
"""authentik core exceptions"""
from authentik.common.exceptions import NotReportedException
from authentik.common.expression.exceptions import ControlFlowException
from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.sentry import SentryIgnoredException
class PropertyMappingExpressionException(NotReportedException):
class PropertyMappingExpressionException(SentryIgnoredException):
"""Error when a PropertyMapping Exception expression could not be parsed or evaluated."""
def __init__(self, exc: Exception, mapping) -> None:

View File

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

View File

@ -5,7 +5,7 @@ from typing import TextIO
from daphne.management.commands.runserver import Command as RunServer
from daphne.server import Server
from authentik.root.debug import start_debug_server
from authentik.lib.debug import start_debug_server
from authentik.root.signals import post_startup, pre_startup, startup

View File

@ -8,9 +8,9 @@ from django.core.management.base import BaseCommand
from django.db import close_old_connections
from structlog.stdlib import get_logger
from authentik.common.config import CONFIG
from authentik.lib.config import CONFIG
from authentik.lib.debug import start_debug_server
from authentik.root.celery import CELERY_APP
from authentik.root.debug import start_debug_server
LOGGER = get_logger()

View File

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

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
import authentik.core.models
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
def set_default_token_key(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):

View File

@ -10,7 +10,7 @@ from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.models import Count
import authentik.core.models
import authentik.common.models
import authentik.lib.models
def migrate_sessions(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
@ -160,7 +160,7 @@ class Migration(migrations.Migration):
field=models.TextField(
blank=True,
default="",
validators=[authentik.common.models.DomainlessFormattedURLValidator()],
validators=[authentik.lib.models.DomainlessFormattedURLValidator()],
),
),
migrations.RunPython(

View File

@ -2,7 +2,7 @@
from django.db import migrations, models
from authentik.common.migrations import fallback_names
from authentik.lib.migrations import fallback_names
class Migration(migrations.Migration):

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,14 @@
"""authentik core models"""
from datetime import datetime
from enum import StrEnum
from hashlib import sha256
from typing import Any, Optional, Self
from uuid import uuid4
from deepmerge import Merger, always_merger
from deepmerge import always_merger
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
from django.db import models
from django.db.models import Q, QuerySet, options
from django.db.models.constants import LOOKUP_SEP
@ -26,18 +24,18 @@ from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger
from authentik.blueprints.models import ManagedModel
from authentik.common.expression.exceptions import ControlFlowException
from authentik.common.models import (
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.lib.avatars import get_avatar
from authentik.lib.expression.exceptions import ControlFlowException
from authentik.lib.generators import generate_id
from authentik.lib.merge import MERGE_LIST_UNIQUE
from authentik.lib.models import (
CreatedUpdatedModel,
DomainlessFormattedURLValidator,
SerializerModel,
internal_model,
)
from authentik.common.utils.time import timedelta_from_string
from authentik.core.avatars import get_avatar
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.types import UILoginButton, UserSettingSerializer
from authentik.crypto.generators import generate_id
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.models import PolicyBindingModel
from authentik.tenants.models import DEFAULT_TOKEN_DURATION, DEFAULT_TOKEN_LENGTH
from authentik.tenants.utils import get_current_tenant, get_unique_identifier
@ -64,10 +62,6 @@ options.DEFAULT_NAMES = options.DEFAULT_NAMES + (
GROUP_RECURSION_LIMIT = 20
MERGE_LIST_UNIQUE = Merger(
[(list, ["append_unique"]), (dict, ["merge"]), (set, ["union"])], ["override"], ["override"]
)
def default_token_duration() -> datetime:
"""Default duration a Token is valid"""
@ -412,7 +406,6 @@ class User(SerializerModel, GuardianUserMixin, AttributesMixin, AbstractUser):
return get_avatar(self)
@internal_model
class Provider(SerializerModel):
"""Application-independent Provider instance. For example SAML2 Remote, OAuth2 Application"""
@ -653,30 +646,19 @@ class SourceUserMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning users"""
IDENTIFIER = "identifier", _("Use the source-specific identifier")
EMAIL_LINK = (
"email_link",
_(
"Link to a user with identical email address. Can have security implications "
"when a source doesn't validate email addresses."
),
EMAIL_LINK = "email_link", _(
"Link to a user with identical email address. Can have security implications "
"when a source doesn't validate email addresses."
)
EMAIL_DENY = (
"email_deny",
_(
"Use the user's email address, but deny enrollment when the email address already "
"exists."
),
EMAIL_DENY = "email_deny", _(
"Use the user's email address, but deny enrollment when the email address already exists."
)
USERNAME_LINK = (
"username_link",
_(
"Link to a user with identical username. Can have security implications "
"when a username is used with another source."
),
USERNAME_LINK = "username_link", _(
"Link to a user with identical username. Can have security implications "
"when a username is used with another source."
)
USERNAME_DENY = (
"username_deny",
_("Use the user's username, but deny enrollment when the username already exists."),
USERNAME_DENY = "username_deny", _(
"Use the user's username, but deny enrollment when the username already exists."
)
@ -684,20 +666,15 @@ class SourceGroupMatchingModes(models.TextChoices):
"""Different modes a source can handle new/returning groups"""
IDENTIFIER = "identifier", _("Use the source-specific identifier")
NAME_LINK = (
"name_link",
_(
"Link to a group with identical name. Can have security implications "
"when a group name is used with another source."
),
NAME_LINK = "name_link", _(
"Link to a group with identical name. Can have security implications "
"when a group name is used with another source."
)
NAME_DENY = (
"name_deny",
_("Use the group name, but deny enrollment when the name already exists."),
NAME_DENY = "name_deny", _(
"Use the group name, but deny enrollment when the name already exists."
)
@internal_model
class Source(ManagedModel, SerializerModel, PolicyBindingModel):
"""Base Authentication source, i.e. an OAuth Provider, SAML Remote or LDAP Server"""
@ -753,20 +730,13 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
choices=SourceGroupMatchingModes.choices,
default=SourceGroupMatchingModes.IDENTIFIER,
help_text=_(
"How the source determines if an existing group should be used or a new group created."
"How the source determines if an existing group should be used or "
"a new group created."
),
)
objects = InheritanceManager()
@property
def serializer(self) -> type[Serializer]:
if self.managed == self.MANAGED_INBUILT:
from authentik.core.api.sources import SourceSerializer
return SourceSerializer
return super().serializer
@property
def icon_url(self) -> str | None:
"""Get the URL to the Icon. If the name is /static or
@ -791,17 +761,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
@property
def component(self) -> str:
"""Return component used to edit this object"""
if self.managed == self.MANAGED_INBUILT:
return ""
raise NotImplementedError
@property
def property_mapping_type(self) -> "type[PropertyMapping]":
"""Return property mapping type used by this object"""
if self.managed == self.MANAGED_INBUILT:
from authentik.core.models import PropertyMapping
return PropertyMapping
raise NotImplementedError
def ui_login_button(self, request: HttpRequest) -> UILoginButton | None:
@ -816,14 +780,10 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
def get_base_user_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a user to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError
def get_base_group_properties(self, **kwargs) -> dict[str, Any | dict[str, Any]]:
"""Get base properties for a group to build final properties upon."""
if self.managed == self.MANAGED_INBUILT:
return {}
raise NotImplementedError
def __str__(self):
@ -849,13 +809,11 @@ class Source(ManagedModel, SerializerModel, PolicyBindingModel):
]
@internal_model
class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
"""Connection between User and Source."""
user = models.ForeignKey(User, on_delete=models.CASCADE)
source = models.ForeignKey(Source, on_delete=models.CASCADE)
identifier = models.TextField()
objects = InheritanceManager()
@ -869,13 +827,8 @@ class UserSourceConnection(SerializerModel, CreatedUpdatedModel):
class Meta:
unique_together = (("user", "source"),)
indexes = (
models.Index(fields=("identifier",)),
models.Index(fields=("source", "identifier")),
)
@internal_model
class GroupSourceConnection(SerializerModel, CreatedUpdatedModel):
"""Connection between Group and Source."""
@ -1005,7 +958,6 @@ class Token(SerializerModel, ManagedModel, ExpiringModel):
).save()
@internal_model
class PropertyMapping(SerializerModel, ManagedModel):
"""User-defined key -> x mapping which can be used by providers to expose extra data."""
@ -1045,77 +997,45 @@ class PropertyMapping(SerializerModel, ManagedModel):
verbose_name_plural = _("Property Mappings")
@internal_model
class Session(ExpiringModel, AbstractBaseSession):
"""User session with extra fields for fast access"""
class AuthenticatedSession(ExpiringModel):
"""Additional session class for authenticated users. Augments the standard django session
to achieve the following:
- Make it queryable by user
- Have a direct connection to user objects
- Allow users to view their own sessions and terminate them
- Save structured and well-defined information.
"""
# Remove upstream field because we're using our own ExpiringModel
expire_date = None
session_data = models.BinaryField(_("session data"))
uuid = models.UUIDField(default=uuid4, primary_key=True)
# Keep in sync with Session.Keys
last_ip = models.GenericIPAddressField()
session_key = models.CharField(max_length=40)
user = models.ForeignKey(User, on_delete=models.CASCADE)
last_ip = models.TextField()
last_user_agent = models.TextField(blank=True)
last_used = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = _("Session")
verbose_name_plural = _("Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["expires", "session_key"]),
]
default_permissions = []
def __str__(self):
return self.session_key
class Keys(StrEnum):
"""
Keys to be set with the session interface for the fields above to be updated.
If a field is added here that needs to be initialized when the session is initialized,
it must also be reflected in authentik.root.middleware.SessionMiddleware.process_request
and in authentik.core.sessions.SessionStore.__init__
"""
LAST_IP = "last_ip"
LAST_USER_AGENT = "last_user_agent"
LAST_USED = "last_used"
@classmethod
def get_session_store_class(cls):
from authentik.core.sessions import SessionStore
return SessionStore
def get_decoded(self):
raise NotImplementedError
@internal_model
class AuthenticatedSession(SerializerModel):
session = models.OneToOneField(Session, on_delete=models.CASCADE, primary_key=True)
# We use the session as primary key, but we need the API to be able to reference
# this object uniquely without exposing the session key
uuid = models.UUIDField(default=uuid4, unique=True)
user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
verbose_name = _("Authenticated Session")
verbose_name_plural = _("Authenticated Sessions")
indexes = ExpiringModel.Meta.indexes + [
models.Index(fields=["session_key"]),
]
def __str__(self) -> str:
return f"Authenticated Session {str(self.pk)[:10]}"
return f"Authenticated Session {self.session_key[:10]}"
@staticmethod
def from_request(request: HttpRequest, user: User) -> Optional["AuthenticatedSession"]:
"""Create a new session from a http request"""
if not hasattr(request, "session") or not request.session.exists(
request.session.session_key
):
from authentik.root.middleware import ClientIPMiddleware
if not hasattr(request, "session") or not request.session.session_key:
return None
return AuthenticatedSession(
session=Session.objects.filter(session_key=request.session.session_key).first(),
session_key=request.session.session_key,
user=user,
last_ip=ClientIPMiddleware.get_client_ip(request),
last_user_agent=request.META.get("HTTP_USER_AGENT", ""),
expires=request.session.get_expiry_date(),
)

View File

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

View File

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

View File

@ -10,7 +10,6 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
from authentik.common.views import bad_request_message
from authentik.core.models import (
Group,
GroupSourceConnection,
@ -37,6 +36,7 @@ from authentik.flows.planner import (
)
from authentik.flows.stage import StageView
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET
from authentik.lib.views import bad_request_message
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.utils import delete_none_values
from authentik.stages.password import BACKEND_INBUILT

View File

@ -3,10 +3,11 @@ from typing import Any
from django.http import HttpRequest
from structlog.stdlib import get_logger
from authentik.common.sync.mapper import PropertyMappingManager
from authentik.core.expression.exceptions import PropertyMappingExpressionException
from authentik.core.models import MERGE_LIST_UNIQUE, Group, PropertyMapping, Source, User
from authentik.core.models import Group, PropertyMapping, Source, User
from authentik.events.models import Event, EventAction
from authentik.lib.merge import MERGE_LIST_UNIQUE
from authentik.lib.sync.mapper import PropertyMappingManager
from authentik.policies.utils import delete_none_values
LOGGER = get_logger()

View File

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

View File

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

View File

@ -6,7 +6,7 @@ from rest_framework.test import APITestCase
from authentik.core.models import Application, ApplicationEntitlement, Group
from authentik.core.tests.utils import create_test_admin_user, create_test_flow, create_test_user
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import OAuth2Provider

View File

@ -9,7 +9,7 @@ from rest_framework.test import APITestCase
from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
from authentik.policies.dummy.models import DummyPolicy
from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode
@ -133,6 +133,8 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": {
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"assigned_backchannel_application_name": "",
"assigned_backchannel_application_slug": "",
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),
@ -186,6 +188,8 @@ class TestApplicationsAPI(APITestCase):
"provider_obj": {
"assigned_application_name": "allowed",
"assigned_application_slug": "allowed",
"assigned_backchannel_application_name": "",
"assigned_backchannel_application_slug": "",
"authentication_flow": None,
"invalidation_flow": None,
"authorization_flow": str(self.provider.authorization_flow.pk),

View File

@ -5,7 +5,7 @@ from json import loads
from django.urls.base import reverse
from rest_framework.test import APITestCase
from authentik.core.models import AuthenticatedSession, Session, User
from authentik.core.models import User
from authentik.core.tests.utils import create_test_admin_user
@ -30,18 +30,3 @@ class TestAuthenticatedSessionsAPI(APITestCase):
self.assertEqual(response.status_code, 200)
body = loads(response.content.decode())
self.assertEqual(body["pagination"]["count"], 1)
def test_delete(self):
"""Test deletion"""
self.client.force_login(self.user)
self.assertEqual(AuthenticatedSession.objects.all().count(), 1)
self.assertEqual(Session.objects.all().count(), 1)
response = self.client.delete(
reverse(
"authentik_api:authenticatedsession-detail",
kwargs={"uuid": AuthenticatedSession.objects.first().uuid},
)
)
self.assertEqual(response.status_code, 204)
self.assertEqual(AuthenticatedSession.objects.all().count(), 0)
self.assertEqual(Session.objects.all().count(), 0)

View File

@ -3,7 +3,7 @@
from django.test.testcases import TestCase
from authentik.core.models import Group, User
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
class TestGroups(TestCase):

View File

@ -6,7 +6,7 @@ from rest_framework.test import APITestCase
from authentik.core.models import Group
from authentik.core.tests.utils import create_test_admin_user, create_test_user
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
class TestGroupsAPI(APITestCase):

View File

@ -8,8 +8,8 @@ from django.utils.timezone import now
from freezegun import freeze_time
from guardian.shortcuts import get_anonymous_user
from authentik.common.utils.reflection import all_subclasses
from authentik.core.models import Provider, Source, Token
from authentik.lib.utils.reflection import all_subclasses
class TestModels(TestCase):

View File

@ -9,8 +9,8 @@ from authentik.core.expression.exceptions import (
)
from authentik.core.models import PropertyMapping
from authentik.core.tests.utils import create_test_admin_user
from authentik.crypto.generators import generate_id
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.policies.expression.models import ExpressionPolicy

View File

@ -9,7 +9,7 @@ from rest_framework.test import APITestCase
from authentik.core.api.property_mappings import PropertyMappingSerializer
from authentik.core.models import Group, PropertyMapping
from authentik.core.tests.utils import create_test_admin_user
from authentik.crypto.generators import generate_id
from authentik.lib.generators import generate_id
class TestPropertyMappingAPI(APITestCase):

View File

@ -3,7 +3,8 @@
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import PropertyMapping
from authentik.core.api.providers import ProviderSerializer
from authentik.core.models import Application, PropertyMapping, Provider
from authentik.core.tests.utils import create_test_admin_user
@ -24,3 +25,51 @@ class TestProvidersAPI(APITestCase):
reverse("authentik_api:provider-types"),
)
self.assertEqual(response.status_code, 200)
def test_provider_serializer_without_application(self):
"""Test that Provider serializer handles missing application gracefully"""
# Create a provider without an application
provider = Provider.objects.create(name="test-provider")
serializer = ProviderSerializer(instance=provider)
serialized_data = serializer.data
# Check that fields return empty strings when no application exists
self.assertEqual(serialized_data["assigned_application_slug"], "")
self.assertEqual(serialized_data["assigned_application_name"], "")
self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "")
self.assertEqual(serialized_data["assigned_backchannel_application_name"], "")
def test_provider_serializer_with_application(self):
"""Test that Provider serializer correctly includes application data"""
# Create an application
app = Application.objects.create(name="Test App", slug="test-app")
# Create a provider with an application
provider = Provider.objects.create(name="test-provider-with-app")
app.provider = provider
app.save()
serializer = ProviderSerializer(instance=provider)
serialized_data = serializer.data
# Check that fields return correct values when application exists
self.assertEqual(serialized_data["assigned_application_slug"], "test-app")
self.assertEqual(serialized_data["assigned_application_name"], "Test App")
self.assertEqual(serialized_data["assigned_backchannel_application_slug"], "")
self.assertEqual(serialized_data["assigned_backchannel_application_name"], "")
def test_provider_api_response(self):
"""Test that the API response includes empty strings for missing applications"""
# Create a provider without an application
provider = Provider.objects.create(name="test-provider-api")
response = self.client.get(
reverse("authentik_api:provider-detail", kwargs={"pk": provider.pk}),
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["assigned_application_slug"], "")
self.assertEqual(response.data["assigned_application_name"], "")
self.assertEqual(response.data["assigned_backchannel_application_slug"], "")
self.assertEqual(response.data["assigned_backchannel_application_name"], "")

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